sidecar: scoped labels + counts proxy (fixes cross-user label leak)
- New GET /api/sidecar/labels — proxies PP's labels, recalculates
PhotoCount per user's BasePath via DB query
- New GET /api/sidecar/counts — returns user-scoped sidebar badges
(all, review, archived, private, photos, videos, favorites)
- Fixed auth middleware to expose userUID and basePath on context
- Fixed ppClient.resolveSession — uses correct endpoint
(GET /api/v1/session, not /api/v1/session/{token}) and correct
JSON field names (UID, Name instead of UserUID, UserName)
- Frontend: listLabels now calls /api/sidecar/labels instead of /api/v1/labels
This commit is contained in:
148
sidecar/handlers_labels.go
Normal file
148
sidecar/handlers_labels.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PpLabel mirrors the shape PhotoPrism's /api/v1/labels endpoint returns.
|
||||
// We decode enough to filter + recalculate PhotoCount; fields the client
|
||||
// doesn't render are skipped for token efficiency.
|
||||
type PpLabel struct {
|
||||
UID string `json:"UID"`
|
||||
Name string `json:"Name"`
|
||||
Slug string `json:"Slug"`
|
||||
CustomSlug string `json:"CustomSlug"`
|
||||
Priority int `json:"Priority"`
|
||||
Favorite bool `json:"Favorite"`
|
||||
PhotoCount int `json:"PhotoCount"`
|
||||
Thumb string `json:"Thumb"`
|
||||
CreatedAt string `json:"CreatedAt"`
|
||||
UpdatedAt string `json:"UpdatedAt"`
|
||||
}
|
||||
|
||||
// handleLabels proxies PhotoPrism's /api/v1/labels and then post-filters
|
||||
// each label's PhotoCount (and removes labels with zero count) so they
|
||||
// reflect only photos under the caller's BasePath.
|
||||
//
|
||||
// Route: GET /api/sidecar/labels (behind requireSession)
|
||||
func handleLabels(pp *ppClient, ppDb *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
basePath := ctxBasePath(c)
|
||||
|
||||
// Forward the query string (count, offset, q, all, …) to PhotoPrism.
|
||||
query := c.Request.URL.RawQuery
|
||||
|
||||
// Call PhotoPrism's labels endpoint using the caller's token.
|
||||
resp, err := pp.call(c.Request.Context(), http.MethodGet, "/api/v1/labels?"+query, token, nil)
|
||||
if err != nil || !resp.OK {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream labels request failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Decode labels.
|
||||
var labels []PpLabel
|
||||
if err := json.Unmarshal(resp.Body, &labels); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse labels"})
|
||||
return
|
||||
}
|
||||
|
||||
// If the user has no BasePath (admin/empty), return labels as-is.
|
||||
if basePath == "" || ppDb == nil {
|
||||
c.JSON(http.StatusOK, labels)
|
||||
return
|
||||
}
|
||||
|
||||
// For each label, count how many photos under this user's BasePath
|
||||
// have that label. Remove labels with zero count for this user.
|
||||
filtered := make([]PpLabel, 0, len(labels))
|
||||
prefix := basePath + "/%"
|
||||
|
||||
for _, l := range labels {
|
||||
var cnt int64
|
||||
if err := ppDb.Raw(
|
||||
`SELECT COUNT(*) FROM photos_labels pl
|
||||
JOIN photos p ON pl.photo_id = p.id
|
||||
JOIN labels lb ON pl.label_id = lb.id
|
||||
WHERE lb.label_uid = ?
|
||||
AND (p.photo_path = ? OR p.photo_path LIKE ?)
|
||||
AND p.deleted_at IS NULL`,
|
||||
l.UID, basePath, prefix,
|
||||
).Count(&cnt).Error; err != nil {
|
||||
// On DB error, skip this label rather than failing the whole response.
|
||||
continue
|
||||
}
|
||||
if cnt == 0 {
|
||||
continue
|
||||
}
|
||||
l.PhotoCount = int(cnt)
|
||||
filtered = append(filtered, l)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, filtered)
|
||||
}
|
||||
}
|
||||
|
||||
// Now also handle the session/config count scoping.
|
||||
|
||||
// PpCounts mirrors PhotoPrism's session config.count block that drives
|
||||
// the sidebar badges (review, archive, all, etc.).
|
||||
type PpCounts struct {
|
||||
All int `json:"all"`
|
||||
Photos int `json:"photos"`
|
||||
Media int `json:"media"`
|
||||
Videos int `json:"videos"`
|
||||
Review int `json:"review"`
|
||||
Archived int `json:"archived"`
|
||||
Hidden int `json:"hidden"`
|
||||
Private int `json:"private"`
|
||||
Favorites int `json:"favorites"`
|
||||
}
|
||||
|
||||
// handleScopedCounts returns user-scoped counts for review/archive/all
|
||||
// so the sidebar badges match what the user actually sees.
|
||||
//
|
||||
// Route: GET /api/sidecar/counts (behind requireSession)
|
||||
func handleScopedCounts(ppDb *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
basePath := ctxBasePath(c)
|
||||
if basePath == "" || ppDb == nil {
|
||||
// Admin or no DB — can't scope, return empty.
|
||||
c.JSON(http.StatusOK, PpCounts{})
|
||||
return
|
||||
}
|
||||
|
||||
prefix := basePath + "/%"
|
||||
pathCond := "(p.photo_path = ? OR p.photo_path LIKE ?)"
|
||||
args := []any{basePath, prefix}
|
||||
|
||||
var counts PpCounts
|
||||
|
||||
// All non-deleted photos in this user's scope.
|
||||
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND `+pathCond, args...).Scan(&counts.All)
|
||||
|
||||
// Photos needing review (quality < 3).
|
||||
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_quality < 3 AND `+pathCond, args...).Scan(&counts.Review)
|
||||
|
||||
// Archived (soft-deleted) photos.
|
||||
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NOT NULL AND `+pathCond, args...).Scan(&counts.Archived)
|
||||
|
||||
// Private photos.
|
||||
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_private = 1 AND `+pathCond, args...).Scan(&counts.Private)
|
||||
|
||||
// Photos (type image).
|
||||
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_type IN ('image','raw','live','animated') AND `+pathCond, args...).Scan(&counts.Photos)
|
||||
|
||||
// Videos.
|
||||
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_type IN ('video','hdr','burst','live') AND `+pathCond, args...).Scan(&counts.Videos)
|
||||
|
||||
// Favorites.
|
||||
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_favorite = 1 AND `+pathCond, args...).Scan(&counts.Favorites)
|
||||
|
||||
c.JSON(http.StatusOK, counts)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user