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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user