Folder create/rename/delete/move, photo move, heap convert, and file rename now reject paths outside the caller's BasePath (403). Sources resolved via PhotoPrism UIDs are re-checked in movePhotoFiles. The USER_BASEPATHS reconciler also sets upload_path so client-app uploads land inside the user's subtree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
317 lines
9.9 KiB
Go
317 lines
9.9 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"sync"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// pathParam pulls the URL-encoded :rel out of the Gin context and
|
||
// unescapes it. UseRawPath is on at the router level (see main.go) so the
|
||
// raw value still carries `%2F` for nested paths; we decode here.
|
||
func pathParam(c *gin.Context, key string) (string, bool) {
|
||
raw := c.Param(key)
|
||
if raw == "" {
|
||
return "", false
|
||
}
|
||
dec, err := url.PathUnescape(raw)
|
||
if err != nil {
|
||
return "", false
|
||
}
|
||
return dec, true
|
||
}
|
||
|
||
type folderCreateBody struct {
|
||
Path string `json:"path"`
|
||
}
|
||
|
||
func handleFolderCreate(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
token := ctxToken(c)
|
||
var body folderCreateBody
|
||
if err := c.ShouldBindJSON(&body); err != nil || body.Path == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "path required"})
|
||
return
|
||
}
|
||
abs, err := resolveUnderRoot(cfg.OriginalsRoot, body.Path, false)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||
return
|
||
}
|
||
if !requireUserScope(c, cfg, abs, true) {
|
||
return
|
||
}
|
||
if _, err := os.Stat(abs); err == nil {
|
||
c.JSON(http.StatusConflict, gin.H{"error": "already exists"})
|
||
return
|
||
} else if !errors.Is(err, os.ErrNotExist) {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
if err := os.Mkdir(abs, 0o755); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
rel, _ := filepath.Rel(cfg.OriginalsRoot, abs)
|
||
slog.Info("folder.create", "path", rel)
|
||
go fireReindex(cfg, pp, token, "/"+filepath.Dir(rel))
|
||
c.JSON(http.StatusOK, gin.H{"ok": true, "path": rel})
|
||
}
|
||
}
|
||
|
||
type folderRenameBody struct {
|
||
NewName string `json:"newName"`
|
||
}
|
||
|
||
func handleFolderRename(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
token := ctxToken(c)
|
||
rel, ok := pathParam(c, "rel")
|
||
if !ok {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||
return
|
||
}
|
||
var body folderRenameBody
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||
return
|
||
}
|
||
newName, ok := sanitizeFilename(body.NewName)
|
||
if !ok {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "newName must be a plain dirname"})
|
||
return
|
||
}
|
||
oldAbs, err := resolveUnderRoot(cfg.OriginalsRoot, rel, true)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||
return
|
||
}
|
||
if !requireUserScope(c, cfg, oldAbs, true) {
|
||
return
|
||
}
|
||
st, err := os.Stat(oldAbs)
|
||
if err != nil || !st.IsDir() {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
|
||
return
|
||
}
|
||
newAbs := filepath.Join(filepath.Dir(oldAbs), newName)
|
||
if _, err := os.Stat(newAbs); err == nil {
|
||
c.JSON(http.StatusConflict, gin.H{"error": "target already exists"})
|
||
return
|
||
} else if !errors.Is(err, os.ErrNotExist) {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
if !sameOrUnder(newAbs, cfg.OriginalsRoot) {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "target escapes root"})
|
||
return
|
||
}
|
||
if err := os.Rename(oldAbs, newAbs); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
oldRel, _ := filepath.Rel(cfg.OriginalsRoot, oldAbs)
|
||
newRel, _ := filepath.Rel(cfg.OriginalsRoot, newAbs)
|
||
slog.Info("folder.rename", "from", oldRel, "to", newRel)
|
||
go fireReindex(cfg, pp, token, "/"+filepath.Dir(oldRel))
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"ok": true,
|
||
"oldPath": oldRel,
|
||
"newPath": newRel,
|
||
})
|
||
}
|
||
}
|
||
|
||
func handleFolderDelete(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
token := ctxToken(c)
|
||
rel, ok := pathParam(c, "rel")
|
||
if !ok {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||
return
|
||
}
|
||
abs, err := resolveUnderRoot(cfg.OriginalsRoot, rel, true)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||
return
|
||
}
|
||
if abs == cfg.OriginalsRoot {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "refuse to delete root"})
|
||
return
|
||
}
|
||
if !requireUserScope(c, cfg, abs, true) {
|
||
return
|
||
}
|
||
st, err := os.Stat(abs)
|
||
if err != nil || !st.IsDir() {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
|
||
return
|
||
}
|
||
entries, err := os.ReadDir(abs)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
if len(entries) > 0 {
|
||
c.JSON(http.StatusConflict, gin.H{"error": "directory not empty"})
|
||
return
|
||
}
|
||
if err := os.Remove(abs); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
slog.Info("folder.delete", "path", rel)
|
||
go fireReindex(cfg, pp, token, "/"+filepath.Dir(rel))
|
||
c.JSON(http.StatusOK, gin.H{"ok": true, "path": rel})
|
||
}
|
||
}
|
||
|
||
type folderCountsBody struct {
|
||
Paths []string `json:"paths"`
|
||
}
|
||
|
||
// folderCountsRow is the minimal PhotoPrism photo projection the handler
|
||
// needs — just UID, so dedupe-by-UID survives `merged=false` (which
|
||
// expands one photo into one row per File on disk). PhotoPrism returns a
|
||
// JSON array of much richer objects; unmarshalling into this small
|
||
// shape ignores everything we don't care about.
|
||
type folderCountsRow struct {
|
||
UID string `json:"UID"`
|
||
}
|
||
|
||
// handleFolderCounts returns photo counts for each PhotoPrism folder
|
||
// path in one round-trip. The web client used to fire one
|
||
// `/photos?count=1000` per folder from the browser (≈1 MB JSON per
|
||
// folder × N folders) to populate the left-sidebar tree. Moving the
|
||
// fan-out into the sidecar keeps the same correctness profile — same
|
||
// q-DSL, same `merged=false` UID dedupe — but the wire payload back
|
||
// to the browser collapses to a single small JSON object
|
||
// (`{path: count}`).
|
||
//
|
||
// We bounce off PhotoPrism with paginated `count=1000` calls and dedupe
|
||
// UIDs server-side rather than trusting a count header: PhotoPrism's
|
||
// `/photos` X-Count is the *per-page* row count (per existing front-end
|
||
// comment), not the total-match count, so we'd silently undercount any
|
||
// folder with more than 1000 files. The loop walks offsets until PP
|
||
// returns a short page, so the result is correct regardless of folder
|
||
// size (until the wider fan-out becomes the bottleneck, which is many
|
||
// orders of magnitude away on this hardware).
|
||
//
|
||
// Bounded concurrency caps the fan-out so a library with hundreds of
|
||
// folders doesn't open hundreds of connections to PhotoPrism at once.
|
||
// Errors per-folder degrade to count=0 rather than failing the whole
|
||
// batch — the sidebar would rather show a missing badge for one folder
|
||
// than nothing for any.
|
||
func handleFolderCounts(pp *ppClient) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
token := ctxToken(c)
|
||
var body folderCountsBody
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||
return
|
||
}
|
||
if len(body.Paths) == 0 {
|
||
c.JSON(http.StatusOK, gin.H{})
|
||
return
|
||
}
|
||
|
||
const maxInFlight = 8
|
||
var (
|
||
wg sync.WaitGroup
|
||
sem = make(chan struct{}, maxInFlight)
|
||
mu sync.Mutex
|
||
counts = make(map[string]int, len(body.Paths))
|
||
)
|
||
// Seed every input key so the response always carries the same
|
||
// shape the client posted, even for paths whose lookup failed.
|
||
for _, p := range body.Paths {
|
||
counts[p] = 0
|
||
}
|
||
for _, p := range body.Paths {
|
||
path := p
|
||
wg.Add(1)
|
||
sem <- struct{}{}
|
||
go func() {
|
||
defer wg.Done()
|
||
defer func() { <-sem }()
|
||
// `path:<x>` is an exact match in PhotoPrism's q-DSL —
|
||
// it matches only photos whose `photo_path` field equals
|
||
// <x>, not descendants. The indexer always nests photos
|
||
// under YYYY/MM, so an internal tree node like `2024` has
|
||
// zero direct children and reports a count of 0 unless we
|
||
// recurse. `path:<x>*` is the documented wildcard form and
|
||
// matches both `<x>` itself (no harm if empty) and every
|
||
// `<x>/...` descendant.
|
||
//
|
||
// `merged=false` still returns one row per File on disk,
|
||
// so HEIC + companion JPG count twice unless we dedupe by
|
||
// UID — which is what the old client-side code did, and
|
||
// what we keep doing here.
|
||
q := url.QueryEscape(`path:"` + path + `*"`)
|
||
const pageSize = 1000
|
||
seen := make(map[string]struct{})
|
||
for offset := 0; ; offset += pageSize {
|
||
resp, err := pp.call(c.Request.Context(), http.MethodGet,
|
||
fmt.Sprintf("/api/v1/photos?count=%d&offset=%d&merged=false&q=%s", pageSize, offset, q),
|
||
token, nil)
|
||
if err != nil || !resp.OK {
|
||
slog.Warn("folder.counts: pp call failed",
|
||
"path", path,
|
||
"offset", offset,
|
||
"err", err,
|
||
"status", func() int {
|
||
if resp != nil {
|
||
return resp.Status
|
||
}
|
||
return 0
|
||
}())
|
||
return
|
||
}
|
||
var rows []folderCountsRow
|
||
if err := json.Unmarshal(resp.Body, &rows); err != nil {
|
||
slog.Warn("folder.counts: parse failed", "path", path, "offset", offset, "err", err)
|
||
return
|
||
}
|
||
for _, r := range rows {
|
||
if r.UID == "" {
|
||
continue
|
||
}
|
||
seen[r.UID] = struct{}{}
|
||
}
|
||
if len(rows) < pageSize {
|
||
break
|
||
}
|
||
}
|
||
mu.Lock()
|
||
counts[path] = len(seen)
|
||
mu.Unlock()
|
||
}()
|
||
}
|
||
wg.Wait()
|
||
c.JSON(http.StatusOK, counts)
|
||
}
|
||
}
|
||
|
||
// fireReindex wraps pp.reindex with logging and a detached context so
|
||
// it can run in a goroutine after the response has gone out. The Node
|
||
// prototype kicks reindex with `void reindex(...)` and never awaits;
|
||
// matching that here keeps the apparent latency of mutating endpoints
|
||
// low (PhotoPrism's index can take seconds on a big folder).
|
||
func fireReindex(_ *Config, pp *ppClient, token, parentRel string) {
|
||
// pp.call's client already enforces a 60s timeout, so the parent
|
||
// context can be detached from the request — the handler has long
|
||
// since written its response.
|
||
if err := pp.reindex(context.Background(), token, parentRel); err != nil {
|
||
slog.Warn("reindex failed", "path", parentRel, "err", err)
|
||
}
|
||
}
|