package main import ( "encoding/json" "fmt" "net/http" "strings" "github.com/gin-gonic/gin" ) // handlePhotos proxies PhotoPrism's /api/v1/photos and then post-filters // the response so only photos under the caller's BasePath are returned. // This fixes the review/archive tab cross-user leak. // // Route: GET /api/sidecar/timeline (behind requireSession) func handlePhotos(pp *ppClient) gin.HandlerFunc { return func(c *gin.Context) { token := ctxToken(c) basePath := ctxBasePath(c) // Forward the raw query string to PhotoPrism. query := c.Request.URL.RawQuery resp, err := pp.call(c.Request.Context(), http.MethodGet, "/api/v1/photos?"+query, token, nil) if err != nil || !resp.OK { c.JSON(http.StatusBadGateway, gin.H{"error": "upstream photos request failed"}) return } // Decode as a generic array so we can inspect Path without // committing to a rigid struct (PhotoPrism's photo response // varies between list/detail/search endpoints). var photos []map[string]any if err := json.Unmarshal(resp.Body, &photos); err != nil { // If it's not an array (e.g. error, single object), pass through. c.Data(resp.Status, "application/json", resp.Body) return } // If the user has no BasePath (admin/empty), return as-is. if basePath == "" { // Forward PhotoPrism's X-Count header for countPhotos(). if count := resp.Header.Get("X-Count"); count != "" { c.Header("X-Count", count) } c.JSON(http.StatusOK, photos) return } prefix := basePath + "/" // Post-filter by FileName field (originals-relative path). filtered := make([]map[string]any, 0, len(photos)) for _, ph := range photos { rawPath, ok := ph["FileName"] if !ok { continue } pathStr, ok := rawPath.(string) if !ok { continue } // Match exact basePath or basePath/... if pathStr == basePath || strings.HasPrefix(pathStr, prefix) { filtered = append(filtered, ph) } } // Forward X-Count header adjusted to the filtered count. c.Header("X-Count", itoa(len(filtered))) c.JSON(http.StatusOK, filtered) } } // handleNotes pages PhotoPrism's photo list to completion and returns only // photos carrying a non-empty Caption (mule-image's "Note"), scoped to the // caller's BasePath. Paging server-side is what makes this correct: the // client can't tell when the *BasePath-filtered* list is exhausted (a full // upstream page can filter down to a short — or empty — slice), but here we // can key the loop off the raw upstream page length. // // Route: GET /api/sidecar/notes (behind requireSession) func handleNotes(pp *ppClient) gin.HandlerFunc { return func(c *gin.Context) { token := ctxToken(c) basePath := ctxBasePath(c) prefix := basePath + "/" const pageSize = 1000 out := make([]map[string]any, 0, 64) seen := make(map[string]struct{}) for offset := 0; ; offset += pageSize { path := fmt.Sprintf("/api/v1/photos?count=%d&offset=%d&merged=true&order=newest", pageSize, offset) resp, err := pp.call(c.Request.Context(), http.MethodGet, path, token, nil) if err != nil || !resp.OK { c.JSON(http.StatusBadGateway, gin.H{"error": "upstream photos request failed"}) return } var photos []map[string]any if err := json.Unmarshal(resp.Body, &photos); err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "unexpected photos response"}) return } rawLen := len(photos) for _, ph := range photos { // BasePath scope — same rule as handlePhotos. if basePath != "" { pathStr, _ := ph["FileName"].(string) if pathStr != basePath && !strings.HasPrefix(pathStr, prefix) { continue } } // Non-empty caption only. caption, _ := ph["Caption"].(string) if strings.TrimSpace(caption) == "" { continue } // Dedupe by UID — `merged` can still repeat a photo at a page seam. uid, _ := ph["UID"].(string) if uid != "" { if _, ok := seen[uid]; ok { continue } seen[uid] = struct{}{} } out = append(out, ph) } // A short upstream page means PhotoPrism has no more rows. if rawLen < pageSize { break } } c.JSON(http.StatusOK, out) } }