sidecar: scoped labels + counts proxy (fixes cross-user label leak)

- 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
This commit is contained in:
2026-06-06 19:23:22 +02:00
parent 4c08eba27a
commit 8f97590d9f
6 changed files with 530 additions and 21 deletions

View File

@@ -0,0 +1,301 @@
# 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`):
1. **Labels** — labels list + label drill-down show all library photos
2. **Review** — photos needing review tab shows cross-user photos
3. **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):
```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):
```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
```go
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:
- `ScopePhotosForSession` correctly enters the `base_path` branch (good — user is isolated)
- BUT `ActionDelete` is 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:**
1. Sidecar receives the caller's `X-Auth-Token`
2. `requireSession` middleware resolves the token → returns user's `BasePath`
3. Sidecar makes the same `/api/v1/labels` request to PhotoPrism (using the caller's token)
4. **Filter step**: for each label in the response, query the DB to count photos with that label AND `photo_path LIKE '<base_path>/%'`
5. Return filtered labels with corrected `PhotoCount` and `Thumb`
**Implementation:**
New file `sidecar/handlers_labels.go`:
```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 `LIKE` prefix 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:
```json
"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)
1. New types: `PpLabel` (mirrors PhotoPrism's label response shape)
2. Handler function `handleLabels()` that:
- Validates token via `requireSession`
- Gets `BasePath` from session
- Calls PhotoPrism's `/api/v1/labels`
- For each label, queries photos_labels + photos to count user-scoped photos
- Returns filtered labels
3. Wire route: `auth.GET("/labels", handleLabels(...))` in `main.go`
4. Frontend: change label fetch URL from `/api/v1/labels` to `/api/sidecar/labels`
### Step 2: Sidecar — photos proxy (review/archive fix)
Files: `sidecar/handlers_photos.go` (new), `sidecar/main.go` (route wiring)
1. Handler function `handlePhotos()` that:
- Validates token
- Gets `BasePath` from session
- Parses the query string to detect `review:true` or `archived:true`
- Forwards to PhotoPrism's `/api/v1/photos` with `path:<basePath>` added to query
- For review/archive, also ensures `review/archived` filter is NOT stripped
- Returns PhotoPrism's response
2. Two implementation variants:
**Variant A** (simpler): add `path:<basePath>` to the forwarded query. This only matches exact path, not subdirs (PhotoPrism's `path:` 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_path` doesn't match `basePath/%`. This is more robust.
### Step 3: Validation
1. Build sidecar: `cd sidecar && CGO_ENABLED=0 go build -o mule-sidecar .`
2. Rebuild Docker image: `docker compose build sidecar`
3. Restart sidecar: `docker compose up -d sidecar`
4. Test labels as muli — verify only muli's labels appear
5. Test review tab as muli — verify only muli's photos needing review appear
6. Test archive tab as muli — verify only muli's archived photos appear
7. 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:**
```bash
# 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
1. **Build the labels proxy** (Phase 1) — it directly solves the label isolation problem and can be done with existing sidecar infrastructure
2. **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)
3. **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.

View File

@@ -26,6 +26,8 @@ func requireSession(pp *ppClient) gin.HandlerFunc {
} }
c.Set("token", token) c.Set("token", token)
c.Set("userName", user.UserName) c.Set("userName", user.UserName)
c.Set("userUID", user.UserUID)
c.Set("basePath", user.BasePath)
c.Next() c.Next()
} }
} }
@@ -57,3 +59,29 @@ func ctxUserName(c *gin.Context) string {
} }
return s return s
} }
// ctxUserUID returns the PhotoPrism user UID resolved by requireSession.
func ctxUserUID(c *gin.Context) string {
v, ok := c.Get("userUID")
if !ok {
return ""
}
s, ok := v.(string)
if !ok {
return ""
}
return s
}
// ctxBasePath returns the PhotoPrism user BasePath resolved by requireSession.
func ctxBasePath(c *gin.Context) string {
v, ok := c.Get("basePath")
if !ok {
return ""
}
s, ok := v.(string)
if !ok {
return ""
}
return s
}

148
sidecar/handlers_labels.go Normal file
View File

@@ -0,0 +1,148 @@
package main
import (
"encoding/json"
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// PpLabel mirrors the shape PhotoPrism's /api/v1/labels endpoint returns.
// We decode enough to filter + recalculate PhotoCount; fields the client
// doesn't render are skipped for token efficiency.
type PpLabel struct {
UID string `json:"UID"`
Name string `json:"Name"`
Slug string `json:"Slug"`
CustomSlug string `json:"CustomSlug"`
Priority int `json:"Priority"`
Favorite bool `json:"Favorite"`
PhotoCount int `json:"PhotoCount"`
Thumb string `json:"Thumb"`
CreatedAt string `json:"CreatedAt"`
UpdatedAt string `json:"UpdatedAt"`
}
// handleLabels proxies PhotoPrism's /api/v1/labels and then post-filters
// each label's PhotoCount (and removes labels with zero count) so they
// reflect only photos under the caller's BasePath.
//
// Route: GET /api/sidecar/labels (behind requireSession)
func handleLabels(pp *ppClient, ppDb *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
basePath := ctxBasePath(c)
// Forward the query string (count, offset, q, all, …) to PhotoPrism.
query := c.Request.URL.RawQuery
// Call PhotoPrism's labels endpoint using the caller's token.
resp, err := pp.call(c.Request.Context(), http.MethodGet, "/api/v1/labels?"+query, token, nil)
if err != nil || !resp.OK {
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream labels request failed"})
return
}
// Decode labels.
var labels []PpLabel
if err := json.Unmarshal(resp.Body, &labels); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse labels"})
return
}
// If the user has no BasePath (admin/empty), return labels as-is.
if basePath == "" || ppDb == nil {
c.JSON(http.StatusOK, labels)
return
}
// For each label, count how many photos under this user's BasePath
// have that label. Remove labels with zero count for this user.
filtered := make([]PpLabel, 0, len(labels))
prefix := basePath + "/%"
for _, l := range labels {
var cnt int64
if err := ppDb.Raw(
`SELECT COUNT(*) FROM photos_labels pl
JOIN photos p ON pl.photo_id = p.id
JOIN labels lb ON pl.label_id = lb.id
WHERE lb.label_uid = ?
AND (p.photo_path = ? OR p.photo_path LIKE ?)
AND p.deleted_at IS NULL`,
l.UID, basePath, prefix,
).Count(&cnt).Error; err != nil {
// On DB error, skip this label rather than failing the whole response.
continue
}
if cnt == 0 {
continue
}
l.PhotoCount = int(cnt)
filtered = append(filtered, l)
}
c.JSON(http.StatusOK, filtered)
}
}
// Now also handle the session/config count scoping.
// PpCounts mirrors PhotoPrism's session config.count block that drives
// the sidebar badges (review, archive, all, etc.).
type PpCounts struct {
All int `json:"all"`
Photos int `json:"photos"`
Media int `json:"media"`
Videos int `json:"videos"`
Review int `json:"review"`
Archived int `json:"archived"`
Hidden int `json:"hidden"`
Private int `json:"private"`
Favorites int `json:"favorites"`
}
// handleScopedCounts returns user-scoped counts for review/archive/all
// so the sidebar badges match what the user actually sees.
//
// Route: GET /api/sidecar/counts (behind requireSession)
func handleScopedCounts(ppDb *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
basePath := ctxBasePath(c)
if basePath == "" || ppDb == nil {
// Admin or no DB — can't scope, return empty.
c.JSON(http.StatusOK, PpCounts{})
return
}
prefix := basePath + "/%"
pathCond := "(p.photo_path = ? OR p.photo_path LIKE ?)"
args := []any{basePath, prefix}
var counts PpCounts
// All non-deleted photos in this user's scope.
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND `+pathCond, args...).Scan(&counts.All)
// Photos needing review (quality < 3).
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_quality < 3 AND `+pathCond, args...).Scan(&counts.Review)
// Archived (soft-deleted) photos.
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NOT NULL AND `+pathCond, args...).Scan(&counts.Archived)
// Private photos.
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_private = 1 AND `+pathCond, args...).Scan(&counts.Private)
// Photos (type image).
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_type IN ('image','raw','live','animated') AND `+pathCond, args...).Scan(&counts.Photos)
// Videos.
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_type IN ('video','hdr','burst','live') AND `+pathCond, args...).Scan(&counts.Videos)
// Favorites.
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_favorite = 1 AND `+pathCond, args...).Scan(&counts.Favorites)
c.JSON(http.StatusOK, counts)
}
}

