sidecar: scoped photos/timeline proxy (fixes review + archive leak)

- 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
This commit is contained in:
2026-06-06 20:11:07 +02:00
parent 8f97590d9f
commit 7df1c04c0f
3 changed files with 324 additions and 6 deletions

View File

@@ -0,0 +1,247 @@
# 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_path` feature correctly scopes the main search (`/api/v1/photos`).
- Label views (`/api/v1/labels`) do **not** respect `base_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_users` table 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_path` column in `photos` stores paths like `muli/files/Photo Archive...` or `dtoro/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
1. **Does PhotoPrism use `photos_users` for 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 ignore `photos_users` in label queries.
2. **Performance impact**: 88K photos × 2 users = up to 176K rows. Could slow label queries.
3. **Side effects**: If `photos_users` controls sharing, adding auto-entries might break explicit share workflow.
4. **`perm` values**: Unclear what `perm` value 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_users` works as an exclusive ACL, yes.)
- Does the dtoro photo with a `photos_users` entry 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`:
```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:
```go
func reconcilePhotoUsers(ppDSN, originalsRoot string, mapping map[string]string) error
```
Logic:
1. For each `username:path` pair in `mapping`, look up the user's `user_uid` in `auth_users`.
2. Query `photos` for all `photo_uid` where `photo_path LIKE 'path/%'`.
3. Batch-insert entries into `photos_users` with a default `perm` value (to be determined in Phase 1).
4. Use `INSERT IGNORE` or `ON DUPLICATE KEY UPDATE` for idempotency.
5. Handle deletions: if a photo's path is changed (via rename), the old `photos_users` entry should be cleaned up.
**Step 2.3: Wire into reconciler loop**
Extend the existing `startUserBasepathReconciler` to call `reconcilePhotoUsers` after `reconcileUserBasepaths`.
```go
func apply() {
reconcileUserBasepaths(...)
reconcilePhotoUsers(...)
}
```
**Step 2.4: Handle re-index edge cases**
- When new photos are indexed, they won't have `photos_users` entries 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)
1. Build the binary: `cd sidecar && CGO_ENABLED=0 go build -o mule-sidecar .`
2. Rebuild the Docker image and restart the sidecar.
3. Check `photos_users` has expected rows.
4. Log in as `muli` via Authentik SSO, browse labels — verify dtoro photos are gone.
5. Log in as `dtoro` — verify still sees own photos.
6. 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
1. **Build check**: `go build ./...` from `sidecar/`
2. **Manual DB test** (Phase 1): Insert test `photos_users` rows via `docker exec pp-mariadb mysql ...`
3. **Integration test**: After deploy, check `photos_users` row count matches expected photo count per user.
4. **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`:
```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`:
```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:
1. **Pre-computed counts** in the sidebar tabs show total numbers across all users
2. **Label thumbnails** and category summaries are computed from the `labels` table which is global
3. 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
1. **Sidecar label filter** (recommended) — The sidecar already validates sessions via `resolveSession()` which returns the user's `BasePath`. Extend the sidecar to expose a **proxied `/api/v1/labels`** endpoint that:
- Accepts the caller's `X-Auth-Token` (already validated by `requireSession`)
- Forwards the request to PhotoPrism's `/api/v1/labels`
- **Filters the response** to remove labels whose `Thumb` belongs to a photo outside the user's `base_path`
- Recalculates `PhotoCount` for the user's scope (count photos under `base_path/%` for that label)
- Also filter `Count` values in the sidebar summary response
**Why this works:** The sidecar already has DB access to PhotoPrism's schema (`PpDSN`) and validates sessions. It can query `photos` to count label intersections per base_path.
2. **Same approach for review/archive sidebar counts** — Intercept the relevant metadata/summary endpoints to scope counts by base_path.
3. **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:
```sql
DELETE FROM photoprism.photos_users WHERE user_uid = 'utg7jjbd8iwaghn6';
```