Files
mule-image/.hermes/plans/2026-06-06_124500-label-review-archive-isolation.md
dtoro 8f97590d9f 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
2026-06-06 19:23:22 +02:00

14 KiB

Plan: Fix user isolation in PhotoPrism — labels, review, and archive views

Date: 2026-06-06
Author: Hermes Agent
Status: Draft


1. Goal

Fix the three views where a user with base_path set (e.g. muli) sees photos from other users (e.g. dtoro):

  1. Labels — labels list + label drill-down show all library photos
  2. Review — photos needing review tab shows cross-user photos
  3. Archive — archived photos tab shows cross-user photos

2. Current Context & Source Analysis

2.1 How base_path scoping works

PhotoPrism's ScopePhotosForSession (in internal/entity/search/photos_scope.go) is the only function that enforces user isolation. It adds WHERE photos.photo_path = '<basePath>' OR photos.photo_path LIKE '<basePath>/%' to the SQL query.

This is called by searchPhotos() — used by the GET /api/v1/photos endpoint (search, timeline, folders).

2.2 How endpoints use base_path

View Endpoint Function chain Applies base_path?
Main timeline GET /api/v1/photos SearchPhotossearchPhotosScopePhotosForSession Yes
Folders GET /api/v1/photos with path filter Same chain Yes
Labels GET /api/v1/labels SearchLabelssearch.Labels(frm)no session No — queries labels table directly
Label drill-down GET /api/v1/photos?label=X Uses searchPhotosScopePhotosForSession Should scope (if label= param doesn't bypass)
Review tab GET /api/v1/photos?q=review:true searchPhotosScopePhotosForSession Should scope, BUT...
Archive tab GET /api/v1/photos?q=archived:true Same Should scope, BUT...

2.3 The review/archive problem: ACL overrides

In searchPhotosForm() (internal/api/photos_search.go):

if acl.Rules.Deny(acl.ResourcePhotos, s.GetUserRole(), acl.ActionManage) {
    frm.Quality = 3
}

For role=user, Deny(ActionManage) → true → sets frm.Quality = 3 (minimum quality).

Then in searchPhotos() (internal/entity/search/photos.go):

if acl.Rules.Deny(acl.ResourcePhotos, aclRole, acl.ActionDelete) {
    frm.Archived = false
    frm.Review = false
}

For role=user, Deny(ActionDelete) → true → overrides review:true and archived:true to false.

So the review and archive filters are completely ignored for the user role. The frontend sends review:true but the server discards it. The result: the review/archive tabs show ALL photos scoped by base_path (no quality/review/archive filter), which means basically the same as the main timeline.

2.4 Label problem: no session scoping at all

search.Labels() queries the labels table directly with a WHERE photo_count > 0 clause. There is no session parameter, no ScopePhotosForSession, and no base_path or user filtering whatsoever. Labels are library-wide in PhotoPrism.

The label drill-down (click into a label) uses GET /api/v1/photos?label=X which DOES go through ScopePhotosForSession, so the photo list itself should be scoped — but the label thumbnails, counts, and covers are global.

2.5 ACL GrantDefaults — missing RoleUser entry

var GrantDefaults = Roles{
    RoleAdmin:    GrantFullAccess,   // FullAccess = AccessLibrary + everything
    RoleGuest:    GrantReactShared,  // Only shared content
    RoleVisitor:  GrantViewShared,   // Only shared content
    RolePortal:   GrantFullAccess,
    RoleClient:   GrantFullAccess,
    // RoleUser and RoleViewer are NOT listed → fallback to RoleDefault (also missing) → denied
}

Because RoleUser is absent from GrantDefaults, the Allow() function falls back to RoleDefault which is also absent → returns false for all permissions. This means:

  • ScopePhotosForSession correctly enters the base_path branch (good — user is isolated)
  • BUT ActionDelete is denied → review/archive filters are forced off (bad — can't browse review/archive)

2.6 The photos_users table — ruled out

internal/entity/photo_user.go defines PhotoUser but it is not referenced in ScopePhotosForSession, searchPhotos, or any label/album search function. Populating it won't fix any of these issues.

3. Proposed Approach

Phase 1: Sidecar proxy for labels (direct fix)

Extend the sidecar to expose a scoped labels endpoint:

GET /api/sidecar/labels → proxies to GET /api/v1/labels → filters by base_path

How it works:

  1. Sidecar receives the caller's X-Auth-Token
  2. requireSession middleware resolves the token → returns user's BasePath
  3. Sidecar makes the same /api/v1/labels request to PhotoPrism (using the caller's token)
  4. Filter step: for each label in the response, query the DB to count photos with that label AND photo_path LIKE '<base_path>/%'
  5. Return filtered labels with corrected PhotoCount and Thumb

Implementation:

New file sidecar/handlers_labels.go:

// handleLabels proxies to PP's /api/v1/labels, then post-filters
// counts and thumbnails by the caller's base_path.
func handleLabels(pp *ppClient, ppDSN string) gin.HandlerFunc {
    return func(c *gin.Context) {
        token := ctxToken(c)
        user := ctxUser(c) // resolved from session, includes BasePath
        
        // 1. Get raw labels from PhotoPrism
        resp, _ := pp.call(c, "GET", "/api/v1/labels?"+c.Request.URL.RawQuery, token, nil)
        
        // 2. Decode labels
        var labels []PpLabel
        json.Unmarshal(resp.Body, &labels)
        
        // 3. For each label, recalculate count for this user's base_path
        for i, l := range labels {
            // Count photos with this label AND where photo_path matches base_path
            var count int
            db.Raw(`SELECT COUNT(*) FROM photos_labels pl 
                     JOIN photos p ON pl.photo_uid = p.photo_uid 
                     WHERE pl.label_uid = ? AND p.photo_path LIKE ?`,
                l.UID, user.BasePath+"/%").Scan(&count)
            labels[i].PhotoCount = count
            
            // If count is 0, the thumb from the global label doesn't apply
            // Could also update thumb to a user-scoped one
        }
        
        c.JSON(http.StatusOK, labels)
    }
}

Frontend change: Update the label query in web/src/routes/tags/[category]/[[value]]/+page.svelte to call /api/sidecar/labels instead of /api/v1/labels.

Phase 2: Fix review/archive — skip the ACL override

Two options:

Option A (Recommended): Sidecar proxy for photos search

Extend the sidecar with:

GET /api/sidecar/photos → proxies to GET /api/v1/photos → adds path filter

The sidecar intercepts the photos request and adds the path:<basePath> query parameter to PhotoPrism's API call. This forces PhotoPrism to add WHERE photo_path LIKE '<base_path>/%'.

For review/archive, the sidecar also adds review:true or archived:true BEFORE the ACL override happens (since the sidecar doesn't hit the ACL code).

Option B: Custom frontend query

The frontend explicitly adds path:muli to the query string for review/archive tabs:

GET /api/v1/photos?q=review:true path:muli&count=50

The path filter is a standard PhotoPrism search operator that adds WHERE photos.photo_path = '<path>'. But this only matches the exact path, not path/% (subdirectories). The path: operator does photo_path = ? (exact match) per the code at line 668.

Option A is better because:

  • Works for all users without frontend changes
  • Can add the proper LIKE prefix match
  • Centralized logic in the sidecar

Phase 3: Sidecar proxy for sidebar counts

The session response (or GET /api/v1/config) includes library-wide counts:

"count": {
    "review": 248,
    "archived": 94,
    "all": 88203,
    "photos": 88000
}

These show the TOTAL across all users. The sidecar can proxy this and recalculate counts per base_path.

4. Step-by-step Plan

Step 1: Sidecar — labels proxy

Files: sidecar/handlers_labels.go (new), sidecar/main.go (route wiring)

  1. New types: PpLabel (mirrors PhotoPrism's label response shape)
  2. Handler function handleLabels() that:
    • Validates token via requireSession
    • Gets BasePath from session
    • Calls PhotoPrism's /api/v1/labels
    • For each label, queries photos_labels + photos to count user-scoped photos
    • Returns filtered labels
  3. Wire route: auth.GET("/labels", handleLabels(...)) in main.go
  4. Frontend: change label fetch URL from /api/v1/labels to /api/sidecar/labels

Step 2: Sidecar — photos proxy (review/archive fix)

Files: sidecar/handlers_photos.go (new), sidecar/main.go (route wiring)

  1. Handler function handlePhotos() that:

    • Validates token
    • Gets BasePath from session
    • Parses the query string to detect review:true or archived:true
    • Forwards to PhotoPrism's /api/v1/photos with path:<basePath> added to query
    • For review/archive, also ensures review/archived filter is NOT stripped
    • Returns PhotoPrism's response
  2. Two implementation variants:

    Variant A (simpler): add path:<basePath> to the forwarded query. This only matches exact path, not subdirs (PhotoPrism's path: operator does exact match). Might miss photos in subdirectories.

    Variant B (correct): Forward the query without path, then post-filter the response to remove photos whose photo_path doesn't match basePath/%. This is more robust.

Step 3: Validation

  1. Build sidecar: cd sidecar && CGO_ENABLED=0 go build -o mule-sidecar .
  2. Rebuild Docker image: docker compose build sidecar
  3. Restart sidecar: docker compose up -d sidecar
  4. Test labels as muli — verify only muli's labels appear
  5. Test review tab as muli — verify only muli's photos needing review appear
  6. Test archive tab as muli — verify only muli's archived photos appear
  7. Test same views as admin — verify dtoro still sees all

5. Files Likely to Change

File Change
sidecar/handlers_labels.go New — label proxy handler
sidecar/handlers_photos.go New — photos proxy handler (or merged into one proxy.go)
sidecar/handlers_folder.go Reference for existing handler patterns
sidecar/main.go Wire new routes under auth group
sidecar/pp.go May need new helper methods for label/photo API calls
sidecar/users.go No change
sidecar/db.go May add types for PpLabel, PpPhoto
web/src/routes/tags/[category]/[[value]]/+page.svelte Change label fetch URL
web/src/lib/stores/filters.svelte.ts Possibly change how review/archive queries are built

6. Tests & Validation

Build: cd sidecar && go build ./... && go vet ./...

Manual validation on LXC 120:

# Test labels endpoint
curl -s "http://localhost:8000/api/sidecar/labels?count=5" \
  -H "X-Auth-Token: <muli-token>" | python3 -c "import sys,json;d=json.load(sys.stdin);[print(l.get('Name','?'),l.get('PhotoCount')) for l in d[:5]]"

# Test photos endpoint with review
curl -s "http://localhost:8000/api/sidecar/photos?q=review:true&count=5" \
  -H "X-Auth-Token: <muli-token>" | python3 -c "import sys,json;d=json.load(sys.stdin);print(f'{len(d)} photos')"

# Verify vs. admin token — counts should differ

Cross-user check: Log in as muli and dtoro in separate browser sessions. Verify:

  • Labels show different counts per user
  • Review photos are scoped per user
  • Archive photos are scoped per user

7. Risks, Tradeoffs & Open Questions

Risks

Risk Impact Mitigation
Sidecar proxying adds latency Slower page loads Labels are small payloads; single DB query per label is fast
Frontend needs URL changes Breaks if not updated Do frontend change alongside sidecar deploy
Photo count queries on every label request DB load Cache results for 30s in the sidecar
PhotoPrism's label PhotoCount is stale Mismatch with actual count Acceptable — PhotoPrism's count is already cached
Review/archive fix depends on how PhotoPrism handles path: operator Photos in subdirs missed Use Variant B (post-filter by path prefix)

Open Questions

  • Q1: For review/archive — is the user seeing dtoro's photos in the grid or only the sidebar counts? Need to verify actual API response vs what the frontend renders.
  • Q2: What's the performance impact of running SELECT COUNT(*) FROM photos_labels ... JOIN photos ... for every label in the response? (Labels list is typically short, < 100)
  • Q3: Does the frontend cache the label response aggressively? Need to invalidate cache on user switch.
  • Q4: For the path: operator — does it do exact match or LIKE? From source: WHERE photos.photo_path = ? — exact match only.

Tradeoffs

  • Sidecar proxy vs. frontend-only: Proxy centralizes logic but adds network hop. Frontend-only is faster but more complex (every route needs path filtering).
  • Label count accuracy: Recalculated per-user counts will differ from the library-wide counts. This is intentional — labels are scoped now.
  • Sidecar vs. patching PhotoPrism: Sidecar approach is non-invasive (no fork/build of PP). PhotoPrism patch would be cleaner but requires maintaining a fork.

8. Recommendation

  1. Build the labels proxy (Phase 1) — it directly solves the label isolation problem and can be done with existing sidecar infrastructure
  2. Investigate review/archive leak first — run the actual API query as muli to confirm whether the photos search is actually scoped. The code analysis says it should be, but the user reports otherwise. If confirmed as a real leak, implement the sidecar photos proxy (Phase 2)
  3. Sidebar counts (Phase 3) — lower priority, can be done after labels and review/archive are fixed

Before building, confirm with the user whether they see cross-user photos in the actual grid or only in the sidebar counts for review/archive.