Backend correctness was already sound (companion files travel as a
group, coordinated collision suffixes, EXDEV fallback, per-file scope
checks, blocking scoped reindex) — this pass adds reversibility and
brings the modal up to standard.
Sidecar:
- movePhotoFiles records per-file {from,to} pairs (move mode) —
including siblings of photos that failed partway, since undo must
restore whatever actually left its folder. Both POST /photos/move
and POST /albums/:uid/convert return them as movedFiles.
- New POST /files/restore-moves plays those pairs backwards: both ends
scope-checked (sources aren't quarantined like the duplicates
restore), never clobbers an existing destination, EXDEV fallback,
blocking reindex of affected parents so the client's refetch already
sees the restored layout.
Dialog (all three subjects — photos, heap convert, folder reparent):
- Search field on top (autofocused) filtering the tree live: matches +
ancestors, force-expanded without touching the sidebar's persisted
open/collapse state (new FolderTree forceExpand prop).
- Arrow keys rove through visible rows with selection following focus
(data-move-row attributes in FolderTree's readonly picker mode);
Enter confirms from anywhere once a destination is set.
- Recent destinations as one-click chips (last 5, per library base).
- Live destination preview line and count-labeled confirm buttons
("Move 12 photos", "Move “2024”") with a disabled-reason tooltip.
- Client-side subfolder validation mirroring the sidecar's
sanitizeFilename rules (inline error, aria-invalid, confirm gated).
- Pre-disables Move when every selected photo is already in the target.
- Undo everywhere it's safe: photo/heap moves restore via the new
endpoint, folder moves invert to another folder move, copies stay
toast-only (their inverse would be deletion). Success toasts carry
an inline Undo action; ⌘Z works through the shared undo stack.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
167 lines
5.4 KiB
Go
167 lines
5.4 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("/prefs", handlePrefsGet(db))
|
|
auth.PUT("/prefs", handlePrefsPut(cfg, db))
|
|
|
|
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.POST("/folders/:rel/move", handleFolderMove(cfg, pp))
|
|
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
|
|
|
|
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
|
|
auth.POST("/photos/move", handlePhotosMove(cfg, pp))
|
|
auth.POST("/files/restore-moves", handleRestoreMoves(cfg, pp))
|
|
|
|
auth.GET("/duplicates/scan", handleDupScan(cfg, pp, db))
|
|
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp, db))
|
|
auth.POST("/duplicates/restore", handleDupRestore(cfg, pp, db))
|
|
|
|
// User-scoped proxies — require PpDSN connection.
|
|
if ppDb != nil {
|
|
auth.GET("/labels", handleLabels(pp, ppDb))
|
|
auth.GET("/counts", handleScopedCounts(ppDb))
|
|
auth.GET("/countries", handleCountries(ppDb))
|
|
auth.GET("/subjects", handleSubjects(pp, ppDb))
|
|
auth.GET("/faces/unnamed", handleUnnamedFaces(ppDb))
|
|
}
|
|
|
|
// User-scoped photos — post-filters by BasePath so review/archive
|
|
// tabs only show photos the user owns.
|
|
auth.GET("/timeline", handlePhotos(pp))
|
|
|
|
// Photos carrying a Note (Caption) — pages PhotoPrism fully so
|
|
// the /notes view isn't capped to the newest slice.
|
|
auth.GET("/notes", handleNotes(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))
|
|
}
|
|
|
|
// PhotoPrism-compatible scoped proxy — the public /api/v1 surface for
|
|
// both the web client and third-party PhotoPrism apps (Caddy routes
|
|
// /api/v1 here instead of straight to PhotoPrism, which does not
|
|
// enforce base_path in CE). See handlers_ppproxy.go for the rules.
|
|
r.Any("/api/v1/*rest", handlePPProxy(cfg, ppDb))
|
|
|
|
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
|
|
}
|