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:
@@ -26,6 +26,8 @@ func requireSession(pp *ppClient) gin.HandlerFunc {
|
||||
}
|
||||
c.Set("token", token)
|
||||
c.Set("userName", user.UserName)
|
||||
c.Set("userUID", user.UserUID)
|
||||
c.Set("basePath", user.BasePath)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -57,3 +59,29 @@ func ctxUserName(c *gin.Context) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ctxUserUID returns the PhotoPrism user UID resolved by requireSession.
|
||||
func ctxUserUID(c *gin.Context) string {
|
||||
v, ok := c.Get("userUID")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ctxBasePath returns the PhotoPrism user BasePath resolved by requireSession.
|
||||
func ctxBasePath(c *gin.Context) string {
|
||||
v, ok := c.Get("basePath")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
@@ -87,8 +88,8 @@ func (c *ppClient) call(ctx context.Context, method, urlPath, token string, body
|
||||
|
||||
// ppSessionUser is the subset of PhotoPrism's session response we need.
|
||||
type ppSessionUser struct {
|
||||
UserName string `json:"UserName"`
|
||||
UserUID string `json:"UserUID"`
|
||||
UserUID string `json:"UID"`
|
||||
UserName string `json:"Name"`
|
||||
BasePath string `json:"BasePath"`
|
||||
}
|
||||
|
||||
@@ -102,15 +103,22 @@ func (c *ppClient) resolveSession(ctx context.Context, token string) *ppSessionU
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
r, err := c.call(ctx, http.MethodGet, "/api/v1/session/"+token, token, nil)
|
||||
if err != nil || !r.OK {
|
||||
r, err := c.call(ctx, http.MethodGet, "/api/v1/session", token, nil)
|
||||
if err != nil {
|
||||
slog.Warn("resolveSession: call failed", "err", err)
|
||||
return nil
|
||||
}
|
||||
if !r.OK {
|
||||
slog.Warn("resolveSession: not OK", "status", r.Status, "body", string(r.Body[:min(len(r.Body), 200)]))
|
||||
return nil
|
||||
}
|
||||
var resp ppSessionResponse
|
||||
if err := json.Unmarshal(r.Body, &resp); err != nil {
|
||||
slog.Warn("resolveSession: unmarshal failed", "err", err, "body", string(r.Body[:min(len(r.Body), 200)]))
|
||||
return nil
|
||||
}
|
||||
if resp.User.UserName == "" {
|
||||
slog.Warn("resolveSession: empty username", "body", string(r.Body[:min(len(r.Body), 200)]))
|
||||
return nil
|
||||
}
|
||||
return &resp.User
|
||||
|
||||
Reference in New Issue
Block a user