Files
mule-image/sidecar/handlers_folders.go
dtoro 032dce6c85 feat(sidecar): port Node prototype to Go + Gin + GORM + MariaDB
Replace the Node prototype (server.mjs) with the stack the merge plan
calls for: Go 1.25, Gin for routing, GORM + MariaDB for persistence.
Same wire contract on /api/sidecar/* so the SvelteKit client doesn't
change.

- Marks move from a JSON file on disk to mule_sidecar.marks (auto-
  migrated by GORM on first boot). The Node prototype's marks.json
  was dev-only; not migrated.
- Folder/rename/heap-convert/duplicates handlers reproduce the
  prototype's behaviour, including the path-traversal defence
  (resolveUnderRoot + EvalSymlinks), the size-bucket prefilter for
  the duplicate hasher, and the background reindex fire-and-forget
  pattern.
- Auth model unchanged: requireSession middleware proxies the
  caller's X-Auth-Token to PhotoPrism's /api/v1/photos?count=1
  before any destructive op.
- Expose pp-mariadb on 127.0.0.1:3306 in docker-compose so the
  host Go process can reach mule_sidecar.* without joining the
  container network.
- Archive the Node prototype under sidecar/legacy/server.mjs for
  one cycle as reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:15:47 +02:00

179 lines
5.3 KiB
Go

package main
import (
"context"
"errors"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"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 _, 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
}
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
}
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})
}
}
// 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)
}
}