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:
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user