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

@@ -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
// `{"<uid>": {"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
}