Compare commits
3 Commits
634abc2a95
...
312a4c1ee4
| Author | SHA1 | Date | |
|---|---|---|---|
| 312a4c1ee4 | |||
| e578e1ce75 | |||
| 6cbabda86b |
@@ -2,6 +2,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -85,3 +87,35 @@ func ctxBasePath(c *gin.Context) string {
|
|||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// userScopeRoot returns the absolute directory the session may mutate:
|
||||||
|
// ORIGINALS_ROOT/<BasePath> for scoped users, the whole originals root for
|
||||||
|
// admins (empty BasePath). Lexical join only — callers compare it against
|
||||||
|
// paths built the same way from cfg.OriginalsRoot.
|
||||||
|
func userScopeRoot(c *gin.Context, cfg *Config) string {
|
||||||
|
base := strings.Trim(ctxBasePath(c), "/")
|
||||||
|
if base == "" {
|
||||||
|
return cfg.OriginalsRoot
|
||||||
|
}
|
||||||
|
return filepath.Join(cfg.OriginalsRoot, base)
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireUserScope guards an already-root-resolved absolute path against
|
||||||
|
// the caller's BasePath. PhotoPrism scopes what a session can *see* by
|
||||||
|
// BasePath, but the sidecar's filesystem endpoints accept raw paths, so
|
||||||
|
// every mutation must re-check that boundary here. `strict` additionally
|
||||||
|
// rejects the scope root itself — renaming/deleting/moving the user's own
|
||||||
|
// base folder would detach their library from auth_users.base_path.
|
||||||
|
// Writes the 403 response and returns false when out of bounds.
|
||||||
|
func requireUserScope(c *gin.Context, cfg *Config, abs string, strict bool) bool {
|
||||||
|
scope := userScopeRoot(c, cfg)
|
||||||
|
if !sameOrUnder(abs, scope) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "path outside your library"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strict && abs == scope {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "cannot modify your library root"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
50
sidecar/auth_test.go
Normal file
50
sidecar/auth_test.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func scopeCtx(basePath string) *gin.Context {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||||
|
c.Set("basePath", basePath)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireUserScope(t *testing.T) {
|
||||||
|
cfg := &Config{OriginalsRoot: filepath.FromSlash("/originals")}
|
||||||
|
abs := func(rel string) string { return filepath.Join(cfg.OriginalsRoot, filepath.FromSlash(rel)) }
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
basePath string
|
||||||
|
path string
|
||||||
|
strict bool
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"admin sees root", "", cfg.OriginalsRoot, false, true},
|
||||||
|
{"admin anywhere", "", abs("bob/x"), false, true},
|
||||||
|
{"admin strict rejects root", "", cfg.OriginalsRoot, true, false},
|
||||||
|
{"scoped inside own tree", "alice", abs("alice/2024"), false, true},
|
||||||
|
{"scoped own root non-strict", "alice", abs("alice"), false, true},
|
||||||
|
{"scoped own root strict", "alice", abs("alice"), true, false},
|
||||||
|
{"scoped other user", "alice", abs("bob/2024"), false, false},
|
||||||
|
{"scoped sibling prefix", "alice", abs("alice2/2024"), false, false},
|
||||||
|
{"scoped originals root", "alice", cfg.OriginalsRoot, false, false},
|
||||||
|
{"nested base path", "family/alice", abs("family/alice/x"), false, true},
|
||||||
|
{"nested base path parent", "family/alice", abs("family"), false, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
c := scopeCtx(tc.basePath)
|
||||||
|
if got := requireUserScope(c, cfg, tc.path, tc.strict); got != tc.want {
|
||||||
|
t.Errorf("requireUserScope(base=%q, path=%q, strict=%v) = %v, want %v",
|
||||||
|
tc.basePath, tc.path, tc.strict, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,10 +25,10 @@ type dupFileLite struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type dupGroup struct {
|
type dupGroup struct {
|
||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
IndexedPath *string `json:"indexedPath"`
|
IndexedPath *string `json:"indexedPath"`
|
||||||
Files []dupFileLite `json:"files"`
|
Files []dupFileLite `json:"files"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// dupListPhoto is the partial photo shape we pull from PhotoPrism when
|
// dupListPhoto is the partial photo shape we pull from PhotoPrism when
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ func handleFolderCreate(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireUserScope(c, cfg, abs, true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if _, err := os.Stat(abs); err == nil {
|
if _, err := os.Stat(abs); err == nil {
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "already exists"})
|
c.JSON(http.StatusConflict, gin.H{"error": "already exists"})
|
||||||
return
|
return
|
||||||
@@ -92,6 +95,9 @@ func handleFolderRename(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireUserScope(c, cfg, oldAbs, true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
st, err := os.Stat(oldAbs)
|
st, err := os.Stat(oldAbs)
|
||||||
if err != nil || !st.IsDir() {
|
if err != nil || !st.IsDir() {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
|
||||||
@@ -142,6 +148,9 @@ func handleFolderDelete(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "refuse to delete root"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "refuse to delete root"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireUserScope(c, cfg, abs, true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
st, err := os.Stat(abs)
|
st, err := os.Stat(abs)
|
||||||
if err != nil || !st.IsDir() {
|
if err != nil || !st.IsDir() {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
|
||||||
|
|||||||
@@ -67,4 +67,4 @@ func handleFoldersProxy(pp *ppClient) gin.HandlerFunc {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"folders": filtered})
|
c.JSON(http.StatusOK, gin.H{"folders": filtered})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,9 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireUserScope(c, cfg, targetAbs, false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
// Pull the heap's membership via the q=album:UID query (count=1000
|
// Pull the heap's membership via the q=album:UID query (count=1000
|
||||||
// covers every realistic heap). We only need the UID list here — the
|
// covers every realistic heap). We only need the UID list here — the
|
||||||
// search's Files array is trimmed and drops videos, so we re-resolve
|
// search's Files array is trimmed and drops videos, so we re-resolve
|
||||||
@@ -124,7 +127,7 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
moved, copied, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode)
|
moved, copied, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode, userScopeRoot(c, cfg))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -167,7 +170,10 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
// and handlePhotosMove (UID-list scoped); both resolve `photos` differently
|
// and handlePhotosMove (UID-list scoped); both resolve `photos` differently
|
||||||
// but move them identically. Returns per-photo errors in `errs`; the returned
|
// but move them identically. Returns per-photo errors in `errs`; the returned
|
||||||
// top-level error is only for a fatal precondition (subfolder mkdir failed).
|
// top-level error is only for a fatal precondition (subfolder mkdir failed).
|
||||||
func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto, targetAbs, subfolder, mode string) (moved, copied int, errs []heapErr, err error) {
|
// `scopeAbs` is the caller's userScopeRoot — source files outside it fail
|
||||||
|
// per-photo, so a UID that resolves outside the user's BasePath (however
|
||||||
|
// PhotoPrism came to return it) can't be used to pull files across users.
|
||||||
|
func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto, targetAbs, subfolder, mode, scopeAbs string) (moved, copied int, errs []heapErr, err error) {
|
||||||
destAbs := targetAbs
|
destAbs := targetAbs
|
||||||
if subfolder != "" {
|
if subfolder != "" {
|
||||||
destAbs = filepath.Join(targetAbs, subfolder)
|
destAbs = filepath.Join(targetAbs, subfolder)
|
||||||
@@ -234,8 +240,8 @@ func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto,
|
|||||||
for _, f := range group {
|
for _, f := range group {
|
||||||
srcRel := f.Name
|
srcRel := f.Name
|
||||||
srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel)
|
srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel)
|
||||||
if !sameOrUnder(srcAbs, cfg.OriginalsRoot) {
|
if !sameOrUnder(srcAbs, scopeAbs) {
|
||||||
failure = "path escapes originals"
|
failure = "path outside your library"
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
st, statErr := os.Stat(srcAbs)
|
st, statErr := os.Stat(srcAbs)
|
||||||
|
|||||||
@@ -115,14 +115,14 @@ func handleLabels(pp *ppClient, ppDb *gorm.DB) gin.HandlerFunc {
|
|||||||
// PpCounts mirrors PhotoPrism's session config.count block that drives
|
// PpCounts mirrors PhotoPrism's session config.count block that drives
|
||||||
// the sidebar badges (review, archive, all, etc.).
|
// the sidebar badges (review, archive, all, etc.).
|
||||||
type PpCounts struct {
|
type PpCounts struct {
|
||||||
All int `json:"all"`
|
All int `json:"all"`
|
||||||
Photos int `json:"photos"`
|
Photos int `json:"photos"`
|
||||||
Media int `json:"media"`
|
Media int `json:"media"`
|
||||||
Videos int `json:"videos"`
|
Videos int `json:"videos"`
|
||||||
Review int `json:"review"`
|
Review int `json:"review"`
|
||||||
Archived int `json:"archived"`
|
Archived int `json:"archived"`
|
||||||
Hidden int `json:"hidden"`
|
Hidden int `json:"hidden"`
|
||||||
Private int `json:"private"`
|
Private int `json:"private"`
|
||||||
Favorites int `json:"favorites"`
|
Favorites int `json:"favorites"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,4 +168,4 @@ func handleScopedCounts(ppDb *gorm.DB) gin.HandlerFunc {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, counts)
|
c.JSON(http.StatusOK, counts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ func handlePhotosMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireUserScope(c, cfg, targetAbs, false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve each photo's FULL file list via the single-photo endpoint
|
// Resolve each photo's FULL file list via the single-photo endpoint
|
||||||
// rather than the /photos search (see resolvePhotosFull) — the search
|
// rather than the /photos search (see resolvePhotosFull) — the search
|
||||||
@@ -67,7 +70,7 @@ func handlePhotosMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
moved, copied, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode)
|
moved, copied, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode, userScopeRoot(c, cfg))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -148,6 +151,9 @@ func handleFolderMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireUserScope(c, cfg, oldAbs, true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
st, err := os.Stat(oldAbs)
|
st, err := os.Stat(oldAbs)
|
||||||
if err != nil || !st.IsDir() {
|
if err != nil || !st.IsDir() {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
|
||||||
@@ -158,6 +164,9 @@ func handleFolderMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetParent"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetParent"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireUserScope(c, cfg, targetParentAbs, false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
// Can't move a folder into itself or one of its own descendants.
|
// Can't move a folder into itself or one of its own descendants.
|
||||||
if sameOrUnder(targetParentAbs, oldAbs) {
|
if sameOrUnder(targetParentAbs, oldAbs) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot move a folder into itself"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot move a folder into itself"})
|
||||||
|
|||||||
354
sidecar/handlers_ppproxy.go
Normal file
354
sidecar/handlers_ppproxy.go
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
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.
|
||||||
|
//
|
||||||
|
// 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;
|
||||||
|
// - 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 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.
|
||||||
|
//
|
||||||
|
// 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/*).
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, ppDb *gorm.DB) 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) {
|
||||||
|
// 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
|
||||||
|
// 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 == "" || 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// 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]
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
|
||||||
|
// 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/"):
|
||||||
|
proxy.ServeHTTP(c.Writer, c.Request)
|
||||||
|
|
||||||
|
default:
|
||||||
|
slog.Info("pp-proxy: blocked", "user", user.UserName, "method", method, "path", rest)
|
||||||
|
forbid(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
222
sidecar/handlers_ppproxy_test.go
Normal file
222
sidecar/handlers_ppproxy_test.go
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,9 @@ func handleRename(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "path escapes originals root"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "path escapes originals root"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireUserScope(c, cfg, oldAbs, false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
st, err := os.Stat(oldAbs)
|
st, err := os.Stat(oldAbs)
|
||||||
if err != nil || !st.Mode().IsRegular() {
|
if err != nil || !st.Mode().IsRegular() {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "file missing on disk"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "file missing on disk"})
|
||||||
|
|||||||
@@ -79,49 +79,55 @@ func main() {
|
|||||||
|
|
||||||
// Every other endpoint runs behind the session gate. Mounting them
|
// Every other endpoint runs behind the session gate. Mounting them
|
||||||
// under one group keeps the middleware wiring obvious.
|
// under one group keeps the middleware wiring obvious.
|
||||||
auth := r.Group("/api/sidecar", requireSession(pp))
|
auth := r.Group("/api/sidecar", requireSession(pp))
|
||||||
{
|
{
|
||||||
auth.GET("/prefs", handlePrefsGet(db))
|
auth.GET("/prefs", handlePrefsGet(db))
|
||||||
auth.PUT("/prefs", handlePrefsPut(cfg, db))
|
auth.PUT("/prefs", handlePrefsPut(cfg, db))
|
||||||
|
|
||||||
auth.GET("/photos/marks", handleMarksAll(db))
|
auth.GET("/photos/marks", handleMarksAll(db))
|
||||||
auth.GET("/photos/:uid/marks", handleMarkGet(db))
|
auth.GET("/photos/:uid/marks", handleMarkGet(db))
|
||||||
auth.PUT("/photos/:uid/marks", handleMarkPut(db))
|
auth.PUT("/photos/:uid/marks", handleMarkPut(db))
|
||||||
auth.POST("/photos/marks/bulk", handleMarkBulk(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", handleFolderCreate(cfg, pp))
|
||||||
auth.POST("/folders/counts", handleFolderCounts(pp))
|
auth.POST("/folders/counts", handleFolderCounts(pp))
|
||||||
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp))
|
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp))
|
||||||
auth.POST("/folders/:rel/move", handleFolderMove(cfg, pp))
|
auth.POST("/folders/:rel/move", handleFolderMove(cfg, pp))
|
||||||
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
|
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
|
||||||
|
|
||||||
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
|
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
|
||||||
auth.POST("/photos/move", handlePhotosMove(cfg, pp))
|
auth.POST("/photos/move", handlePhotosMove(cfg, pp))
|
||||||
|
|
||||||
auth.GET("/duplicates/scan", handleDupScan(cfg, pp, db))
|
auth.GET("/duplicates/scan", handleDupScan(cfg, pp, db))
|
||||||
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp, db))
|
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp, db))
|
||||||
|
|
||||||
// User-scoped proxies — require PpDSN connection.
|
// User-scoped proxies — require PpDSN connection.
|
||||||
if ppDb != nil {
|
if ppDb != nil {
|
||||||
auth.GET("/labels", handleLabels(pp, ppDb))
|
auth.GET("/labels", handleLabels(pp, ppDb))
|
||||||
auth.GET("/counts", handleScopedCounts(ppDb))
|
auth.GET("/counts", handleScopedCounts(ppDb))
|
||||||
auth.GET("/countries", handleCountries(ppDb))
|
auth.GET("/countries", handleCountries(ppDb))
|
||||||
}
|
}
|
||||||
|
|
||||||
// User-scoped photos — post-filters by BasePath so review/archive
|
// User-scoped photos — post-filters by BasePath so review/archive
|
||||||
// tabs only show photos the user owns.
|
// tabs only show photos the user owns.
|
||||||
auth.GET("/timeline", handlePhotos(pp))
|
auth.GET("/timeline", handlePhotos(pp))
|
||||||
|
|
||||||
// Photos carrying a Note (Caption) — pages PhotoPrism fully so
|
// Photos carrying a Note (Caption) — pages PhotoPrism fully so
|
||||||
// the /notes view isn't capped to the newest slice.
|
// the /notes view isn't capped to the newest slice.
|
||||||
auth.GET("/notes", handleNotes(pp))
|
auth.GET("/notes", handleNotes(pp))
|
||||||
|
|
||||||
// User-scoped folders — post-filters the folder tree by BasePath
|
// User-scoped folders — post-filters the folder tree by BasePath
|
||||||
// so the sidebar shows only folders under the user's library root.
|
// so the sidebar shows only folders under the user's library root.
|
||||||
auth.GET("/folders", handleFoldersProxy(pp))
|
auth.GET("/folders", handleFoldersProxy(pp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
addr := cfg.ListenAddr + ":" + itoa(cfg.Port)
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
@@ -154,4 +160,3 @@ func main() {
|
|||||||
}
|
}
|
||||||
<-idleClosed
|
<-idleClosed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ func (c *ppClient) call(ctx context.Context, method, urlPath, token string, body
|
|||||||
type ppSessionUser struct {
|
type ppSessionUser struct {
|
||||||
UserUID string `json:"UID"`
|
UserUID string `json:"UID"`
|
||||||
UserName string `json:"Name"`
|
UserName string `json:"Name"`
|
||||||
|
Role string `json:"Role"`
|
||||||
BasePath string `json:"BasePath"`
|
BasePath string `json:"BasePath"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,12 +86,15 @@ func reconcileUserBasepaths(ppDSN, originalsRoot string, mapping map[string]stri
|
|||||||
// ACL kicks in even if the directory is created later.
|
// ACL kicks in even if the directory is created later.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// upload_path rides along with base_path so anything a client app
|
||||||
|
// uploads (WebDAV sync apps, PhotoPrism's own UI) lands inside the
|
||||||
|
// user's library subtree instead of the shared originals root.
|
||||||
res := db.Exec(`UPDATE auth_users
|
res := db.Exec(`UPDATE auth_users
|
||||||
SET base_path = ?
|
SET base_path = ?, upload_path = ?
|
||||||
WHERE user_name = ?
|
WHERE user_name = ?
|
||||||
AND COALESCE(base_path, '') <> ?
|
AND (COALESCE(base_path, '') <> ? OR COALESCE(upload_path, '') <> ?)
|
||||||
AND deleted_at IS NULL`,
|
AND deleted_at IS NULL`,
|
||||||
path, username, path)
|
path, path, username, path, path)
|
||||||
if res.Error != nil {
|
if res.Error != nil {
|
||||||
slog.Error("user-basepath: update failed", "user", username, "err", res.Error)
|
slog.Error("user-basepath: update failed", "user", username, "err", res.Error)
|
||||||
continue
|
continue
|
||||||
|
|||||||
Reference in New Issue
Block a user