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>
This commit is contained in:
2026-05-17 17:15:47 +02:00
parent 0766b47bb2
commit 032dce6c85
17 changed files with 1838 additions and 30 deletions

143
sidecar/handlers_rename.go Normal file
View File

@@ -0,0 +1,143 @@
package main
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
// renameBody mirrors the Node prototype's wire contract — a single
// `newName` field carrying the bare basename (no slashes).
type renameBody struct {
NewName string `json:"newName"`
}
// ppPhoto is the partial PhotoPrism photo shape we need to find the
// primary file's on-disk location. Anything we don't read stays
// unspecified so version drift across PhotoPrism builds doesn't break
// JSON unmarshalling.
type ppPhoto struct {
Files []ppFile `json:"Files"`
}
type ppFile struct {
Name string `json:"Name"`
Root string `json:"Root"`
Primary bool `json:"Primary"`
}
func primaryFileOf(p *ppPhoto) (ppFile, bool) {
if p == nil {
return ppFile{}, false
}
for _, f := range p.Files {
if f.Primary {
return f, true
}
}
if len(p.Files) > 0 {
return p.Files[0], true
}
return ppFile{}, false
}
func handleRename(cfg *Config, pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
photoUID := c.Param("uid")
var body renameBody
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 filename"})
return
}
// Fetch the photo so we can resolve Files[0].Root + Name into a
// concrete on-disk path. PhotoPrism has no "file by UID" endpoint
// in this build, so the single-photo lookup is the cheapest path.
resp, err := pp.call(c.Request.Context(), http.MethodGet, "/api/v1/photos/"+photoUID, token, nil)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
if !resp.OK {
c.JSON(resp.Status, gin.H{"error": "photo not found"})
return
}
var photo ppPhoto
if err := json.Unmarshal(resp.Body, &photo); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "decode photo"})
return
}
file, ok := primaryFileOf(&photo)
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "no files on this photo"})
return
}
root := strings.TrimPrefix(file.Root, "/")
if root == "" || root == "/" {
root = ""
}
relPath := filepath.Join(root, file.Name)
oldAbs := filepath.Join(cfg.OriginalsRoot, relPath)
if !ensureWithinOriginals(cfg.OriginalsRoot, oldAbs) {
c.JSON(http.StatusBadRequest, gin.H{"error": "path escapes originals root"})
return
}
st, err := os.Stat(oldAbs)
if err != nil || !st.Mode().IsRegular() {
c.JSON(http.StatusNotFound, gin.H{"error": "file missing on disk"})
return
}
newAbs := filepath.Join(filepath.Dir(oldAbs), newName)
if !ensureWithinOriginals(cfg.OriginalsRoot, newAbs) {
c.JSON(http.StatusBadRequest, gin.H{"error": "new path escapes originals root"})
return
}
if _, err := os.Stat(newAbs); err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "target filename already exists"})
return
} else if !errors.Is(err, os.ErrNotExist) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
newRel := filepath.Join(root, newName)
slog.Info("rename", "from", relPath, "to", newRel)
if err := os.Rename(oldAbs, newAbs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Trigger reindex on the parent so PhotoPrism picks up the new
// filename and drops the orphan row for the old name. Best-effort.
reindexPath := "/"
if root != "" {
reindexPath = "/" + root
}
if err := pp.reindex(c.Request.Context(), token, reindexPath); err != nil {
slog.Warn("rename reindex failed", "err", err)
}
c.JSON(http.StatusOK, gin.H{
"ok": true,
"oldName": file.Name,
"newName": newName,
"oldRelPath": relPath,
"newRelPath": newRel,
})
}
}