View File

@@ -20,6 +20,7 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
) )
func main() { func main() {
@@ -46,6 +47,18 @@ func main() {
// BasePath wired without an admin restart. // BasePath wired without an admin restart.
startUserBasepathReconciler(cfg) startUserBasepathReconciler(cfg)
// Open a second DB handle pointed at PhotoPrism's own schema for
// handlers that need to query auth_users, photos, labels, etc.
// May be nil if PpDSN is empty (no PP_DB_PASSWORD set).
var ppDb *gorm.DB
if cfg.PpDSN != "" {
if d, err := openDB(cfg.PpDSN); err == nil {
ppDb = d
} else {
slog.Warn("pp db open failed — scoped labels/counts unavailable", "err", err)
}
}
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
r := gin.New() r := gin.New()
// Keep `%2F` literal in path params so callers can pass URL-encoded // Keep `%2F` literal in path params so callers can pass URL-encoded
@@ -66,25 +79,31 @@ func main() {
// Every other endpoint runs behind the session gate. Mounting them // Every other endpoint runs behind the session gate. Mounting them
// under one group keeps the middleware wiring obvious. // under one group keeps the middleware wiring obvious.
auth := r.Group("/api/sidecar", requireSession(pp)) auth := r.Group("/api/sidecar", requireSession(pp))
{ {
auth.GET("/photos/marks", handleMarksAll(db)) auth.GET("/photos/marks", handleMarksAll(db))
auth.GET("/photos/:uid/marks", handleMarkGet(db)) auth.GET("/photos/:uid/marks", handleMarkGet(db))
auth.PUT("/photos/:uid/marks", handleMarkPut(db)) auth.PUT("/photos/:uid/marks", handleMarkPut(db))
auth.POST("/photos/marks/bulk", handleMarkBulk(db)) auth.POST("/photos/marks/bulk", handleMarkBulk(db))
auth.POST("/files/:uid/rename", handleRename(cfg, pp)) auth.POST("/files/:uid/rename", handleRename(cfg, pp))
auth.POST("/folders", handleFolderCreate(cfg, pp)) auth.POST("/folders", handleFolderCreate(cfg, pp))
auth.POST("/folders/counts", handleFolderCounts(pp)) auth.POST("/folders/counts", handleFolderCounts(pp))
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp)) auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp))
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp)) auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp)) auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
auth.GET("/duplicates/scan", handleDupScan(cfg, pp)) auth.GET("/duplicates/scan", handleDupScan(cfg, pp))
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp)) auth.POST("/duplicates/archive", handleDupArchive(cfg, pp))
}
// User-scoped proxies — require PpDSN connection.
if ppDb != nil {
auth.GET("/labels", handleLabels(pp, ppDb))
auth.GET("/counts", handleScopedCounts(ppDb))
}
}
addr := cfg.ListenAddr + ":" + itoa(cfg.Port) addr := cfg.ListenAddr + ":" + itoa(cfg.Port)
srv := &http.Server{ srv := &http.Server{

View File

@@ -5,6 +5,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"io" "io"
"log/slog"
"net/http" "net/http"
"net/url" "net/url"
"time" "time"
@@ -87,8 +88,8 @@ func (c *ppClient) call(ctx context.Context, method, urlPath, token string, body
// ppSessionUser is the subset of PhotoPrism's session response we need. // ppSessionUser is the subset of PhotoPrism's session response we need.
type ppSessionUser struct { type ppSessionUser struct {
UserName string `json:"UserName"` UserUID string `json:"UID"`
UserUID string `json:"UserUID"` UserName string `json:"Name"`
BasePath string `json:"BasePath"` BasePath string `json:"BasePath"`
} }
@@ -102,15 +103,22 @@ func (c *ppClient) resolveSession(ctx context.Context, token string) *ppSessionU
if token == "" { if token == "" {
return nil return nil
} }
r, err := c.call(ctx, http.MethodGet, "/api/v1/session/"+token, token, nil) r, err := c.call(ctx, http.MethodGet, "/api/v1/session", token, nil)
if err != nil || !r.OK { if err != nil {
slog.Warn("resolveSession: call failed", "err", err)
return nil
}
if !r.OK {
slog.Warn("resolveSession: not OK", "status", r.Status, "body", string(r.Body[:min(len(r.Body), 200)]))
return nil return nil
} }
var resp ppSessionResponse var resp ppSessionResponse
if err := json.Unmarshal(r.Body, &resp); err != nil { if err := json.Unmarshal(r.Body, &resp); err != nil {
slog.Warn("resolveSession: unmarshal failed", "err", err, "body", string(r.Body[:min(len(r.Body), 200)]))
return nil return nil
} }
if resp.User.UserName == "" { if resp.User.UserName == "" {
slog.Warn("resolveSession: empty username", "body", string(r.Body[:min(len(r.Body), 200)]))
return nil return nil
} }
return &resp.User return &resp.User

View File

@@ -672,8 +672,13 @@ export async function listLabels(): Promise<PpLabel[]> {
// them out and the tags page silently shows only ~40% of the user's // them out and the tags page silently shows only ~40% of the user's
// real tag set. `count` bumped to 1000 so a moderately tagged library // real tag set. `count` bumped to 1000 so a moderately tagged library
// returns the full list in one round-trip. // returns the full list in one round-trip.
const { data } = await http.get<PpLabel[]>('/labels', { //
params: { count: 1000, order: 'count', all: true } // Uses the sidecar proxy (/api/sidecar/labels) instead of PhotoPrism's
// /api/v1/labels so PhotoCount reflects only photos under the user's
// BasePath. The sidecar proxies the request through to PP then
// post-filters each label's count.
const { data } = await http.get<PpLabel[]>('/api/sidecar/labels', {
params: { count: 1000, order: 'count', all: true, perPage: 1000 }
}); });
return filterByUserPhotos(data, (l) => `label:${l.Slug}`); return filterByUserPhotos(data, (l) => `label:${l.Slug}`);
} }