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>
179 lines
5.3 KiB
Go
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)
|
|
}
|
|
}
|