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:` is an exact match in PhotoPrism's q-DSL — // it matches only photos whose `photo_path` field equals // , 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:*` is the documented wildcard form and // matches both `` itself (no harm if empty) and every // `/...` 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) } }