package main import ( "bytes" "context" "encoding/json" "io" "log/slog" "net/http" "net/http/httputil" "net/url" gopath "path" "regexp" "strings" "sync" "time" "github.com/gin-gonic/gin" "gorm.io/gorm" ) // Scoped PhotoPrism-compatible API proxy. // // PhotoPrism CE does not enforce auth_users.base_path on API reads — any // authenticated user can search the whole library (`q=path:"other/*"`), // verified empirically against this deployment. The web client compensates // by post-filtering inside /api/sidecar/*, but third-party PhotoPrism apps // (Gallery for PhotoPrism, Photo Uploader, …) speak to /api/v1 directly. // // This proxy is the public face for those apps. It forwards /api/v1/* to // PhotoPrism with these rules for sessions that carry a BasePath: // // - search endpoints (photos, geo) get their `path` filter rewritten so // results stay inside the caller's BasePath subtree; // - per-photo reads and mutations the web client needs (metadata PUT, // approve, like, stack file ops) are ownership-checked per UID; // - batch archive/restore/delete validates every UID in the body against // the PhotoPrism DB before forwarding; // - hash-addressed media (t/, dl/, videos/) and session/config pass // through — media URLs embed per-instance preview/download tokens and // unguessable content hashes, the same protection PhotoPrism's own // share links rely on; // - album + label + subject reads and album/subject mutations pass // through: PhotoPrism CE has no per-user albums or faces, so these are // shared across users by design; the photos inside stay path-scoped; // - everything else (settings, users, index, import, uploads) answers 403. // // Admin-role sessions (and any session without a BasePath) pass through // fully — the web client's admin dialogs (settings, users, indexing) need // the raw API. // ppQPathTerm matches a `path:` filter inside PhotoPrism's q-DSL — either // quoted (path:"a b/*") or bare (path:a/*). var ppQPathTerm = regexp.MustCompile(`(?i)\bpath:("[^"]*"|\S+)`) // pathValueAllowed reports whether one path-filter value stays inside base. // PhotoPrism ORs `|`-separated alternatives inside a single value, so every // alternative must pass. A bare `base*` (no slash) is rejected because the // wildcard would also match sibling folders like `base2/…`. func pathValueAllowed(val, base string) bool { val = strings.Trim(val, `"`) for _, alt := range strings.Split(val, "|") { v := strings.Trim(strings.TrimSpace(alt), "/") if v == base { continue } if strings.HasPrefix(v, base+"/") { continue } return false } return true } // scopeQ rewrites a q-DSL string so its path filter cannot leave base. // User-supplied path terms that already stay inside base are kept (the web // client and gallery apps use them for folder drills); any term that // escapes — or the absence of one — collapses to `path:"base/*"`. func scopeQ(q, base string) string { terms := ppQPathTerm.FindAllStringSubmatch(q, -1) if len(terms) > 0 { ok := true for _, m := range terms { if !pathValueAllowed(m[1], base) { ok = false break } } if ok { return q } q = strings.TrimSpace(ppQPathTerm.ReplaceAllString(q, "")) } scope := ` path:"` + strings.ReplaceAll(base, `"`, "") + `/*"` return strings.TrimSpace(q + scope) } // scopeSearchValues enforces the BasePath on a search request's query // string. The q-DSL `path:` term overrides the `path` form parameter in // PhotoPrism's parser (verified empirically), so the guarantee lives in q; // the standalone param is validated too so it can't disagree. func scopeSearchValues(v url.Values, base string) url.Values { v.Set("q", scopeQ(v.Get("q"), base)) if p := v.Get("path"); p != "" && !pathValueAllowed(p, base) { v.Del("path") } return v } // ppProxyPhoto is the projection needed for per-UID ownership checks. type ppProxyPhoto struct { Path string `json:"Path"` Files []ppFile `json:"Files"` } // photoWithinBase fetches one photo with the caller's own token and checks // that it lives inside base. Fails closed on any error. func photoWithinBase(ctx context.Context, pp *ppClient, token, uid, base string) bool { resp, err := pp.call(ctx, http.MethodGet, "/api/v1/photos/"+url.PathEscape(uid), token, nil) if err != nil || !resp.OK { return false } var p ppProxyPhoto if err := json.Unmarshal(resp.Body, &p); err != nil { return false } if p.Path != "" { return p.Path == base || strings.HasPrefix(p.Path, base+"/") } for _, f := range p.Files { if f.Root == "/" && strings.HasPrefix(f.Name, base+"/") { return true } } return false } // cachedSession is a short-lived token→user cache so a burst of gallery // requests doesn't double every call with a /session probe. 60s matches // the BasePath reconciler cadence; a revoked token lives at most that long. type cachedSession struct { user *ppSessionUser expiry time.Time } type sessionCache struct { mu sync.Mutex m map[string]cachedSession } func (sc *sessionCache) resolve(ctx context.Context, pp *ppClient, token string) *ppSessionUser { if token == "" { return nil } now := time.Now() sc.mu.Lock() if e, ok := sc.m[token]; ok && now.Before(e.expiry) { sc.mu.Unlock() return e.user } sc.mu.Unlock() user := pp.resolveSession(ctx, token) if user == nil { return nil } sc.mu.Lock() if len(sc.m) > 1024 { // hard cap; sessions are few, tokens churn rarely sc.m = map[string]cachedSession{} } sc.m[token] = cachedSession{user: user, expiry: now.Add(60 * time.Second)} sc.mu.Unlock() return user } // proxyToken pulls the session token from any header form PhotoPrism // clients use: X-Auth-Token (canonical), Authorization: Bearer, or the // legacy X-Session-ID. func proxyToken(r *http.Request) string { if t := r.Header.Get("X-Auth-Token"); t != "" { return t } if a := r.Header.Get("Authorization"); strings.HasPrefix(a, "Bearer ") { return strings.TrimPrefix(a, "Bearer ") } return r.Header.Get("X-Session-ID") } // markerWithinBase reports whether a marker's underlying photo lives under // base. Fails closed: no DB handle or unknown marker → false. func markerWithinBase(ppDb *gorm.DB, markerUID, base string) bool { if ppDb == nil || markerUID == "" { return false } var n int64 err := ppDb.Table("markers m"). Joins("JOIN files f ON f.file_uid = m.file_uid"). Joins("JOIN photos p ON p.photo_uid = f.photo_uid"). Where("m.marker_uid = ?", markerUID). Where("p.deleted_at IS NULL"). Where("p.photo_path = ? OR p.photo_path LIKE ?", base, base+"/%"). Count(&n).Error if err != nil { slog.Warn("pp-proxy: marker ownership query failed", "err", err) return false } return n > 0 } // batchUIDsWithinBase validates that every photo UID in a batch body lives // under base, using one SQL query against PhotoPrism's photos table. Fails // closed: no DB handle, unknown UIDs, or any path outside base → false. func batchUIDsWithinBase(ppDb *gorm.DB, uids []string, base string) bool { if ppDb == nil || len(uids) == 0 { return false } var n int64 err := ppDb.Table("photos"). Where("photo_uid IN ?", uids). Where("photo_path = ? OR photo_path LIKE ?", base, base+"/%"). Count(&n).Error if err != nil { slog.Warn("pp-proxy: batch ownership query failed", "err", err) return false } return n == int64(len(uids)) } // readBatchBody consumes the request body, extracts the `photos` UID list, // and reinstates the body so the proxy can still forward it. func readBatchBody(r *http.Request) ([]string, bool) { buf, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) r.Body.Close() r.Body = io.NopCloser(bytes.NewReader(buf)) r.ContentLength = int64(len(buf)) if err != nil { return nil, false } var body struct { Photos []string `json:"photos"` } if err := json.Unmarshal(buf, &body); err != nil { return nil, false } return body.Photos, true } // handlePPProxy returns the gin handler mounted at /api/v1/*rest. func handlePPProxy(cfg *Config, ppDb *gorm.DB) gin.HandlerFunc { target, err := url.Parse(cfg.PhotoprismBaseURL) if err != nil { slog.Error("pp-proxy: bad PHOTOPRISM_BASE_URL", "err", err) return func(c *gin.Context) { c.AbortWithStatus(http.StatusBadGateway) } } proxy := httputil.NewSingleHostReverseProxy(target) proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { slog.Warn("pp-proxy: upstream error", "path", r.URL.Path, "err", err) w.WriteHeader(http.StatusBadGateway) } // Ownership checks reuse the normal client; a dedicated instance would // gain nothing. pp := newPPClient(cfg.PhotoprismBaseURL) cache := &sessionCache{m: map[string]cachedSession{}} forbid := func(c *gin.Context) { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "not available through this proxy"}) } return func(c *gin.Context) { // The router keeps raw escapes (UseRawPath). Unescape and clean // before classifying, then forward exactly the cleaned path — so // `t%2F..%2Fsettings` can't be classified as media here yet reach // /settings after PhotoPrism's own router cleans it. unesc, err := url.PathUnescape(c.Param("rest")) if err != nil { c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "bad path"}) return } rest := strings.TrimPrefix(gopath.Clean("/"+unesc), "/") c.Request.URL.Path = "/api/v1/" + rest c.Request.URL.RawPath = "" method := c.Request.Method // Unauthenticated / token-in-URL surface: login+logout, OIDC // login+callback (PhotoPrism's actual routes are /api/v1/oidc/login // and /api/v1/oidc/redirect — "oauth/" was never a real PhotoPrism // path and left the Authentik callback with no valid token yet // falling through to the authenticated branch below, producing a // 401 "invalid session" before the session was even established), // client config, hash-addressed media, websocket. passUnscoped := rest == "session" || strings.HasPrefix(rest, "session/") || strings.HasPrefix(rest, "oidc/") || rest == "config" || rest == "ws" || strings.HasPrefix(rest, "t/") || strings.HasPrefix(rest, "dl/") || strings.HasPrefix(rest, "videos/") || strings.HasPrefix(rest, "svg/") if passUnscoped { proxy.ServeHTTP(c.Writer, c.Request) return } user := cache.resolve(c.Request.Context(), pp, proxyToken(c.Request)) if user == nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid session"}) return } base := strings.Trim(user.BasePath, "/") if base == "" || user.Role == "admin" { // Admins keep the raw API — the web client's settings, users, // and indexing dialogs need it. (On this deployment the admin // account carries a BasePath purely to default its web view.) proxy.ServeHTTP(c.Writer, c.Request) return } isGet := method == http.MethodGet || method == http.MethodHead switch { // Search endpoints: enforce the path scope inside the query. case isGet && (rest == "photos" || rest == "photos/view" || rest == "geo"): q := c.Request.URL.Query() c.Request.URL.RawQuery = scopeSearchValues(q, base).Encode() proxy.ServeHTTP(c.Writer, c.Request) // Batch mutations: every UID in the body must be the caller's. case method == http.MethodPost && (rest == "batch/photos/archive" || rest == "batch/photos/restore" || rest == "batch/photos/delete" || rest == "batch/photos/approve" || rest == "batch/photos/private"): uids, ok := readBatchBody(c.Request) if !ok || !batchUIDsWithinBase(ppDb, uids, base) { forbid(c) return } proxy.ServeHTTP(c.Writer, c.Request) // Per-photo operations: ownership-checked per UID. Covers reads, // metadata PUT, approve, like/unlike, download, and the stack file // ops (set primary / unstack / delete file) the review UI uses. case strings.HasPrefix(rest, "photos/"): parts := strings.Split(rest, "/") uid := parts[1] var allowed bool switch len(parts) { case 2: allowed = isGet || method == http.MethodPut case 3: allowed = (isGet && parts[2] == "dl") || (method == http.MethodPost && parts[2] == "approve") || (parts[2] == "like" && (method == http.MethodPost || method == http.MethodDelete)) case 4: allowed = method == http.MethodDelete && parts[2] == "files" case 5: allowed = method == http.MethodPost && parts[2] == "files" && (parts[4] == "primary" || parts[4] == "unstack") } if !allowed { forbid(c) return } if !photoWithinBase(c.Request.Context(), pp, proxyToken(c.Request), uid, base) { c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "photo not found"}) return } proxy.ServeHTTP(c.Writer, c.Request) // Albums (heaps), labels, subjects, faces: shared across users by // design in CE — reads and mutations pass through; the photos inside // any of them stay path-scoped by the rules above. Note that naming // a face (marker PUT below) creates/updates a shared Subject the // same way album/label edits are shared. case rest == "albums" || strings.HasPrefix(rest, "albums/") || rest == "labels" || strings.HasPrefix(rest, "labels/") || rest == "subjects" || strings.HasPrefix(rest, "subjects/") || rest == "faces" || strings.HasPrefix(rest, "faces/"): proxy.ServeHTTP(c.Writer, c.Request) // Marker mutations (face naming / clearing): ownership-checked — // the marker's file must belong to a photo under the caller's // BasePath. This is how the web client names people (PhotoPrism's // own naming flow is PUT /markers/:uid {Name, SubjSrc:"manual"}). case (method == http.MethodPut && strings.HasPrefix(rest, "markers/") && strings.Count(rest, "/") == 1) || (method == http.MethodDelete && strings.HasPrefix(rest, "markers/") && strings.HasSuffix(rest, "/subject")): markerUID := strings.TrimSuffix(strings.TrimPrefix(rest, "markers/"), "/subject") if !markerWithinBase(ppDb, markerUID, base) { c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "marker not found"}) return } proxy.ServeHTTP(c.Writer, c.Request) default: slog.Info("pp-proxy: blocked", "user", user.UserName, "method", method, "path", rest) forbid(c) } } }