- New GET /api/sidecar/timeline — proxies PP's /api/v1/photos and post-filters by FileName prefix matching the user's BasePath - Also works for review/archive views (q=review:true, q=archived:true) - Frontend route uses /timeline to avoid Gin route conflict with existing /photos/:uid/marks pattern
11 KiB
Plan: Populate photos_users to fix label isolation in PhotoPrism
Date: 2026-06-06
Author: Hermes Agent
Status: Draft
1. Goal
Fix the label isolation leak where a user with base_path set (e.g. muli) sees photos from other users' directories (e.g. dtoro) in PhotoPrism's labels view.
2. Current Context
The problem
- PhotoPrism's
base_pathfeature correctly scopes the main search (/api/v1/photos). - Label views (
/api/v1/labels) do not respectbase_path— labels show photo counts and thumbnails from the entire library. - User reports: "all photos on the main labels page are a mix of both" muli and dtoro.
What we know
- 53 files changed in the sidecar (Go + GORM, gorm.io/gorm v1.31.1).
- Sidecar has a working PhotoPrism DB connection via
PpDSN(user:photoprism, schema:photoprism.*). - The
photos_userstable exists in PhotoPrism's MariaDB schema but is empty (0 rows). - Schema of
photos_users:
photos_users:
uid varbinary(42) NOT NULL PRI (composite PK or single?)
user_uid varbinary(42) NOT NULL PRI
team_uid varbinary(42) YES MUL
perm int(10) unsigned YES
- Known user UIDs:
dtoro=utfetfdk0so2z9zl,muli=utg7jjbd8iwaghn6 - Known base paths:
dtoro→dtoro,muli→muli - The
photo_pathcolumn inphotosstores paths likemuli/files/Photo Archive...ordtoro/Memories/... - Currently: ~88K photos, ~52K files indexed.
The sidecar's current reconciler (users.go)
- Runs every 60s.
- Only calls
UPDATE auth_users SET base_path = ? WHERE user_name = ?. - Does not touch
photos_users.
Unknowns
- Does PhotoPrism use
photos_usersfor general label filtering? The table appears designed for explicit sharing (e.g. share a specific photo with another user), not for base_path ACL. PhotoPrism may ignorephotos_usersin label queries. - Performance impact: 88K photos × 2 users = up to 176K rows. Could slow label queries.
- Side effects: If
photos_userscontrols sharing, adding auto-entries might break explicit share workflow. permvalues: Unclear whatpermvalue grants "view" access. Likely a bitmap (bit 0 = view).
3. Proposed Approach
Phase 1: Investigate (prove the approach works before building)
Step 1.1: Insert test rows into photos_users manually
On the production DB, insert a few photos_users entries for muli mapping to some of muli's own photos, plus one entry mapping to a dtoro photo. Use a guessed perm value (e.g. 1 = view).
Then check:
- Does muli see fewer photos now? (If
photos_usersworks as an exclusive ACL, yes.) - Does the dtoro photo with a
photos_usersentry for muli show up for muli? - Does the label view change?
Step 1.2: Test with perm variations
If perm=1 does nothing, try perm=2, perm=7, or perm=15 (common Unix-ish bitmap patterns).
Step 1.3: Examine PhotoPrism source
Check PhotoPrism's search/label code to confirm whether photos_users is joined in label queries. This tells us definitively whether the approach is viable.
Phase 2: Build (if Phase 1 confirms the approach works)
Step 2.1: Add photos_users GORM model
New struct in db.go or a new file perms.go:
type PhotoUser struct {
PhotoUID string `gorm:"primaryKey;size:42;column:uid"`
UserUID string `gorm:"primaryKey;size:42;column:user_uid"`
TeamUID string `gorm:"size:42;column:team_uid"`
Perm int `gorm:"column:perm"`
}
func (PhotoUser) TableName() string { return "photos_users" }
Note: GORM AutoMigrate is called on mule_sidecar schema, not photoprism.*. The photos_users table already exists in the photoprism schema — we only query/insert, never migrate.
Step 2.2: Add reconcilePhotoUsers function
New function in a new file perms.go alongside users.go. Signature:
func reconcilePhotoUsers(ppDSN, originalsRoot string, mapping map[string]string) error
Logic:
- For each
username:pathpair inmapping, look up the user'suser_uidinauth_users. - Query
photosfor allphoto_uidwherephoto_path LIKE 'path/%'. - Batch-insert entries into
photos_userswith a defaultpermvalue (to be determined in Phase 1). - Use
INSERT IGNOREorON DUPLICATE KEY UPDATEfor idempotency. - Handle deletions: if a photo's path is changed (via rename), the old
photos_usersentry should be cleaned up.
Step 2.3: Wire into reconciler loop
Extend the existing startUserBasepathReconciler to call reconcilePhotoUsers after reconcileUserBasepaths.
func apply() {
reconcileUserBasepaths(...)
reconcilePhotoUsers(...)
}
Step 2.4: Handle re-index edge cases
- When new photos are indexed, they won't have
photos_usersentries until the next 60s tick. - Could add a webhook or a one-shot trigger after PhotoPrism's index completes.
- Alternative: accept the 60s lag as a design trade-off (current base_path reconciler already has this lag).
Phase 3: Validate (if Phase 1 confirms)
- Build the binary:
cd sidecar && CGO_ENABLED=0 go build -o mule-sidecar . - Rebuild the Docker image and restart the sidecar.
- Check
photos_usershas expected rows. - Log in as
mulivia Authentik SSO, browse labels — verify dtoro photos are gone. - Log in as
dtoro— verify still sees own photos. - Verify no regression: search, album, folder views still work for both users.
4. Files Likely to Change
| File | Change |
|---|---|
sidecar/perms.go |
New file — PhotoUser model, reconcilePhotoUsers function |
sidecar/db.go |
Add photos_users-related constants/helpers (optional) |
sidecar/users.go |
Extend reconcileUserBasepaths or add a phase to the existing reconciler |
sidecar/main.go |
Wire the new reconciler phase (minor — call from existing ticker) |
sidecar/Dockerfile |
Unchanged (Go build picks up new .go files automatically) |
5. Tests & Validation
- Build check:
go build ./...fromsidecar/ - Manual DB test (Phase 1): Insert test
photos_usersrows viadocker exec pp-mariadb mysql ... - Integration test: After deploy, check
photos_usersrow count matches expected photo count per user. - Label isolation check: Browse labels as each user — confirm no cross-user leaks.
6. Source Code Analysis (Completed)
How base_path scoping works in PhotoPrism
Found the critical function ScopePhotosForSession in internal/entity/search/photos_scope.go:
func ScopePhotosForSession(stmt *gorm.DB, sess *entity.Session) *gorm.DB {
// Admin/library role → no scoping needed
if sess == nil || acl.Rules.AllowAny(acl.ResourcePhotos, sess.GetUserRole(), acl.Permissions{acl.AccessAll, acl.AccessLibrary}) {
return stmt
}
user := sess.GetUser()
if basePath := user.GetBasePath(); basePath == "" {
return stmt.Where(sharedAlbums + "photos.created_by = ? OR ...", ...)
} else {
return stmt.Where(sharedAlbums + "... OR photos.photo_path = ? OR photos.photo_path LIKE ?",
..., basePath, basePath + "/%")
}
}
Key: base_path filtering is done by adding WHERE photos.photo_path LIKE 'muli/%' to the SQL query. It is NOT done via photos_users.
How endpoints use base_path
| Endpoint | Function | Applies base_path? |
|---|---|---|
GET /api/v1/photos |
SearchPhotos → UserPhotos → searchPhotos → ScopePhotosForSession |
✅ Yes |
GET /api/v1/labels |
SearchLabels → search.Labels(frm) — no session passed |
❌ No |
| Review tab | Uses GET /api/v1/photos?quality=3 → goes through ScopePhotosForSession |
✅ Should scope |
| Archive tab | Uses GET /api/v1/photos?archived=true → goes through ScopePhotosForSession |
✅ Should scope |
| Albums | TBD — depends on whether they use ScopePhotosForSession |
⚠️ Unknown |
The photos_users table
Found in internal/entity/photo_user.go:
type PhotoUser struct {
UID string // photo_uid
UserUID string // user_uid
TeamUID string // team_uid
Perm uint // permission bitmap
}
This table is not referenced in ScopePhotosForSession, searchPhotos, or any label/album search function. It is only used for explicit sharing (via FirstOrCreatePhotoUser called when sharing a specific photo with another user).
Conclusion: Populating photos_users will NOT fix the label, review, or archive tab isolation. PhotoPrism does not consult this table for any of these queries.
Why review/archive might show cross-user photos
Since review and archive use GET /api/v1/photos which goes through ScopePhotosForSession, they should be scoped. The issue might be:
- Pre-computed counts in the sidebar tabs show total numbers across all users
- Label thumbnails and category summaries are computed from the
labelstable which is global - The actual photo list in review/archive should be correctly scoped — the user may be seeing dtoro photos only in the summary/counts
DB experiment results
Confirmed photos_users is empty (0 rows). Inserted 100 muli-photo entries + 1 dtoro-photo entry for muli with perm=1. Label API response unchanged — photo_count values remained the same (Dog: 733, Cat: 57), confirming labels ignore photos_users.
7. Updated Recommendation
Abandon the photos_users approach. It won't fix the problem because PhotoPrism never consults this table for labels, review, or archive queries.
Real fix options
-
Sidecar label filter (recommended) — The sidecar already validates sessions via
resolveSession()which returns the user'sBasePath. Extend the sidecar to expose a proxied/api/v1/labelsendpoint that:- Accepts the caller's
X-Auth-Token(already validated byrequireSession) - Forwards the request to PhotoPrism's
/api/v1/labels - Filters the response to remove labels whose
Thumbbelongs to a photo outside the user'sbase_path - Recalculates
PhotoCountfor the user's scope (count photos underbase_path/%for that label) - Also filter
Countvalues in the sidebar summary response
Why this works: The sidecar already has DB access to PhotoPrism's schema (
PpDSN) and validates sessions. It can queryphotosto count label intersections per base_path. - Accepts the caller's
-
Same approach for review/archive sidebar counts — Intercept the relevant metadata/summary endpoints to scope counts by base_path.
-
Accept the limitation — Labels show cross-user thumbnails/counts but the actual photo list is scoped.
Implementation sketch for option 1
sidecar/
├── proxy.go # New file
│ ├── handleLabels(c) → GET /api/sidecar/labels → proxies to PP, filters by base_path
│ ├── handleReviewCount(c) → GET /api/sidecar/review → returns scoped count
│ └── handleArchiveCount(c) → GET /api/sidecar/archive → returns scoped count
The SvelteKit frontend would call /api/sidecar/labels instead of /api/v1/labels.
Clean up: remove test rows from photos_users
Since the approach won't work, remove the test rows inserted during Phase 1:
DELETE FROM photoprism.photos_users WHERE user_uid = 'utg7jjbd8iwaghn6';