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';
|
||||||
|
```
|
||||||
67
sidecar/handlers_photos.go
Normal file
67
sidecar/handlers_photos.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handlePhotos proxies PhotoPrism's /api/v1/photos and then post-filters
|
||||||
|
// the response so only photos under the caller's BasePath are returned.
|
||||||
|
// This fixes the review/archive tab cross-user leak.
|
||||||
|
//
|
||||||
|
// Route: GET /api/sidecar/timeline (behind requireSession)
|
||||||
|
func handlePhotos(pp *ppClient) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
token := ctxToken(c)
|
||||||
|
basePath := ctxBasePath(c)
|
||||||
|
|
||||||
|
// Forward the raw query string to PhotoPrism.
|
||||||
|
query := c.Request.URL.RawQuery
|
||||||
|
|
||||||
|
resp, err := pp.call(c.Request.Context(), http.MethodGet, "/api/v1/photos?"+query, token, nil)
|
||||||
|
if err != nil || !resp.OK {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream photos request failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode as a generic array so we can inspect Path without
|
||||||
|
// committing to a rigid struct (PhotoPrism's photo response
|
||||||
|
// varies between list/detail/search endpoints).
|
||||||
|
var photos []map[string]any
|
||||||
|
if err := json.Unmarshal(resp.Body, &photos); err != nil {
|
||||||
|
// If it's not an array (e.g. error, single object), pass through.
|
||||||
|
c.Data(resp.Status, "application/json", resp.Body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the user has no BasePath (admin/empty), return as-is.
|
||||||
|
if basePath == "" {
|
||||||
|
c.JSON(http.StatusOK, photos)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := basePath + "/"
|
||||||
|
|
||||||
|
// Post-filter by FileName field (originals-relative path).
|
||||||
|
filtered := make([]map[string]any, 0, len(photos))
|
||||||
|
for _, ph := range photos {
|
||||||
|
rawPath, ok := ph["FileName"]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pathStr, ok := rawPath.(string)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Match exact basePath or basePath/...
|
||||||
|
if pathStr == basePath || strings.HasPrefix(pathStr, prefix) {
|
||||||
|
filtered = append(filtered, ph)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, filtered)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,6 +103,10 @@ func main() {
|
|||||||
auth.GET("/labels", handleLabels(pp, ppDb))
|
auth.GET("/labels", handleLabels(pp, ppDb))
|
||||||
auth.GET("/counts", handleScopedCounts(ppDb))
|
auth.GET("/counts", handleScopedCounts(ppDb))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User-scoped photos — post-filters by BasePath so review/archive
|
||||||
|
// tabs only show photos the user owns.
|
||||||
|
auth.GET("/timeline", handlePhotos(pp))
|
||||||
}
|
}
|
||||||
|
|
||||||
addr := cfg.ListenAddr + ":" + itoa(cfg.Port)
|
addr := cfg.ListenAddr + ":" + itoa(cfg.Port)
|
||||||
|
|||||||
Reference in New Issue
Block a user