diff --git a/sidecar/handlers_ppproxy.go b/sidecar/handlers_ppproxy.go new file mode 100644 index 0000000..6ef3dfb --- /dev/null +++ b/sidecar/handlers_ppproxy.go @@ -0,0 +1,270 @@ +package main + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "net/http/httputil" + "net/url" + "regexp" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +// 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; +// - single-photo reads and like/unlike are ownership-checked per UID; +// - 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 pass through (names are visible across +// users; the photos inside stay path-scoped); +// - everything else (settings, users, index, import, batch, uploads, +// any mutation) answers 403. +// +// Sessions with an empty BasePath (unscoped admins) pass through fully. + +// 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") +} + +// handlePPProxy returns the gin handler mounted at /api/v1/*rest. +func handlePPProxy(cfg *Config) 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) { + rest := strings.TrimPrefix(strings.TrimPrefix(c.Param("rest"), "/"), "") + method := c.Request.Method + + // Unauthenticated / token-in-URL surface: login+logout, client + // config, hash-addressed media, websocket. + passUnscoped := rest == "session" || strings.HasPrefix(rest, "session/") || + strings.HasPrefix(rest, "oauth/") || + 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 == "" { + // Unscoped admin — full pass-through. + 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) + + // Single-photo reads + like/unlike: ownership-checked per UID. + case strings.HasPrefix(rest, "photos/"): + parts := strings.Split(rest, "/") + uid := parts[1] + allowed := (isGet && len(parts) == 2) || // GET photos/:uid + (isGet && len(parts) == 3 && parts[2] == "dl") || + (len(parts) == 3 && parts[2] == "like" && + (method == http.MethodPost || method == http.MethodDelete)) + 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) + + // Collection reads: names are shared across users by design (CE has + // no per-user albums); their photo contents stay path-scoped above. + case isGet && (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) + + default: + slog.Info("pp-proxy: blocked", "user", user.UserName, "method", method, "path", rest) + forbid(c) + } + } +} diff --git a/sidecar/handlers_ppproxy_test.go b/sidecar/handlers_ppproxy_test.go new file mode 100644 index 0000000..c60b077 --- /dev/null +++ b/sidecar/handlers_ppproxy_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "net/url" + "testing" +) + +func TestScopeQ(t *testing.T) { + cases := []struct { + name string + q string + base string + want string + }{ + {"empty q gains scope", "", "dtoro", `path:"dtoro/*"`}, + {"plain search gains scope", "label:dog", "dtoro", `label:dog path:"dtoro/*"`}, + {"inside path kept", `path:"dtoro/2024/*" label:dog`, "dtoro", `path:"dtoro/2024/*" label:dog`}, + {"exact base kept", `path:"dtoro"`, "dtoro", `path:"dtoro"`}, + {"outside path replaced", `path:"muli/*"`, "dtoro", `path:"dtoro/*"`}, + {"bare term outside replaced", `path:muli/x label:dog`, "dtoro", `label:dog path:"dtoro/*"`}, + {"pipe alternative escaping", `path:"dtoro/*|muli/*"`, "dtoro", `path:"dtoro/*"`}, + {"pipe all inside kept", `path:"dtoro/a|dtoro/b/*"`, "dtoro", `path:"dtoro/a|dtoro/b/*"`}, + {"sibling prefix rejected", `path:"dtoro2/*"`, "dtoro", `path:"dtoro/*"`}, + {"bare wildcard on base rejected", `path:dtoro*`, "dtoro", `path:"dtoro/*"`}, + {"mixed valid+invalid terms collapse", `path:"dtoro/a" path:"muli/b"`, "dtoro", `path:"dtoro/*"`}, + {"case-insensitive filter name", `PATH:"muli/*"`, "dtoro", `path:"dtoro/*"`}, + {"nested base", `path:"family/alice/x"`, "family/alice", `path:"family/alice/x"`}, + {"nested base parent escape", `path:"family/*"`, "family/alice", `path:"family/alice/*"`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := scopeQ(tc.q, tc.base); got != tc.want { + t.Errorf("scopeQ(%q, %q) = %q, want %q", tc.q, tc.base, got, tc.want) + } + }) + } +} + +func TestScopeSearchValues(t *testing.T) { + v := url.Values{} + v.Set("count", "60") + v.Set("path", "muli/*") + got := scopeSearchValues(v, "dtoro") + if got.Get("path") != "" { + t.Errorf("outside path param should be dropped, got %q", got.Get("path")) + } + if got.Get("q") != `path:"dtoro/*"` { + t.Errorf("q should carry the scope, got %q", got.Get("q")) + } + if got.Get("count") != "60" { + t.Errorf("unrelated params must survive, got count=%q", got.Get("count")) + } + + v2 := url.Values{} + v2.Set("path", "dtoro/2024") + got2 := scopeSearchValues(v2, "dtoro") + if got2.Get("path") != "dtoro/2024" { + t.Errorf("inside path param should be kept, got %q", got2.Get("path")) + } +} + +func TestPathValueAllowed(t *testing.T) { + if pathValueAllowed(`"muli/*"`, "dtoro") { + t.Error("outside value must be rejected") + } + if !pathValueAllowed(`"dtoro/Photos/2024"`, "dtoro") { + t.Error("inside value must be allowed") + } + if pathValueAllowed("dtoro/a|muli/b", "dtoro") { + t.Error("any escaping pipe alternative must reject the whole value") + } +} diff --git a/sidecar/main.go b/sidecar/main.go index 4c3a0b3..7bc50f2 100644 --- a/sidecar/main.go +++ b/sidecar/main.go @@ -123,6 +123,11 @@ func main() { auth.GET("/folders", handleFoldersProxy(pp)) } + // PhotoPrism-compatible scoped proxy — the public surface for third- + // party PhotoPrism apps (prism.hubris.network routes here instead of + // straight to PhotoPrism). See handlers_ppproxy.go for the rules. + r.Any("/api/v1/*rest", handlePPProxy(cfg)) + addr := cfg.ListenAddr + ":" + itoa(cfg.Port) srv := &http.Server{ Addr: addr,