feat(sidecar): extend scoped proxy to web-client mutations, harden path classification

Batch archive/restore/delete/approve/private validate every UID against
the PhotoPrism DB in one query. Per-photo PUT/approve/like/stack-file
ops are ownership-checked. Admin-role sessions pass through fully so
settings/users/index dialogs keep working. Paths are unescaped+cleaned
before classification so encoded dot-segments can't smuggle past the
allowlist. Full httptest coverage of the routing decisions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 13:05:12 +02:00
parent e578e1ce75
commit 312a4c1ee4
7 changed files with 307 additions and 72 deletions

View File

@@ -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
}