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:
2026-06-06 19:23:22 +02:00
parent 4c08eba27a
commit 8f97590d9f
6 changed files with 530 additions and 21 deletions

View File

@@ -20,6 +20,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func main() {
@@ -46,6 +47,18 @@ func main() {
// BasePath wired without an admin restart.
startUserBasepathReconciler(cfg)
// Open a second DB handle pointed at PhotoPrism's own schema for
// handlers that need to query auth_users, photos, labels, etc.
// May be nil if PpDSN is empty (no PP_DB_PASSWORD set).
var ppDb *gorm.DB
if cfg.PpDSN != "" {
if d, err := openDB(cfg.PpDSN); err == nil {
ppDb = d
} else {
slog.Warn("pp db open failed — scoped labels/counts unavailable", "err", err)
}
}
gin.SetMode(gin.ReleaseMode)
r := gin.New()
// Keep `%2F` literal in path params so callers can pass URL-encoded
@@ -66,25 +79,31 @@ func main() {
// Every other endpoint runs behind the session gate. Mounting them
// under one group keeps the middleware wiring obvious.
auth := r.Group("/api/sidecar", requireSession(pp))
{
auth.GET("/photos/marks", handleMarksAll(db))
auth.GET("/photos/:uid/marks", handleMarkGet(db))
auth.PUT("/photos/:uid/marks", handleMarkPut(db))
auth.POST("/photos/marks/bulk", handleMarkBulk(db))
auth := r.Group("/api/sidecar", requireSession(pp))
{
auth.GET("/photos/marks", handleMarksAll(db))
auth.GET("/photos/:uid/marks", handleMarkGet(db))
auth.PUT("/photos/:uid/marks", handleMarkPut(db))
auth.POST("/photos/marks/bulk", handleMarkBulk(db))
auth.POST("/files/:uid/rename", handleRename(cfg, pp))
auth.POST("/files/:uid/rename", handleRename(cfg, pp))
auth.POST("/folders", handleFolderCreate(cfg, pp))
auth.POST("/folders/counts", handleFolderCounts(pp))
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp))
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
auth.POST("/folders", handleFolderCreate(cfg, pp))
auth.POST("/folders/counts", handleFolderCounts(pp))
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp))
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
auth.GET("/duplicates/scan", handleDupScan(cfg, pp))
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp))
}
auth.GET("/duplicates/scan", handleDupScan(cfg, pp))
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp))
// User-scoped proxies — require PpDSN connection.
if ppDb != nil {
auth.GET("/labels", handleLabels(pp, ppDb))
auth.GET("/counts", handleScopedCounts(ppDb))
}
}
addr := cfg.ListenAddr + ":" + itoa(cfg.Port)
srv := &http.Server{