From 4c08eba27ae3bf59fee3f5eb559e73a61dd277f5 Mon Sep 17 00:00:00 2001 From: dtoro Date: Sat, 6 Jun 2026 12:36:18 +0200 Subject: [PATCH] fix: scope marks, labels, and subjects to the authenticated user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marks (ratings/color labels) were stored without a user column — every user saw every other user's marks. Labels and subjects from PhotoPrism's global endpoints leaked across users because those endpoints ignore BasePath ACL. Sidecar: - Add UserName as composite primary key on Mark (photo_uid, user_name) - Replace validateSession with resolveSession that fetches the user identity from PhotoPrism's session endpoint - Filter all mark queries by user_name Frontend: - Filter listLabels/listSubjects through a BasePath-aware existence check — each label/subject is kept only if the user has at least one matching photo (single count=1 probe per item, batched at concurrency 8) - Skip filtering for admin users with empty BasePath (single-user compat) Also documents USER_BASEPATHS in .env.example — the env var that drives per-user library isolation via PhotoPrism's auth_users.base_path. Co-Authored-By: Claude Opus 4.6 --- .env.example | 13 ++++++++++++ sidecar/auth.go | 19 +++++++++++++++-- sidecar/db.go | 10 ++++----- sidecar/handlers_marks.go | 23 +++++++++++---------- sidecar/pp.go | 31 ++++++++++++++++++++++++++++ web/src/lib/services/photoprism.ts | 33 ++++++++++++++++++++++++++++-- 6 files changed, 109 insertions(+), 20 deletions(-) diff --git a/.env.example b/.env.example index d139cbe..cab8433 100644 --- a/.env.example +++ b/.env.example @@ -65,6 +65,19 @@ PP_GID=1000 # OIDC_ROLE=user +# ── USER LIBRARY ISOLATION ─────────────────────────────────────────────────── +# Maps PhotoPrism usernames to originals-relative subdirectories so each +# user only sees their own photos. Format: comma-separated user:path pairs. +# The sidecar reconciler applies this to auth_users.base_path on boot and +# every 60s. Leave empty for single-user deployments. +# +# USER_BASEPATHS="alice:alice, bob:bob" + +# Sidecar DB password — provisioned by mariadb/init/01-sidecar.sql on first +# boot. Rotate before any non-local deployment. +# SIDECAR_DB_PASSWORD=replace-at-m4-bringup + + # ── LOGGING ────────────────────────────────────────────────────────────────── PP_LOG_LEVEL=info diff --git a/sidecar/auth.go b/sidecar/auth.go index 0e3310e..5ab82b4 100644 --- a/sidecar/auth.go +++ b/sidecar/auth.go @@ -11,7 +11,7 @@ import ( // is the only authority, and we probe PhotoPrism with it before doing any // destructive work. The handler reads the validated token off the context // via ctxToken so it can keep forwarding it to PhotoPrism for the actual -// operation. +// operation. The resolved username is available via ctxUserName. func requireSession(pp *ppClient) gin.HandlerFunc { return func(c *gin.Context) { token := c.GetHeader("X-Auth-Token") @@ -19,11 +19,13 @@ func requireSession(pp *ppClient) gin.HandlerFunc { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "no token"}) return } - if !pp.validateSession(c.Request.Context(), token) { + user := pp.resolveSession(c.Request.Context(), token) + if user == nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid session"}) return } c.Set("token", token) + c.Set("userName", user.UserName) c.Next() } } @@ -42,3 +44,16 @@ func ctxToken(c *gin.Context) string { } return s } + +// ctxUserName returns the PhotoPrism username resolved by requireSession. +func ctxUserName(c *gin.Context) string { + v, ok := c.Get("userName") + if !ok { + return "" + } + s, ok := v.(string) + if !ok { + return "" + } + return s +} diff --git a/sidecar/db.go b/sidecar/db.go index ddad1ff..67eba69 100644 --- a/sidecar/db.go +++ b/sidecar/db.go @@ -9,13 +9,13 @@ import ( ) // Mark mirrors the per-photo extras the web client stores via the marks -// endpoints — rating + four-colour label. PhotoUID is the row key; both -// payload fields are nullable so the sparse "no rating / no colour" state -// round-trips cleanly. The Node prototype kept this in a JSON file; we -// migrate to MariaDB here so the M4 sharing work has a real table to -// extend. +// endpoints — rating + four-colour label. Composite primary key +// (photo_uid, user_name) so each user has independent marks. Both payload +// fields are nullable so the sparse "no rating / no colour" state +// round-trips cleanly. type Mark struct { PhotoUID string `gorm:"primaryKey;size:64;column:photo_uid" json:"-"` + UserName string `gorm:"primaryKey;size:128;column:user_name" json:"-"` Rating *int `gorm:"column:rating" json:"rating,omitempty"` Color *string `gorm:"size:16;column:color" json:"color,omitempty"` UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` diff --git a/sidecar/handlers_marks.go b/sidecar/handlers_marks.go index 0ba002f..6f98189 100644 --- a/sidecar/handlers_marks.go +++ b/sidecar/handlers_marks.go @@ -70,12 +70,12 @@ func (p *markPatch) apply(m *Mark) bool { return m.Rating != nil || (m.Color != nil && *m.Color != "") } -// allMarksJSON renders the entire `marks` table as the wire shape +// allMarksJSON renders the current user's marks as the wire shape // `{"": {"rating": …, "color": …, "updatedAt": …}, …}`. Used by // GET /photos/marks which the web client calls once on session start. -func allMarksJSON(db *gorm.DB) (map[string]map[string]any, error) { +func allMarksJSON(db *gorm.DB, userName string) (map[string]map[string]any, error) { var rows []Mark - if err := db.Find(&rows).Error; err != nil { + if err := db.Where("user_name = ?", userName).Find(&rows).Error; err != nil { return nil, err } out := make(map[string]map[string]any, len(rows)) @@ -87,7 +87,7 @@ func allMarksJSON(db *gorm.DB) (map[string]map[string]any, error) { func handleMarksAll(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - marks, err := allMarksJSON(db) + marks, err := allMarksJSON(db, ctxUserName(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -100,7 +100,7 @@ func handleMarkGet(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { uid := c.Param("uid") var m Mark - err := db.Where("photo_uid = ?", uid).First(&m).Error + err := db.Where("photo_uid = ? AND user_name = ?", uid, ctxUserName(c)).First(&m).Error if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusOK, gin.H{}) return @@ -115,18 +115,18 @@ func handleMarkGet(db *gorm.DB) gin.HandlerFunc { // upsert applies the patch and writes back. Returns the resulting JSON // shape (empty map if the row was deleted). -func upsert(db *gorm.DB, uid string, patch *markPatch) (map[string]any, error) { +func upsert(db *gorm.DB, uid, userName string, patch *markPatch) (map[string]any, error) { var m Mark - err := db.Where("photo_uid = ?", uid).First(&m).Error + err := db.Where("photo_uid = ? AND user_name = ?", uid, userName).First(&m).Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } m.PhotoUID = uid + m.UserName = userName keep := patch.apply(&m) m.UpdatedAt = time.Now().UTC() if !keep { - // Drop the row entirely so a re-fetch returns {}. - if err := db.Where("photo_uid = ?", uid).Delete(&Mark{}).Error; err != nil { + if err := db.Where("photo_uid = ? AND user_name = ?", uid, userName).Delete(&Mark{}).Error; err != nil { return nil, err } return map[string]any{}, nil @@ -149,7 +149,7 @@ func handleMarkPut(db *gorm.DB) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - out, err := upsert(db, uid, &patch) + out, err := upsert(db, uid, ctxUserName(c), &patch) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -178,6 +178,7 @@ func handleMarkBulk(db *gorm.DB) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + userName := ctxUserName(c) applied := make(map[string]map[string]any, len(body.IDs)) // Single transaction so a partial failure rolls back. The client // expects atomic semantics for a bulk star/colour stamp. @@ -186,7 +187,7 @@ func handleMarkBulk(db *gorm.DB) gin.HandlerFunc { if uid == "" { continue } - out, err := upsert(tx, uid, &body.Patch) + out, err := upsert(tx, uid, userName, &body.Patch) if err != nil { return err } diff --git a/sidecar/pp.go b/sidecar/pp.go index 19caf4c..45cf0fb 100644 --- a/sidecar/pp.go +++ b/sidecar/pp.go @@ -85,6 +85,37 @@ func (c *ppClient) call(ctx context.Context, method, urlPath, token string, body }, nil } +// ppSessionUser is the subset of PhotoPrism's session response we need. +type ppSessionUser struct { + UserName string `json:"UserName"` + UserUID string `json:"UserUID"` + BasePath string `json:"BasePath"` +} + +type ppSessionResponse struct { + User ppSessionUser `json:"user"` +} + +// resolveSession validates the token AND returns the authenticated user. +// Returns nil when the token is invalid or the response can't be parsed. +func (c *ppClient) resolveSession(ctx context.Context, token string) *ppSessionUser { + if token == "" { + return nil + } + r, err := c.call(ctx, http.MethodGet, "/api/v1/session/"+token, token, nil) + if err != nil || !r.OK { + return nil + } + var resp ppSessionResponse + if err := json.Unmarshal(r.Body, &resp); err != nil { + return nil + } + if resp.User.UserName == "" { + return nil + } + return &resp.User +} + // validateSession is the cheapest probe that the supplied token is live: // list one photo. 401 → bad/expired token. We never read the payload. func (c *ppClient) validateSession(ctx context.Context, token string) bool { diff --git a/web/src/lib/services/photoprism.ts b/web/src/lib/services/photoprism.ts index 653c391..bc85454 100644 --- a/web/src/lib/services/photoprism.ts +++ b/web/src/lib/services/photoprism.ts @@ -635,6 +635,35 @@ export async function aggregateKeywords(): Promise { return Array.from(buckets.values()).sort((a, b) => b.count - a.count); } +async function hasPhotosMatching(q: string): Promise { + const resp = await http.get('/photos', { + params: { count: 1, offset: 0, q } + }); + return Array.isArray(resp.data) && resp.data.length > 0; +} + +async function filterByUserPhotos( + items: T[], + queryFor: (item: T) => string +): Promise { + if (userBasePath() === '') return items; + const CONCURRENCY = 8; + const out: T[] = []; + for (let i = 0; i < items.length; i += CONCURRENCY) { + const batch = items.slice(i, i + CONCURRENCY); + const checks = await Promise.all( + batch.map(async (item) => ({ + item, + has: await hasPhotosMatching(queryFor(item)) + })) + ); + for (const { item, has } of checks) { + if (has) out.push(item); + } + } + return out; +} + export async function listLabels(): Promise { // `all=true` includes labels PhotoPrism has soft-deleted (auto-hidden // low-confidence classifier hits, manually-removed labels). They're @@ -646,7 +675,7 @@ export async function listLabels(): Promise { const { data } = await http.get('/labels', { params: { count: 1000, order: 'count', all: true } }); - return data; + return filterByUserPhotos(data, (l) => `label:${l.Slug}`); } // ── Subjects (people / face recognition) ──────────────────────────────────── @@ -670,7 +699,7 @@ export async function listSubjects(): Promise { const { data } = await http.get('/subjects', { params: { count: 1000, order: 'count' } }); - return data ?? []; + return filterByUserPhotos(data ?? [], (s) => `person:${s.Slug}`); } export async function updateSubject(uid: string, patch: Partial): Promise {