diff --git a/sidecar/handlers_dups.go b/sidecar/handlers_dups.go index 0341afa..6e960dc 100644 --- a/sidecar/handlers_dups.go +++ b/sidecar/handlers_dups.go @@ -25,10 +25,10 @@ type dupFileLite struct { } type dupGroup struct { - Hash string `json:"hash"` - Size int64 `json:"size"` - IndexedPath *string `json:"indexedPath"` - Files []dupFileLite `json:"files"` + Hash string `json:"hash"` + Size int64 `json:"size"` + IndexedPath *string `json:"indexedPath"` + Files []dupFileLite `json:"files"` } // dupListPhoto is the partial photo shape we pull from PhotoPrism when diff --git a/sidecar/handlers_folders_proxy.go b/sidecar/handlers_folders_proxy.go index 398c1f5..459188b 100644 --- a/sidecar/handlers_folders_proxy.go +++ b/sidecar/handlers_folders_proxy.go @@ -67,4 +67,4 @@ func handleFoldersProxy(pp *ppClient) gin.HandlerFunc { c.JSON(http.StatusOK, gin.H{"folders": filtered}) } -} \ No newline at end of file +} diff --git a/sidecar/handlers_labels.go b/sidecar/handlers_labels.go index a3f22d6..df24588 100644 --- a/sidecar/handlers_labels.go +++ b/sidecar/handlers_labels.go @@ -115,14 +115,14 @@ func handleLabels(pp *ppClient, ppDb *gorm.DB) gin.HandlerFunc { // 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"` + 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"` } @@ -168,4 +168,4 @@ func handleScopedCounts(ppDb *gorm.DB) gin.HandlerFunc { c.JSON(http.StatusOK, counts) } -} \ No newline at end of file +} diff --git a/sidecar/handlers_ppproxy.go b/sidecar/handlers_ppproxy.go index 6ef3dfb..22377de 100644 --- a/sidecar/handlers_ppproxy.go +++ b/sidecar/handlers_ppproxy.go @@ -1,18 +1,22 @@ 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. @@ -28,17 +32,22 @@ import ( // // - 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; +// - 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 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. +// - 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. // -// Sessions with an empty BasePath (unscoped admins) pass through fully. +// 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/*). @@ -176,8 +185,46 @@ func proxyToken(r *http.Request) string { return r.Header.Get("X-Session-ID") } +// 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) gin.HandlerFunc { +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) @@ -199,7 +246,18 @@ func handlePPProxy(cfg *Config) gin.HandlerFunc { } return func(c *gin.Context) { - rest := strings.TrimPrefix(strings.TrimPrefix(c.Param("rest"), "/"), "") + // 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, client @@ -222,8 +280,10 @@ func handlePPProxy(cfg *Config) gin.HandlerFunc { return } base := strings.Trim(user.BasePath, "/") - if base == "" { - // Unscoped admin — full pass-through. + 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 } @@ -236,14 +296,37 @@ func handlePPProxy(cfg *Config) gin.HandlerFunc { c.Request.URL.RawQuery = scopeSearchValues(q, base).Encode() proxy.ServeHTTP(c.Writer, c.Request) - // Single-photo reads + like/unlike: ownership-checked per UID. + // 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] - 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)) + 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 @@ -254,12 +337,13 @@ func handlePPProxy(cfg *Config) gin.HandlerFunc { } 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/") || + // 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. + 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/")): + rest == "faces" || strings.HasPrefix(rest, "faces/"): proxy.ServeHTTP(c.Writer, c.Request) default: diff --git a/sidecar/handlers_ppproxy_test.go b/sidecar/handlers_ppproxy_test.go index c60b077..def57b5 100644 --- a/sidecar/handlers_ppproxy_test.go +++ b/sidecar/handlers_ppproxy_test.go @@ -1,8 +1,14 @@ package main import ( + "encoding/json" + "net/http" + "net/http/httptest" "net/url" + "strings" "testing" + + "github.com/gin-gonic/gin" ) func TestScopeQ(t *testing.T) { @@ -59,6 +65,150 @@ func TestScopeSearchValues(t *testing.T) { } } +// fakePP stands in for PhotoPrism: answers /session per token, echoes +// every other request's method+path+query back as JSON. +func fakePP(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/session" && r.Method == http.MethodGet { + var user map[string]any + switch r.Header.Get("X-Auth-Token") { + case "tok-scoped": + user = map[string]any{"UID": "u1", "Name": "alice", "Role": "user", "BasePath": "alice"} + case "tok-admin": + user = map[string]any{"UID": "u0", "Name": "root", "Role": "admin", "BasePath": "alice"} + default: + w.WriteHeader(http.StatusUnauthorized) + return + } + json.NewEncoder(w).Encode(map[string]any{"user": user}) + return + } + if strings.HasPrefix(r.URL.Path, "/api/v1/photos/") && r.Method == http.MethodGet && + strings.Count(r.URL.Path, "/") == 4 { + uid := strings.TrimPrefix(r.URL.Path, "/api/v1/photos/") + path := "alice/2024" + if strings.HasPrefix(uid, "foreign") { + path = "bob/2024" + } + json.NewEncoder(w).Encode(map[string]any{"Path": path}) + return + } + json.NewEncoder(w).Encode(map[string]any{ + "echo": r.Method + " " + r.URL.Path, + "query": r.URL.RawQuery, + "handled": true, + }) + })) +} + +func proxyRig(t *testing.T) (*httptest.Server, func()) { + t.Helper() + up := fakePP(t) + cfg := &Config{PhotoprismBaseURL: up.URL} + gin.SetMode(gin.TestMode) + r := gin.New() + r.UseRawPath = true + r.UnescapePathValues = false + r.Any("/api/v1/*rest", handlePPProxy(cfg, nil)) + front := httptest.NewServer(r) + return front, func() { front.Close(); up.Close() } +} + +func proxyReq(t *testing.T, front, method, path, token string) (int, string) { + t.Helper() + req, _ := http.NewRequest(method, front+path, nil) + if token != "" { + req.Header.Set("X-Auth-Token", token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var sb strings.Builder + buf := make([]byte, 4096) + for { + n, err := resp.Body.Read(buf) + sb.Write(buf[:n]) + if err != nil { + break + } + } + return resp.StatusCode, sb.String() +} + +func TestProxyRouting(t *testing.T) { + front, done := proxyRig(t) + defer done() + + t.Run("media passes unauthenticated", func(t *testing.T) { + code, body := proxyReq(t, front.URL, "GET", "/api/v1/t/abc/tok/tile_224", "") + if code != 200 || !strings.Contains(body, "/api/v1/t/abc/tok/tile_224") { + t.Errorf("thumb should pass through, got %d %s", code, body) + } + }) + t.Run("scoped search gains path scope", func(t *testing.T) { + code, body := proxyReq(t, front.URL, "GET", "/api/v1/photos?count=3&q="+url.QueryEscape(`path:"bob/*"`), "tok-scoped") + if code != 200 { + t.Fatalf("got %d", code) + } + var out struct{ Query string } + json.Unmarshal([]byte(body), &out) + q, _ := url.ParseQuery(out.Query) + if q.Get("q") != `path:"alice/*"` { + t.Errorf("escaping q must collapse to own scope, got %q", q.Get("q")) + } + }) + t.Run("scoped settings blocked", func(t *testing.T) { + code, _ := proxyReq(t, front.URL, "GET", "/api/v1/settings", "tok-scoped") + if code != http.StatusForbidden { + t.Errorf("settings should 403 for scoped user, got %d", code) + } + }) + t.Run("admin settings passes", func(t *testing.T) { + code, body := proxyReq(t, front.URL, "GET", "/api/v1/settings", "tok-admin") + if code != 200 || !strings.Contains(body, "/api/v1/settings") { + t.Errorf("admin should pass through, got %d %s", code, body) + } + }) + t.Run("no token unauthorized", func(t *testing.T) { + code, _ := proxyReq(t, front.URL, "GET", "/api/v1/photos", "") + if code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", code) + } + }) + t.Run("own photo readable, foreign 404", func(t *testing.T) { + code, _ := proxyReq(t, front.URL, "GET", "/api/v1/photos/mine123", "tok-scoped") + if code != 200 { + t.Errorf("own photo should pass, got %d", code) + } + code, _ = proxyReq(t, front.URL, "GET", "/api/v1/photos/foreign9", "tok-scoped") + if code != http.StatusNotFound { + t.Errorf("foreign photo should 404, got %d", code) + } + }) + t.Run("encoded traversal cannot reach settings as media", func(t *testing.T) { + code, _ := proxyReq(t, front.URL, "GET", "/api/v1/t%2F..%2Fsettings", "tok-scoped") + if code != http.StatusForbidden { + t.Errorf("traversal should classify as settings and 403, got %d", code) + } + }) + t.Run("batch without db fails closed", func(t *testing.T) { + req, _ := http.NewRequest("POST", front.URL+"/api/v1/batch/photos/archive", + strings.NewReader(`{"photos":["p1"]}`)) + req.Header.Set("X-Auth-Token", "tok-scoped") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Errorf("batch with nil ppDb must 403, got %d", resp.StatusCode) + } + }) +} + func TestPathValueAllowed(t *testing.T) { if pathValueAllowed(`"muli/*"`, "dtoro") { t.Error("outside value must be rejected") diff --git a/sidecar/main.go b/sidecar/main.go index 7bc50f2..bd65f0f 100644 --- a/sidecar/main.go +++ b/sidecar/main.go @@ -79,54 +79,55 @@ func main() { // Every other endpoint runs behind the session gate. Mounting them // under one group keeps the middleware wiring obvious. - auth := r.Group("/api/sidecar", requireSession(pp)) - { - auth.GET("/prefs", handlePrefsGet(db)) - auth.PUT("/prefs", handlePrefsPut(cfg, db)) + auth := r.Group("/api/sidecar", requireSession(pp)) + { + auth.GET("/prefs", handlePrefsGet(db)) + auth.PUT("/prefs", handlePrefsPut(cfg, db)) - auth.GET("/photos/marks", handleMarksAll(db)) - auth.GET("/photos/:uid/marks", handleMarkGet(db)) - auth.PUT("/photos/:uid/marks", handleMarkPut(db)) - auth.POST("/photos/marks/bulk", handleMarkBulk(db)) + auth.GET("/photos/marks", handleMarksAll(db)) + auth.GET("/photos/:uid/marks", handleMarkGet(db)) + auth.PUT("/photos/:uid/marks", handleMarkPut(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/counts", handleFolderCounts(pp)) - auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp)) - auth.POST("/folders/:rel/move", handleFolderMove(cfg, pp)) - auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp)) + auth.POST("/folders", handleFolderCreate(cfg, pp)) + auth.POST("/folders/counts", handleFolderCounts(pp)) + auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp)) + auth.POST("/folders/:rel/move", handleFolderMove(cfg, pp)) + auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp)) - auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp)) - auth.POST("/photos/move", handlePhotosMove(cfg, pp)) + auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp)) + auth.POST("/photos/move", handlePhotosMove(cfg, pp)) - auth.GET("/duplicates/scan", handleDupScan(cfg, pp, db)) - auth.POST("/duplicates/archive", handleDupArchive(cfg, pp, db)) + auth.GET("/duplicates/scan", handleDupScan(cfg, pp, db)) + auth.POST("/duplicates/archive", handleDupArchive(cfg, pp, db)) - // User-scoped proxies — require PpDSN connection. - if ppDb != nil { - auth.GET("/labels", handleLabels(pp, ppDb)) - auth.GET("/counts", handleScopedCounts(ppDb)) - auth.GET("/countries", handleCountries(ppDb)) - } + // User-scoped proxies — require PpDSN connection. + if ppDb != nil { + auth.GET("/labels", handleLabels(pp, ppDb)) + auth.GET("/counts", handleScopedCounts(ppDb)) + auth.GET("/countries", handleCountries(ppDb)) + } - // User-scoped photos — post-filters by BasePath so review/archive - // tabs only show photos the user owns. - auth.GET("/timeline", handlePhotos(pp)) + // User-scoped photos — post-filters by BasePath so review/archive + // tabs only show photos the user owns. + auth.GET("/timeline", handlePhotos(pp)) - // Photos carrying a Note (Caption) — pages PhotoPrism fully so - // the /notes view isn't capped to the newest slice. - auth.GET("/notes", handleNotes(pp)) + // Photos carrying a Note (Caption) — pages PhotoPrism fully so + // the /notes view isn't capped to the newest slice. + auth.GET("/notes", handleNotes(pp)) - // User-scoped folders — post-filters the folder tree by BasePath - // so the sidebar shows only folders under the user's library root. - auth.GET("/folders", handleFoldersProxy(pp)) - } + // User-scoped folders — post-filters the folder tree by BasePath + // so the sidebar shows only folders under the user's library root. + 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)) + // PhotoPrism-compatible scoped proxy — the public /api/v1 surface for + // both the web client and third-party PhotoPrism apps (Caddy routes + // /api/v1 here instead of straight to PhotoPrism, which does not + // enforce base_path in CE). See handlers_ppproxy.go for the rules. + r.Any("/api/v1/*rest", handlePPProxy(cfg, ppDb)) addr := cfg.ListenAddr + ":" + itoa(cfg.Port) srv := &http.Server{ @@ -159,4 +160,3 @@ func main() { } <-idleClosed } - diff --git a/sidecar/pp.go b/sidecar/pp.go index 1ffb5f2..ef101bf 100644 --- a/sidecar/pp.go +++ b/sidecar/pp.go @@ -90,6 +90,7 @@ func (c *ppClient) call(ctx context.Context, method, urlPath, token string, body type ppSessionUser struct { UserUID string `json:"UID"` UserName string `json:"Name"` + Role string `json:"Role"` BasePath string `json:"BasePath"` }