feat(sidecar): scoped PhotoPrism-compatible API proxy for third-party apps
PhotoPrism CE doesn't enforce auth_users.base_path on API reads (any user can q=path:"other/*"). New /api/v1/* proxy forwards to PhotoPrism with per-session enforcement: search queries get their path filter validated/injected, single-photo reads and like are ownership-checked, hash-addressed media and session/config pass through, everything else is 403 for scoped users. Admins (empty BasePath) pass through fully. prism.hubris.network will route here instead of straight to PhotoPrism. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
270
sidecar/handlers_ppproxy.go
Normal file
270
sidecar/handlers_ppproxy.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
72
sidecar/handlers_ppproxy_test.go
Normal file
72
sidecar/handlers_ppproxy_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -123,6 +123,11 @@ func main() {
|
|||||||
auth.GET("/folders", handleFoldersProxy(pp))
|
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)
|
addr := cfg.ListenAddr + ":" + itoa(cfg.Port)
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
|
|||||||
Reference in New Issue
Block a user