fix: scope marks, labels, and subjects to the authenticated user

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 12:36:18 +02:00
parent 6c96c22b33
commit 4c08eba27a
6 changed files with 109 additions and 20 deletions

View File

@@ -65,6 +65,19 @@ PP_GID=1000
# OIDC_ROLE=user # 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 ────────────────────────────────────────────────────────────────── # ── LOGGING ──────────────────────────────────────────────────────────────────
PP_LOG_LEVEL=info PP_LOG_LEVEL=info

View File

@@ -11,7 +11,7 @@ import (
// is the only authority, and we probe PhotoPrism with it before doing any // is the only authority, and we probe PhotoPrism with it before doing any
// destructive work. The handler reads the validated token off the context // destructive work. The handler reads the validated token off the context
// via ctxToken so it can keep forwarding it to PhotoPrism for the actual // 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 { func requireSession(pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
token := c.GetHeader("X-Auth-Token") 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"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "no token"})
return 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"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid session"})
return return
} }
c.Set("token", token) c.Set("token", token)
c.Set("userName", user.UserName)
c.Next() c.Next()
} }
} }
@@ -42,3 +44,16 @@ func ctxToken(c *gin.Context) string {
} }
return s 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
}

View File

@@ -9,13 +9,13 @@ import (
) )
// Mark mirrors the per-photo extras the web client stores via the marks // Mark mirrors the per-photo extras the web client stores via the marks
// endpoints — rating + four-colour label. PhotoUID is the row key; both // endpoints — rating + four-colour label. Composite primary key
// payload fields are nullable so the sparse "no rating / no colour" state // (photo_uid, user_name) so each user has independent marks. Both payload
// round-trips cleanly. The Node prototype kept this in a JSON file; we // fields are nullable so the sparse "no rating / no colour" state
// migrate to MariaDB here so the M4 sharing work has a real table to // round-trips cleanly.
// extend.
type Mark struct { type Mark struct {
PhotoUID string `gorm:"primaryKey;size:64;column:photo_uid" json:"-"` 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"` Rating *int `gorm:"column:rating" json:"rating,omitempty"`
Color *string `gorm:"size:16;column:color" json:"color,omitempty"` Color *string `gorm:"size:16;column:color" json:"color,omitempty"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`

View File

@@ -70,12 +70,12 @@ func (p *markPatch) apply(m *Mark) bool {
return m.Rating != nil || (m.Color != nil && *m.Color != "") 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
// `{"<uid>": {"rating": …, "color": …, "updatedAt": …}, …}`. Used by // `{"<uid>": {"rating": …, "color": …, "updatedAt": …}, …}`. Used by
// GET /photos/marks which the web client calls once on session start. // 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 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 return nil, err
} }
out := make(map[string]map[string]any, len(rows)) 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 { func handleMarksAll(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
marks, err := allMarksJSON(db) marks, err := allMarksJSON(db, ctxUserName(c))
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -100,7 +100,7 @@ func handleMarkGet(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
uid := c.Param("uid") uid := c.Param("uid")
var m Mark 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) { if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusOK, gin.H{}) c.JSON(http.StatusOK, gin.H{})
return return
@@ -115,18 +115,18 @@ func handleMarkGet(db *gorm.DB) gin.HandlerFunc {
// upsert applies the patch and writes back. Returns the resulting JSON // upsert applies the patch and writes back. Returns the resulting JSON
// shape (empty map if the row was deleted). // 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 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) { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err return nil, err
} }
m.PhotoUID = uid m.PhotoUID = uid
m.UserName = userName
keep := patch.apply(&m) keep := patch.apply(&m)
m.UpdatedAt = time.Now().UTC() m.UpdatedAt = time.Now().UTC()
if !keep { if !keep {
// Drop the row entirely so a re-fetch returns {}. if err := db.Where("photo_uid = ? AND user_name = ?", uid, userName).Delete(&Mark{}).Error; err != nil {
if err := db.Where("photo_uid = ?", uid).Delete(&Mark{}).Error; err != nil {
return nil, err return nil, err
} }
return map[string]any{}, nil 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()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
out, err := upsert(db, uid, &patch) out, err := upsert(db, uid, ctxUserName(c), &patch)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -178,6 +178,7 @@ func handleMarkBulk(db *gorm.DB) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
userName := ctxUserName(c)
applied := make(map[string]map[string]any, len(body.IDs)) applied := make(map[string]map[string]any, len(body.IDs))
// Single transaction so a partial failure rolls back. The client // Single transaction so a partial failure rolls back. The client
// expects atomic semantics for a bulk star/colour stamp. // expects atomic semantics for a bulk star/colour stamp.
@@ -186,7 +187,7 @@ func handleMarkBulk(db *gorm.DB) gin.HandlerFunc {
if uid == "" { if uid == "" {
continue continue
} }
out, err := upsert(tx, uid, &body.Patch) out, err := upsert(tx, uid, userName, &body.Patch)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -85,6 +85,37 @@ func (c *ppClient) call(ctx context.Context, method, urlPath, token string, body
}, nil }, 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: // validateSession is the cheapest probe that the supplied token is live:
// list one photo. 401 → bad/expired token. We never read the payload. // list one photo. 401 → bad/expired token. We never read the payload.
func (c *ppClient) validateSession(ctx context.Context, token string) bool { func (c *ppClient) validateSession(ctx context.Context, token string) bool {

View File

@@ -635,6 +635,35 @@ export async function aggregateKeywords(): Promise<AggregatedKeyword[]> {
return Array.from(buckets.values()).sort((a, b) => b.count - a.count); return Array.from(buckets.values()).sort((a, b) => b.count - a.count);
} }
async function hasPhotosMatching(q: string): Promise<boolean> {
const resp = await http.get<PpPhoto[]>('/photos', {
params: { count: 1, offset: 0, q }
});
return Array.isArray(resp.data) && resp.data.length > 0;
}
async function filterByUserPhotos<T>(
items: T[],
queryFor: (item: T) => string
): Promise<T[]> {
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<PpLabel[]> { export async function listLabels(): Promise<PpLabel[]> {
// `all=true` includes labels PhotoPrism has soft-deleted (auto-hidden // `all=true` includes labels PhotoPrism has soft-deleted (auto-hidden
// low-confidence classifier hits, manually-removed labels). They're // low-confidence classifier hits, manually-removed labels). They're
@@ -646,7 +675,7 @@ export async function listLabels(): Promise<PpLabel[]> {
const { data } = await http.get<PpLabel[]>('/labels', { const { data } = await http.get<PpLabel[]>('/labels', {
params: { count: 1000, order: 'count', all: true } params: { count: 1000, order: 'count', all: true }
}); });
return data; return filterByUserPhotos(data, (l) => `label:${l.Slug}`);
} }
// ── Subjects (people / face recognition) ──────────────────────────────────── // ── Subjects (people / face recognition) ────────────────────────────────────
@@ -670,7 +699,7 @@ export async function listSubjects(): Promise<PpSubject[]> {
const { data } = await http.get<PpSubject[]>('/subjects', { const { data } = await http.get<PpSubject[]>('/subjects', {
params: { count: 1000, order: 'count' } params: { count: 1000, order: 'count' }
}); });
return data ?? []; return filterByUserPhotos(data ?? [], (s) => `person:${s.Slug}`);
} }
export async function updateSubject(uid: string, patch: Partial<PpSubject>): Promise<PpSubject> { export async function updateSubject(uid: string, patch: Partial<PpSubject>): Promise<PpSubject> {