feat: Add GET /media/sign and GET /users/by-handle/:handle endpoints

Both were wired in Flutter but missing from Go routes:
- GET /media/sign?path=X — resolves R2 relative keys to full URLs
- GET /users/by-handle/:handle — profile lookup for capsule invite flow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Patrick Britton 2026-02-17 15:53:57 -06:00
parent afdc0f3f1c
commit 6e2de2cd9d
3 changed files with 46 additions and 0 deletions

View file

@ -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)

View file

@ -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=<key> 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")

View file

@ -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")