diff --git a/go-backend/cmd/api/main.go b/go-backend/cmd/api/main.go index cf494bf..d42ce0a 100644 --- a/go-backend/cmd/api/main.go +++ b/go-backend/cmd/api/main.go @@ -398,6 +398,7 @@ func main() { // Media routes authorized.POST("/upload", mediaHandler.Upload) + authorized.GET("/media/sign", mediaHandler.GetSignedMediaURL) // Search & Discover routes discoverHandler := handlers.NewDiscoverHandler(userRepo, postRepo, tagRepo, categoryRepo, assetService) @@ -409,6 +410,9 @@ func main() { authorized.POST("/hashtags/:name/follow", discoverHandler.FollowHashtag) authorized.DELETE("/hashtags/:name/follow", discoverHandler.UnfollowHashtag) + // User by-handle lookup (used by capsule invite to resolve public keys) + authorized.GET("/users/by-handle/:handle", userHandler.GetUserByHandle) + // Follow System (unique routes only — followers/following covered by users group above) followHandler := handlers.NewFollowHandler(dbPool) authorized.POST("/users/:userId/unfollow", followHandler.UnfollowUser) diff --git a/go-backend/internal/handlers/media_handler.go b/go-backend/internal/handlers/media_handler.go index b3117a7..d4eb484 100644 --- a/go-backend/internal/handlers/media_handler.go +++ b/go-backend/internal/handlers/media_handler.go @@ -208,6 +208,32 @@ func (h *MediaHandler) putObjectS3(c *gin.Context, body io.ReadSeeker, contentLe return key, nil } +// GetSignedMediaURL resolves a relative R2 path to a fully-qualified URL. +// Flutter calls GET /media/sign?path= for any path that was stored as a relative key. +func (h *MediaHandler) GetSignedMediaURL(c *gin.Context) { + path := c.Query("path") + if path == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "path query parameter is required"}) + return + } + if strings.HasPrefix(path, "http") { + c.JSON(http.StatusOK, gin.H{"url": path}) + return + } + domain := h.publicDomain + if strings.Contains(path, "videos/") { + domain = h.videoDomain + } + if domain == "" { + c.JSON(http.StatusOK, gin.H{"url": path}) + return + } + if !strings.HasPrefix(domain, "http") { + domain = "https://" + domain + } + c.JSON(http.StatusOK, gin.H{"url": fmt.Sprintf("%s/%s", domain, path)}) +} + func (h *MediaHandler) putObjectR2API(c *gin.Context, fileBytes []byte, contentType string, bucket string, key string, publicDomain string) (string, error) { if h.accountID == "" || h.apiToken == "" { return "", fmt.Errorf("R2 API credentials missing") diff --git a/go-backend/internal/handlers/user_handler.go b/go-backend/internal/handlers/user_handler.go index 57be4cc..1db6ef5 100644 --- a/go-backend/internal/handlers/user_handler.go +++ b/go-backend/internal/handlers/user_handler.go @@ -663,6 +663,22 @@ func (h *UserHandler) BulkBlockUsers(c *gin.Context) { }) } +// GetUserByHandle resolves a public profile by @handle. +// Used by the capsule invite flow so the client can look up a user's public key before encrypting. +func (h *UserHandler) GetUserByHandle(c *gin.Context) { + handle := strings.TrimPrefix(strings.TrimSpace(c.Param("handle")), "@") + if handle == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "handle is required"}) + return + } + profile, err := h.repo.GetProfileByHandle(c.Request.Context(), handle) + if err != nil || profile == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + c.JSON(http.StatusOK, profile) +} + // ExportData streams user data as JSON for portability/GDPR compliance func (h *UserHandler) ExportData(c *gin.Context) { userID, _ := c.Get("user_id")