Files
mule-image/sidecar/main.go
dtoro 243e5d3831 fix: sidecar response interceptor + folder proxy
- Added 401-handling response interceptor to sidecar axios instance
  (matches existing http instance) so expired/invalid tokens redirect
  to login instead of showing raw 404/401 errors
- Added GET /api/sidecar/folders — proxies PhotoPrism's
  /api/v1/folders/originals with BasePath post-filter
- Updated listFolders() frontend to call sidecar proxy
- Updated plan with remaining fixes
2026-06-06 23:01:38 +02:00

148 lines
4.5 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"
"gorm.io/gorm"
)
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)
// Apply any declared username→BasePath mapping to PhotoPrism's
// auth_users table. Runs immediately + every 60s thereafter so a
// user who logs in after the sidecar booted still gets their
// BasePath wired without an admin restart.
startUserBasepathReconciler(cfg)
// Open a second DB handle pointed at PhotoPrism's own schema for
// handlers that need to query auth_users, photos, labels, etc.
// May be nil if PpDSN is empty (no PP_DB_PASSWORD set).
var ppDb *gorm.DB
if cfg.PpDSN != "" {
if d, err := openDB(cfg.PpDSN); err == nil {
ppDb = d
} else {
slog.Warn("pp db open failed — scoped labels/counts unavailable", "err", err)
}
}
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/counts", handleFolderCounts(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))
// User-scoped proxies — require PpDSN connection.
if ppDb != nil {
auth.GET("/labels", handleLabels(pp, ppDb))
auth.GET("/counts", handleScopedCounts(ppDb))
}
// User-scoped photos — post-filters by BasePath so review/archive
// tabs only show photos the user owns.
auth.GET("/timeline", handlePhotos(pp))
// User-scoped folders — post-filters the folder tree by BasePath
// so the sidebar shows only folders under the user's library root.
auth.GET("/folders", handleFoldersProxy(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
}