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:
247
.hermes/plans/2026-06-06_120000-photos_users-label-isolation.md
Normal file
247
.hermes/plans/2026-06-06_120000-photos_users-label-isolation.md
Normal 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';
|
||||
```
|
||||
Reference in New Issue
Block a user