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:
178
sidecar/handlers_folders.go
Normal file
178
sidecar/handlers_folders.go
Normal file
@@ -0,0 +1,178 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user