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

206
sidecar/handlers_marks.go Normal file
View File

@@ -0,0 +1,206 @@
package main
import (
"errors"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// validColors is the four-color palette mule-image always shipped. The
// empty string is the explicit "clear color" sentinel.
var validColors = map[string]struct{}{
"red": {},
"orange": {},
"yellow": {},
"green": {},
}
// markPatch is the request body for all three mutating mark endpoints.
// Pointers distinguish "field omitted" from "field set to zero" — a PUT
// with `{"rating": 0}` clears the rating, but a PUT with `{"color": "red"}`
// alone must NOT wipe an existing rating.
type markPatch struct {
Rating *int `json:"rating,omitempty"`
Color *string `json:"color,omitempty"`
}
func (p *markPatch) sanitize() error {
if p.Rating != nil {
r := *p.Rating
if r < 0 || r > 5 {
return errors.New("rating out of range")
}
}
if p.Color != nil {
c := strings.ToLower(strings.TrimSpace(*p.Color))
if c != "" {
if _, ok := validColors[c]; !ok {
return errors.New("invalid color")
}
}
*p.Color = c
}
return nil
}
// apply merges the patch onto an existing row (or a fresh zero-value
// Mark for an upsert). Returns true if anything in the row still has a
// non-empty value — false signals "delete the row" to the caller.
func (p *markPatch) apply(m *Mark) bool {
if p.Rating != nil {
if *p.Rating > 0 {
r := *p.Rating
m.Rating = &r
} else {
m.Rating = nil
}
}
if p.Color != nil {
if *p.Color != "" {
c := *p.Color
m.Color = &c
} else {
m.Color = nil
}
}
return m.Rating != nil || (m.Color != nil && *m.Color != "")
}
// allMarksJSON renders the entire `marks` table as the wire shape
// `{"<uid>": {"rating": …, "color": …, "updatedAt": …}, …}`. Used by
// GET /photos/marks which the web client calls once on session start.
func allMarksJSON(db *gorm.DB) (map[string]map[string]any, error) {
var rows []Mark
if err := db.Find(&rows).Error; err != nil {
return nil, err
}
out := make(map[string]map[string]any, len(rows))
for i := range rows {
out[rows[i].PhotoUID] = rows[i].asJSON()
}
return out, nil
}
func handleMarksAll(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
marks, err := allMarksJSON(db)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, marks)
}
}
func handleMarkGet(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("uid")
var m Mark
err := db.Where("photo_uid = ?", uid).First(&m).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusOK, gin.H{})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, m.asJSON())
}
}
// upsert applies the patch and writes back. Returns the resulting JSON
// shape (empty map if the row was deleted).
func upsert(db *gorm.DB, uid string, patch *markPatch) (map[string]any, error) {
var m Mark
err := db.Where("photo_uid = ?", uid).First(&m).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
m.PhotoUID = uid
keep := patch.apply(&m)
m.UpdatedAt = time.Now().UTC()
if !keep {
// Drop the row entirely so a re-fetch returns {}.
if err := db.Where("photo_uid = ?", uid).Delete(&Mark{}).Error; err != nil {
return nil, err
}
return map[string]any{}, nil
}
if err := db.Save(&m).Error; err != nil {
return nil, err
}
return m.asJSON(), nil
}
func handleMarkPut(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("uid")
var patch markPatch
if err := c.ShouldBindJSON(&patch); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid patch"})
return
}
if err := patch.sanitize(); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
out, err := upsert(db, uid, &patch)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, out)
}
}
type bulkBody struct {
IDs []string `json:"ids"`
Patch markPatch `json:"patch"`
}
func handleMarkBulk(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var body bulkBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
if len(body.IDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ids[] required"})
return
}
if err := body.Patch.sanitize(); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
applied := make(map[string]map[string]any, len(body.IDs))
// Single transaction so a partial failure rolls back. The client
// expects atomic semantics for a bulk star/colour stamp.
err := db.Transaction(func(tx *gorm.DB) error {
for _, uid := range body.IDs {
if uid == "" {
continue
}
out, err := upsert(tx, uid, &body.Patch)
if err != nil {
return err
}
applied[uid] = out
}
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"count": len(applied),
"marks": applied,
})
}
}