Files
mule-image/sidecar/main.go
dtoro 4362e475a7 build(sidecar): containerize + delete the Node prototype
- New sidecar/Dockerfile: multi-stage golang:1.25-alpine → distroless/
  static, ~12 MB final image, static CGO-free binary.
- Wire pp-sidecar into docker-compose.photoprism.yml so the whole
  stack (mariadb + photoprism + sidecar) starts with one
  `podman-compose up`. Container reaches mariadb + photoprism on the
  internal network; the host gets 127.0.0.1:8000 for Vite's proxy.
- New SIDECAR_LISTEN_ADDR env var (default 127.0.0.1 for the host-mode
  dev loop) so the container can bind 0.0.0.0:8000 and let the port
  mapping reach it. Without this the loopback bind was invisible to
  the host.
- Delete sidecar/legacy/server.mjs — the Node prototype's archival
  window is over; git history is its home now.
- Update sidecar/README with compose-first bringup; keep the host
  `go build` flow as the fast-iteration loop.

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

114 lines
3.2 KiB
Go

// mule-sidecar — Go service for endpoints PhotoPrism does not expose.
//
// Ports the Node prototype (server.mjs) to the stack the merge plan calls
// out: Go + Gin + GORM + MariaDB. Same wire contract as the prototype so
// the SvelteKit web client doesn't need to change.
//
// Auth model is unchanged: the caller's X-Auth-Token is the only authority.
// requireSession validates it against PhotoPrism's /api/v1/photos before
// any destructive op runs.
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
)
func main() {
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})))
cfg, err := loadConfig()
if err != nil {
slog.Error("config", "err", err)
os.Exit(1)
}
db, err := openDB(cfg.DSN)
if err != nil {
slog.Error("db open", "err", err)
os.Exit(1)
}
pp := newPPClient(cfg.PhotoprismBaseURL)
gin.SetMode(gin.ReleaseMode)
r := gin.New()
// Keep `%2F` literal in path params so callers can pass URL-encoded
// nested folder paths (e.g. `foo%2Fbar`) without the router splitting
// them into separate segments. Handlers decode via url.PathUnescape.
r.UseRawPath = true
r.UnescapePathValues = false
r.Use(gin.Recovery())
// Health probe — unauthenticated so a process supervisor can call it
// without needing PhotoPrism to be reachable.
r.GET("/api/sidecar/healthz", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"ok": true,
"originalsRoot": cfg.OriginalsRoot,
})
})
// Every other endpoint runs behind the session gate. Mounting them
// under one group keeps the middleware wiring obvious.
auth := r.Group("/api/sidecar", requireSession(pp))
{
auth.GET("/photos/marks", handleMarksAll(db))
auth.GET("/photos/:uid/marks", handleMarkGet(db))
auth.PUT("/photos/:uid/marks", handleMarkPut(db))
auth.POST("/photos/marks/bulk", handleMarkBulk(db))
auth.POST("/files/:uid/rename", handleRename(cfg, pp))
auth.POST("/folders", handleFolderCreate(cfg, pp))
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp))
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
auth.GET("/duplicates/scan", handleDupScan(cfg, pp))
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp))
}
addr := cfg.ListenAddr + ":" + itoa(cfg.Port)
srv := &http.Server{
Addr: addr,
Handler: r,
ReadHeaderTimeout: 5 * time.Second,
}
// Graceful shutdown so an in-flight duplicate scan or heap convert
// gets a chance to finish (or at least flush logs) on SIGTERM.
idleClosed := make(chan struct{})
go func() {
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs
slog.Info("shutdown signal received")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
close(idleClosed)
}()
slog.Info("mule-sidecar listening",
"addr", "http://"+addr,
"originals", cfg.OriginalsRoot,
)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("listen", "err", err)
os.Exit(1)
}
<-idleClosed
}