- 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
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):
- Labels — labels list + label drill-down show all library photos
- Review — photos needing review tab shows cross-user photos
- 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 |
SearchPhotos → searchPhotos → ScopePhotosForSession |
✅ Yes |
| Folders | GET /api/v1/photos with path filter |
Same chain | ✅ Yes |
| Labels | GET /api/v1/labels |
SearchLabels → search.Labels(frm) — no session |
❌ No — queries labels table directly |
| Label drill-down | GET /api/v1/photos?label=X |
Uses searchPhotos → ScopePhotosForSession |
✅ Should scope (if label= param doesn't bypass) |
| Review tab | GET /api/v1/photos?q=review:true |
searchPhotos → ScopePhotosForSession |
✅ 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:
ScopePhotosForSessioncorrectly enters thebase_pathbranch (good — user is isolated)- BUT
ActionDeleteis 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:
- Sidecar receives the caller's
X-Auth-Token requireSessionmiddleware resolves the token → returns user'sBasePath- Sidecar makes the same
/api/v1/labelsrequest to PhotoPrism (using the caller's token) - Filter step: for each label in the response, query the DB to count photos with that label AND
photo_path LIKE '<base_path>/%' - Return filtered labels with corrected
PhotoCountandThumb
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
LIKEprefix 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)
- New types:
PpLabel(mirrors PhotoPrism's label response shape) - Handler function
handleLabels()that:- Validates token via
requireSession - Gets
BasePathfrom session - Calls PhotoPrism's
/api/v1/labels - For each label, queries photos_labels + photos to count user-scoped photos
- Returns filtered labels
- Validates token via
- Wire route:
auth.GET("/labels", handleLabels(...))inmain.go - Frontend: change label fetch URL from
/api/v1/labelsto/api/sidecar/labels
Step 2: Sidecar — photos proxy (review/archive fix)
Files: sidecar/handlers_photos.go (new), sidecar/main.go (route wiring)
-
Handler function
handlePhotos()that:- Validates token
- Gets
BasePathfrom session - Parses the query string to detect
review:trueorarchived:true - Forwards to PhotoPrism's
/api/v1/photoswithpath:<basePath>added to query - For review/archive, also ensures
review/archivedfilter is NOT stripped - Returns PhotoPrism's response
-
Two implementation variants:
Variant A (simpler): add
path:<basePath>to the forwarded query. This only matches exact path, not subdirs (PhotoPrism'spath: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_pathdoesn't matchbasePath/%. This is more robust.
Step 3: Validation
- Build sidecar:
cd sidecar && CGO_ENABLED=0 go build -o mule-sidecar . - Rebuild Docker image:
docker compose build sidecar - Restart sidecar:
docker compose up -d sidecar - Test labels as muli — verify only muli's labels appear
- Test review tab as muli — verify only muli's photos needing review appear
- Test archive tab as muli — verify only muli's archived photos appear
- 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
- Build the labels proxy (Phase 1) — it directly solves the label isolation problem and can be done with existing sidecar infrastructure
- 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)
- 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.