From 032dce6c85b7646833b9241ce63c0d012d8fefaf Mon Sep 17 00:00:00 2001 From: dtoro Date: Sun, 17 May 2026 17:15:47 +0200 Subject: [PATCH] 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) --- .gitignore | 3 + docker-compose.photoprism.yml | 6 + sidecar/README.md | 113 +++++++++++---- sidecar/auth.go | 44 ++++++ sidecar/config.go | 60 ++++++++ sidecar/db.go | 63 +++++++++ sidecar/fs.go | 213 +++++++++++++++++++++++++++++ sidecar/go.mod | 45 ++++++ sidecar/go.sum | 101 ++++++++++++++ sidecar/handlers_dups.go | 230 +++++++++++++++++++++++++++++++ sidecar/handlers_folders.go | 178 ++++++++++++++++++++++++ sidecar/handlers_heap.go | 235 ++++++++++++++++++++++++++++++++ sidecar/handlers_marks.go | 206 ++++++++++++++++++++++++++++ sidecar/handlers_rename.go | 143 +++++++++++++++++++ sidecar/{ => legacy}/server.mjs | 0 sidecar/main.go | 113 +++++++++++++++ sidecar/pp.go | 115 ++++++++++++++++ 17 files changed, 1838 insertions(+), 30 deletions(-) create mode 100644 sidecar/auth.go create mode 100644 sidecar/config.go create mode 100644 sidecar/db.go create mode 100644 sidecar/fs.go create mode 100644 sidecar/go.mod create mode 100644 sidecar/go.sum create mode 100644 sidecar/handlers_dups.go create mode 100644 sidecar/handlers_folders.go create mode 100644 sidecar/handlers_heap.go create mode 100644 sidecar/handlers_marks.go create mode 100644 sidecar/handlers_rename.go rename sidecar/{ => legacy}/server.mjs (100%) create mode 100644 sidecar/main.go create mode 100644 sidecar/pp.go diff --git a/.gitignore b/.gitignore index 66c8702..36abee2 100644 --- a/.gitignore +++ b/.gitignore @@ -72,6 +72,9 @@ docker-compose.override.yml # Sidecar runtime state (per-user marks etc.) — generated, not seed data. /sidecar/data/ +# Sidecar Go build output. +/sidecar/mule-sidecar + # Photos (for development) /photos/ diff --git a/docker-compose.photoprism.yml b/docker-compose.photoprism.yml index b9af80f..8ba1618 100644 --- a/docker-compose.photoprism.yml +++ b/docker-compose.photoprism.yml @@ -33,6 +33,12 @@ services: MARIADB_USER: ${PP_DB_USER:-photoprism} MARIADB_PASSWORD: ${PP_DB_PASSWORD:?set PP_DB_PASSWORD in .env.photoprism} MARIADB_ROOT_PASSWORD: ${PP_DB_ROOT_PASSWORD:?set PP_DB_ROOT_PASSWORD in .env.photoprism} + # Loopback-only host port so the mule-sidecar (running as a host process + # in M4) can reach `mule_sidecar.*` over TCP. Not exposed beyond + # 127.0.0.1; the photoprism container still resolves mariadb by service + # name on the photoprism-network bridge. + ports: + - "127.0.0.1:${PP_DB_PORT:-3306}:3306" volumes: - pp_mariadb_data:/var/lib/mysql # The init script creates the mule_sidecar database + user that the Go diff --git a/sidecar/README.md b/sidecar/README.md index 6f3c1c2..57d6335 100644 --- a/sidecar/README.md +++ b/sidecar/README.md @@ -1,43 +1,96 @@ # mule-sidecar -Auxiliary service that handles operations PhotoPrism's REST API does not expose. +Go + Gin + GORM service for the endpoints PhotoPrism's REST API does not +expose. Same wire contract as the M3 Node prototype it replaces; the +SvelteKit client at [web/](../web/) talks to it transparently through +Vite's `/api/sidecar/*` proxy. -## Why this exists +## What it owns -Per the merge plan at `/home/dtoro/.claude/plans/i-want-you-to-twinkly-galaxy.md`, -a Go + Gin + GORM service (matching PhotoPrism's stack) will eventually own: +| Method | Path | Purpose | +| ------ | --------------------------------------- | --------------------------------------------- | +| GET | `/api/sidecar/healthz` | Unauthenticated liveness probe. | +| GET | `/api/sidecar/photos/marks` | Every per-photo `{rating, color}` mark. | +| GET | `/api/sidecar/photos/:uid/marks` | One photo's mark (or `{}` if none). | +| PUT | `/api/sidecar/photos/:uid/marks` | Patch one photo's mark. | +| POST | `/api/sidecar/photos/marks/bulk` | Stamp the same mark onto many photos. | +| POST | `/api/sidecar/files/:uid/rename` | Rename the primary file on disk + reindex. | +| POST | `/api/sidecar/folders` | Create a folder under `${ORIGINALS_ROOT}`. | +| POST | `/api/sidecar/folders/:rel/rename` | Rename a folder (rel path URL-encoded). | +| DELETE | `/api/sidecar/folders/:rel` | Delete an **empty** folder. | +| POST | `/api/sidecar/albums/:uid/convert` | Move/copy every photo in a heap into folder X. | +| GET | `/api/sidecar/duplicates/scan` | Walk originals, return same-hash groups. | +| POST | `/api/sidecar/duplicates/archive` | Move duplicate paths into `.duplicates//`. | -- Per-user heap sharing with pending invitations -- Folder mutations under `originals/` (create / rename / delete / move) -- **File rename** on disk (PhotoPrism's `OriginalName` is a display-only rename) +Auth: every endpoint except `healthz` requires the caller's +`X-Auth-Token` header. The sidecar holds no service credentials — it +proxies the token straight back to PhotoPrism's `/api/v1/photos?count=1` +to confirm the session is live before doing anything destructive. -The plan picks Go for stack consistency and the option to upstream features. +Marks persist to **MariaDB** (`mule_sidecar.marks`); everything else +operates on the filesystem under `${ORIGINALS_ROOT}` and triggers a +PhotoPrism reindex of the affected parent in the background. -## What ships today +## Build & run -A **Node.js prototype** (`server.mjs`) covering only the **file rename** endpoint. +```sh +cd sidecar +go build -o mule-sidecar . -The decision to ship Node first is pragmatic — Go isn't installed on this dev -box and `sudo dnf install golang` needs a password. Node is already on PATH for -the SvelteKit dev server, so a single-file Node service unblocks the feature -without changing the host setup. - -The endpoint contract is stable: when M4 lands the proper Go service, the -SvelteKit client keeps calling the same paths. - -## Endpoints - -- `POST /api/sidecar/files/:photoUid/rename` `{ "newName": "newfile.png" }` - Renames the primary file of the photo on disk under `${ORIGINALS_ROOT}`, - then triggers a PhotoPrism reindex of the parent path. - -## Run - -``` -ORIGINALS_ROOT=/home/dtoro/projects/mule-image/photos-sample \ +ORIGINALS_ROOT=/path/to/photoprism/originals \ PHOTOPRISM_BASE_URL=http://localhost:2342 \ SIDECAR_PORT=8000 \ -node server.mjs +./mule-sidecar ``` -The SvelteKit dev server proxies `/api/sidecar/*` to `http://localhost:8000`. +The SvelteKit dev server proxies `/api/sidecar/*` to +`http://localhost:8000`. + +## Env + +| Var | Default | Notes | +| --------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------- | +| `ORIGINALS_ROOT` | `/photoprism/originals` | Absolute path; must match PhotoPrism's mount. | +| `PHOTOPRISM_BASE_URL` | `http://localhost:2342` | Where to reach PhotoPrism for session validation + reindex calls. | +| `SIDECAR_PORT` | `8000` | Loopback-only; reverse-proxy fronts it in production. | +| `SIDECAR_DSN` | _(built from the vars below)_ | Set this to override the assembled MySQL DSN entirely. | +| `SIDECAR_DB_HOST` | `127.0.0.1` | Host of the MariaDB the compose stack publishes on `127.0.0.1:3306`. | +| `SIDECAR_DB_PORT` | `3306` | | +| `SIDECAR_DB_USER` | `sidecar` | Provisioned by [`mariadb/init/01-sidecar.sql`](../mariadb/init/01-sidecar.sql) on first boot. | +| `SIDECAR_DB_PASSWORD` | `replace-at-m4-bringup` | Literal placeholder — **rotate before any non-local deployment**. | +| `SIDECAR_DB_NAME` | `mule_sidecar` | | + +## Schema + +GORM `AutoMigrate` creates the only table the service owns: + +```sql +CREATE TABLE marks ( + photo_uid VARCHAR(64) PRIMARY KEY, + rating BIGINT NULL, + color VARCHAR(16) NULL, + updated_at DATETIME(3) +); +``` + +The M3 Node prototype kept the same data in `sidecar/data/marks.json`. +There is no migration path — the prototype's marks file was dev-only +state. Heap-sharing tables (M4) will land in subsequent migrations. + +## Layout + +```text +sidecar/ +├── main.go entrypoint, route wiring, graceful shutdown +├── config.go env-driven Config +├── db.go GORM open + Mark model + AutoMigrate +├── auth.go requireSession middleware + ctxToken +├── fs.go path safety, walk, sha1 +├── pp.go PhotoPrism HTTP client (validateSession, reindex) +├── handlers_rename.go +├── handlers_folders.go +├── handlers_marks.go +├── handlers_heap.go +├── handlers_dups.go +└── legacy/server.mjs Node prototype, retained for one cycle as a reference. +``` diff --git a/sidecar/auth.go b/sidecar/auth.go new file mode 100644 index 0000000..0e3310e --- /dev/null +++ b/sidecar/auth.go @@ -0,0 +1,44 @@ +package main + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// requireSession is the standard auth shim every mutating handler wears. +// We don't store a shared service credential — the caller's X-Auth-Token +// is the only authority, and we probe PhotoPrism with it before doing any +// destructive work. The handler reads the validated token off the context +// via ctxToken so it can keep forwarding it to PhotoPrism for the actual +// operation. +func requireSession(pp *ppClient) gin.HandlerFunc { + return func(c *gin.Context) { + token := c.GetHeader("X-Auth-Token") + if token == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "no token"}) + return + } + if !pp.validateSession(c.Request.Context(), token) { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid session"}) + return + } + c.Set("token", token) + c.Next() + } +} + +// ctxToken returns the validated X-Auth-Token a previous requireSession +// middleware stored on the request. Handlers MUST run behind that +// middleware; otherwise this returns the empty string. +func ctxToken(c *gin.Context) string { + v, ok := c.Get("token") + if !ok { + return "" + } + s, ok := v.(string) + if !ok { + return "" + } + return s +} diff --git a/sidecar/config.go b/sidecar/config.go new file mode 100644 index 0000000..b14c1dc --- /dev/null +++ b/sidecar/config.go @@ -0,0 +1,60 @@ +package main + +import ( + "os" + "path/filepath" + "strconv" +) + +// Config aggregates every runtime knob the sidecar reads from the +// environment. Held in one struct so the rest of the package can take a +// pointer instead of poking os.Getenv at use-sites. +type Config struct { + OriginalsRoot string // absolute path to PhotoPrism's originals dir + PhotoprismBaseURL string // e.g. http://localhost:2342 + Port int // HTTP listen port (loopback only) + DSN string // GORM/MySQL connection string for mule_sidecar +} + +func loadConfig() (*Config, error) { + root := envOr("ORIGINALS_ROOT", "/photoprism/originals") + abs, err := filepath.Abs(root) + if err != nil { + return nil, err + } + + portStr := envOr("SIDECAR_PORT", "8000") + port, err := strconv.Atoi(portStr) + if err != nil { + return nil, err + } + + dsn := os.Getenv("SIDECAR_DSN") + if dsn == "" { + // Default matches the user that mariadb/init/01-sidecar.sql provisions + // on first boot. The literal placeholder password is intentional: the + // SQL ships with it and an env-templating step is left for whoever + // runs this in a non-local context. + user := envOr("SIDECAR_DB_USER", "sidecar") + pass := envOr("SIDECAR_DB_PASSWORD", "replace-at-m4-bringup") + host := envOr("SIDECAR_DB_HOST", "127.0.0.1") + dbPort := envOr("SIDECAR_DB_PORT", "3306") + name := envOr("SIDECAR_DB_NAME", "mule_sidecar") + dsn = user + ":" + pass + "@tcp(" + host + ":" + dbPort + ")/" + name + + "?charset=utf8mb4&parseTime=true&loc=Local" + } + + return &Config{ + OriginalsRoot: abs, + PhotoprismBaseURL: envOr("PHOTOPRISM_BASE_URL", "http://localhost:2342"), + Port: port, + DSN: dsn, + }, nil +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/sidecar/db.go b/sidecar/db.go new file mode 100644 index 0000000..ddad1ff --- /dev/null +++ b/sidecar/db.go @@ -0,0 +1,63 @@ +package main + +import ( + "time" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// Mark mirrors the per-photo extras the web client stores via the marks +// endpoints — rating + four-colour label. PhotoUID is the row key; both +// payload fields are nullable so the sparse "no rating / no colour" state +// round-trips cleanly. The Node prototype kept this in a JSON file; we +// migrate to MariaDB here so the M4 sharing work has a real table to +// extend. +type Mark struct { + PhotoUID string `gorm:"primaryKey;size:64;column:photo_uid" json:"-"` + Rating *int `gorm:"column:rating" json:"rating,omitempty"` + Color *string `gorm:"size:16;column:color" json:"color,omitempty"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` +} + +// TableName pins the GORM-pluralised default to a name that matches the +// other tables the M4 plan calls out (`marks`, `heap_shares`, …) so +// nothing surprising lands in the schema. +func (Mark) TableName() string { return "marks" } + +// asJSON returns the wire shape clients expect — same flat object the +// Node prototype emitted. An empty Mark (rating=nil, color=nil) renders +// as `{}` which the client treats as "no mark on this photo". +func (m *Mark) asJSON() map[string]any { + out := map[string]any{} + if m == nil { + return out + } + if m.Rating != nil { + out["rating"] = *m.Rating + } + if m.Color != nil && *m.Color != "" { + out["color"] = *m.Color + } + if !m.UpdatedAt.IsZero() { + // ISO-8601 with millisecond precision, UTC — matches the Node + // prototype's `new Date().toISOString()` so clients written against + // the old endpoint stay happy. + out["updatedAt"] = m.UpdatedAt.UTC().Format("2006-01-02T15:04:05.000Z") + } + return out +} + +func openDB(dsn string) (*gorm.DB, error) { + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Warn), + }) + if err != nil { + return nil, err + } + if err := db.AutoMigrate(&Mark{}); err != nil { + return nil, err + } + return db, nil +} diff --git a/sidecar/fs.go b/sidecar/fs.go new file mode 100644 index 0000000..f307208 --- /dev/null +++ b/sidecar/fs.go @@ -0,0 +1,213 @@ +package main + +import ( + "crypto/sha1" + "encoding/hex" + "errors" + "io" + "os" + "path/filepath" + "strings" +) + +// sanitizeFilename trims a user-supplied filename and rejects anything +// dangerous: path separators, leading dots, NUL bytes, the `.`/`..` +// pseudo-names, anything absurdly long. PhotoPrism's indexer is happy +// with most filename shapes; we lock down the ones a malicious or +// careless caller might use to escape the folder. +func sanitizeFilename(name string) (string, bool) { + s := strings.TrimSpace(name) + if s == "" || len(s) > 240 { + return "", false + } + if strings.HasPrefix(s, ".") { + return "", false + } + if s == "." || s == ".." { + return "", false + } + if strings.ContainsAny(s, "/\\\x00") { + return "", false + } + return s, true +} + +// resolveUnderRoot takes a user-supplied relative path and returns its +// absolute form, but only when the resolved location lives under the +// configured originals root. Symlink escapes are caught by resolving the +// parent through filepath.EvalSymlinks first. +// +// `mustExist=false` is for the *target* of a rename/create where the +// terminal path isn't on disk yet; the parent still has to exist and +// still has to be inside the root. +func resolveUnderRoot(root, rel string, mustExist bool) (string, error) { + if rel == "" { + return "", errors.New("empty path") + } + clean := strings.TrimLeft(rel, "/") + if clean == "" || clean == "." { + return "", errors.New("empty path") + } + for _, seg := range strings.Split(clean, "/") { + if seg == "" || seg == ".." { + return "", errors.New("path traversal") + } + } + abs := filepath.Join(root, clean) + parent := filepath.Dir(abs) + + parentReal, err := filepath.EvalSymlinks(parent) + if err != nil { + return "", err + } + if !sameOrUnder(parentReal, root) { + return "", errors.New("parent escapes originals root") + } + if mustExist { + if _, err := os.Stat(abs); err != nil { + return "", err + } + } + return abs, nil +} + +// ensureWithinOriginals checks that an absolute path's parent resolves to +// somewhere inside the root after symlink evaluation. Used for the +// already-resolved-on-disk paths returned by PhotoPrism's Files[]. +func ensureWithinOriginals(root, absPath string) bool { + real, err := filepath.EvalSymlinks(filepath.Dir(absPath)) + if err != nil { + return false + } + return sameOrUnder(real, root) +} + +func sameOrUnder(p, root string) bool { + if p == root { + return true + } + return strings.HasPrefix(p, root+string(os.PathSeparator)) +} + +// uniqueName resolves "destDir/basename" against collisions by appending +// `-1`, `-2`, … to the stem. Caps at 1000 attempts so a runaway loop +// can't pin the goroutine forever. +func uniqueName(destDir, basename string) (abs, name string, ok bool) { + ext := filepath.Ext(basename) + stem := strings.TrimSuffix(basename, ext) + for i := 0; i < 1000; i++ { + candidate := basename + if i > 0 { + candidate = stem + "-" + itoa(i) + ext + } + p := filepath.Join(destDir, candidate) + if _, err := os.Stat(p); errors.Is(err, os.ErrNotExist) { + return p, candidate, true + } + } + return "", "", false +} + +// itoa is the tiny stdlib-free formatter we use inside hot loops. +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} + +// fileEntry is the per-file row walkFiles emits. relPath stays root- +// relative so it can land in API responses unchanged. +type fileEntry struct { + RelPath string + AbsPath string + Size int64 +} + +// supportedExts mirrors the Node prototype's whitelist. PhotoPrism +// itself walks the same set; we keep the list in lock-step so callers +// don't see "duplicate" warnings about files PhotoPrism would ignore. +var supportedExts = map[string]struct{}{ + ".jpg": {}, ".jpeg": {}, ".png": {}, ".heic": {}, ".heif": {}, + ".tiff": {}, ".tif": {}, ".gif": {}, ".bmp": {}, ".webp": {}, ".avif": {}, + ".mov": {}, ".mp4": {}, ".m4v": {}, ".avi": {}, ".mkv": {}, ".webm": {}, + ".dng": {}, ".cr2": {}, ".cr3": {}, ".nef": {}, ".arw": {}, + ".orf": {}, ".rw2": {}, ".raw": {}, +} + +// walkFiles enumerates every supported media file under root, skipping +// dotfiles/dotdirs (matches PhotoPrism's indexer and our own quarantine +// folder). Errors on individual entries are swallowed so a single +// permission-denied dir doesn't abort the whole scan. +func walkFiles(root string) ([]fileEntry, error) { + var out []fileEntry + err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { + if err != nil { + // Permission errors etc. — skip the offending subtree but + // keep walking. The dup-scan endpoint is best-effort. + if d != nil && d.IsDir() { + return filepath.SkipDir + } + return nil + } + name := d.Name() + if p != root && strings.HasPrefix(name, ".") { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + if d.IsDir() { + return nil + } + ext := strings.ToLower(filepath.Ext(name)) + if _, ok := supportedExts[ext]; !ok { + return nil + } + info, err := d.Info() + if err != nil { + return nil + } + rel, err := filepath.Rel(root, p) + if err != nil { + return nil + } + out = append(out, fileEntry{ + RelPath: rel, + AbsPath: p, + Size: info.Size(), + }) + return nil + }) + return out, err +} + +// sha1File streams the file through a SHA1 hasher so a 4GB ProRes clip +// doesn't blow the process's RAM. Returns the hex digest. +func sha1File(absPath string) (string, error) { + f, err := os.Open(absPath) + if err != nil { + return "", err + } + defer f.Close() + h := sha1.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/sidecar/go.mod b/sidecar/go.mod new file mode 100644 index 0000000..b435917 --- /dev/null +++ b/sidecar/go.mod @@ -0,0 +1,45 @@ +module mule-sidecar + +go 1.25.0 + +require ( + github.com/gin-gonic/gin v1.12.0 + gorm.io/driver/mysql v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/sidecar/go.sum b/sidecar/go.sum new file mode 100644 index 0000000..e08bb5b --- /dev/null +++ b/sidecar/go.sum @@ -0,0 +1,101 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/sidecar/handlers_dups.go b/sidecar/handlers_dups.go new file mode 100644 index 0000000..de62a75 --- /dev/null +++ b/sidecar/handlers_dups.go @@ -0,0 +1,230 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "os" + "path/filepath" + "sort" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +const quarantineDir = ".duplicates" + +type dupFileLite struct { + Path string `json:"path"` + Size int64 `json:"size"` +} + +type dupGroup struct { + Hash string `json:"hash"` + Size int64 `json:"size"` + IndexedPath *string `json:"indexedPath"` + Files []dupFileLite `json:"files"` +} + +// dupListPhoto is the partial photo shape we pull from PhotoPrism when +// looking up "which file path has this hash already indexed", used to +// hint the UI which copy to keep. +type dupListPhoto struct { + Files []ppFile `json:"Files"` +} + +func handleDupScan(cfg *Config, pp *ppClient) gin.HandlerFunc { + return func(c *gin.Context) { + token := ctxToken(c) + start := time.Now() + slog.Info("dup.scan starting", "root", cfg.OriginalsRoot) + + all, err := walkFiles(cfg.OriginalsRoot) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Group by size first: byte-identical files necessarily share size, + // so size-collision is a cheap O(N) prefilter that lets us skip + // hashing >95% of a typical library. + bySize := map[int64][]fileEntry{} + for _, f := range all { + bySize[f.Size] = append(bySize[f.Size], f) + } + + // Hash size-collision buckets concurrently. Cap fan-out to GOMAXPROCS + // so we don't drown the disk with parallel reads on a spinning HDD. + type hashOut struct { + hash string + f fileEntry + } + var ( + wg sync.WaitGroup + sem = make(chan struct{}, 4) + outMu sync.Mutex + byHash = map[string][]fileEntry{} + hashSize = map[string]int64{} + ) + for size, group := range bySize { + if len(group) < 2 { + continue + } + for _, f := range group { + wg.Add(1) + sem <- struct{}{} + go func(f fileEntry, sz int64) { + defer wg.Done() + defer func() { <-sem }() + h, err := sha1File(f.AbsPath) + if err != nil { + return + } + outMu.Lock() + byHash[h] = append(byHash[h], f) + hashSize[h] = sz + outMu.Unlock() + }(f, size) + } + } + wg.Wait() + + // Drop singletons (size collision but different hashes), then ask + // PhotoPrism which of the duplicates it has indexed so the UI can + // default the "keep" selection to that one. + groups := make([]dupGroup, 0) + for h, files := range byHash { + if len(files) < 2 { + continue + } + g := dupGroup{Hash: h, Size: hashSize[h]} + for _, f := range files { + g.Files = append(g.Files, dupFileLite{Path: f.RelPath, Size: f.Size}) + } + // Best-effort lookup; swallow errors. The hash query is cheap on + // PhotoPrism's side (indexed column). + resp, err := pp.call(c.Request.Context(), http.MethodGet, + "/api/v1/photos?q=hash:"+h+"&count=1&merged=true", token, nil) + if err == nil && resp.OK { + var photos []dupListPhoto + if err := json.Unmarshal(resp.Body, &photos); err == nil && len(photos) > 0 { + if pf, ok := primaryFileOf(&ppPhoto{Files: photos[0].Files}); ok && pf.Name != "" { + p := pf.Name + g.IndexedPath = &p + } + } + } + groups = append(groups, g) + } + // Sort by reclaimable bytes descending (size × duplicate-count) so + // the biggest wins float to the top of the UI. + sort.Slice(groups, func(i, j int) bool { + return groups[i].Size*int64(len(groups[i].Files)-1) > + groups[j].Size*int64(len(groups[j].Files)-1) + }) + + ms := time.Since(start).Milliseconds() + slog.Info("dup.scan done", "groups", len(groups), "ms", ms) + c.JSON(http.StatusOK, gin.H{ + "groups": groups, + "scannedMs": ms, + }) + } +} + +type dupArchiveBody struct { + Paths []string `json:"paths"` +} + +type dupMoved struct { + From string `json:"from"` + To string `json:"to"` +} + +type dupArchiveErr struct { + Path string `json:"path"` + Error string `json:"error"` +} + +func handleDupArchive(cfg *Config, pp *ppClient) gin.HandlerFunc { + return func(c *gin.Context) { + token := ctxToken(c) + var body dupArchiveBody + if err := c.ShouldBindJSON(&body); err != nil || len(body.Paths) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "paths[] required"}) + return + } + + // Each archive batch lands in its own timestamped subdir so the + // user can browse what was quarantined when (and recover by hand + // if they change their mind). + stamp := time.Now().UTC().Format("2006-01-02T15-04-05.000Z") + targetDir := filepath.Join(cfg.OriginalsRoot, quarantineDir, stamp) + if err := os.MkdirAll(targetDir, 0o755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // archiveOne moves a single file into the quarantine batch dir + // and returns the new relative path. Disambiguates same-basename + // collisions within the batch so two `IMG_0001.jpg` from + // different folders don't clobber each other. + archiveOne := func(rel string) (string, error) { + abs, err := resolveUnderRoot(cfg.OriginalsRoot, rel, true) + if err != nil { + return "", errors.New("invalid path") + } + base := filepath.Base(abs) + dest := filepath.Join(targetDir, base) + for i := 1; ; i++ { + if _, err := os.Stat(dest); errors.Is(err, os.ErrNotExist) { + break + } else if err != nil { + return "", err + } + stem := base[:len(base)-len(filepath.Ext(base))] + dest = filepath.Join(targetDir, stem+"__"+itoa(i)+filepath.Ext(base)) + } + if err := os.Rename(abs, dest); err != nil { + // EXDEV fallback — copy+remove for libraries that span + // filesystems (e.g. originals on a different mount). + if err2 := copyFile(abs, dest); err2 != nil { + return "", err + } + if err2 := os.Remove(abs); err2 != nil { + return "", errors.New("moved but source remove failed: " + err2.Error()) + } + } + relDest, _ := filepath.Rel(cfg.OriginalsRoot, dest) + return relDest, nil + } + + moved := []dupMoved{} + errs := []dupArchiveErr{} + for _, rel := range body.Paths { + relDest, err := archiveOne(rel) + if err != nil { + errs = append(errs, dupArchiveErr{Path: rel, Error: err.Error()}) + continue + } + moved = append(moved, dupMoved{From: rel, To: relDest}) + slog.Info("dup.archive", "from", rel, "to", relDest) + } + + // Reindex the entire library so PhotoPrism drops rows for the + // archived files. cleanup:true is critical — the files still + // exist on disk, just under .duplicates/ which the indexer + // ignores. + if len(moved) > 0 { + go func() { + if err := pp.reindex(context.Background(), token, "/"); err != nil { + slog.Warn("dup.archive reindex failed", "err", err) + } + }() + } + c.JSON(http.StatusOK, gin.H{"moved": moved, "errors": errs}) + } +} diff --git a/sidecar/handlers_folders.go b/sidecar/handlers_folders.go new file mode 100644 index 0000000..9a35600 --- /dev/null +++ b/sidecar/handlers_folders.go @@ -0,0 +1,178 @@ +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) + } +} diff --git a/sidecar/handlers_heap.go b/sidecar/handlers_heap.go new file mode 100644 index 0000000..1547f3c --- /dev/null +++ b/sidecar/handlers_heap.go @@ -0,0 +1,235 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/url" + "os" + "path/filepath" + + "github.com/gin-gonic/gin" +) + +type heapConvertBody struct { + TargetFolder string `json:"targetFolder"` + Mode string `json:"mode"` // "move" or "copy" + Subfolder string `json:"subfolder"` // optional, sanitized to a single segment + DeleteHeap bool `json:"deleteHeap"` +} + +type heapPhoto struct { + UID string `json:"UID"` + Files []ppFile `json:"Files"` +} + +type heapErr struct { + UID string `json:"uid"` + Reason string `json:"reason"` +} + +// copyFile is the os.Rename fallback for cross-device moves and the +// primary path for "copy" mode. Streams so a 4GB video doesn't pin +// memory; preserves mode bits, sets the modification time to now (we're +// creating a new inode either way). +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + st, err := in.Stat() + if err != nil { + return err + } + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, st.Mode()) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + os.Remove(dst) + return err + } + return out.Close() +} + +func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc { + return func(c *gin.Context) { + token := ctxToken(c) + albumUID := c.Param("uid") + + var body heapConvertBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"}) + return + } + mode := body.Mode + if mode != "copy" { + mode = "move" + } + deleteHeap := mode == "move" && body.DeleteHeap + + var subfolder string + if body.Subfolder != "" { + s, ok := sanitizeFilename(body.Subfolder) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subfolder name"}) + return + } + subfolder = s + } + + // Resolve destination. resolveUnderRoot ensures the target lives + // inside ORIGINALS_ROOT and that its parent is a real directory. + targetAbs, err := resolveUnderRoot(cfg.OriginalsRoot, body.TargetFolder, true) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"}) + return + } + destAbs := targetAbs + if subfolder != "" { + destAbs = filepath.Join(targetAbs, subfolder) + if err := os.MkdirAll(destAbs, 0o755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + + // Pull the heap's photos via the q=album:UID query. count=1000 covers + // every realistic heap; merged=true expands stacked variants so we + // move the JPG/HEIC sibling alongside the primary. + q := url.QueryEscape("album:" + albumUID) + listURL := "/api/v1/photos?q=" + q + "&count=1000&merged=true" + resp, err := pp.call(c.Request.Context(), http.MethodGet, listURL, token, nil) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + if !resp.OK { + c.JSON(resp.Status, gin.H{"error": "list photos failed"}) + return + } + var photos []heapPhoto + if err := json.Unmarshal(resp.Body, &photos); err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "decode photo list"}) + return + } + + sourceParents := map[string]struct{}{} + errs := []heapErr{} + moved, copied := 0, 0 + + for _, photo := range photos { + var file ppFile + found := false + for _, f := range photo.Files { + if f.Primary { + file, found = f, true + break + } + } + if !found { + if len(photo.Files) == 0 { + errs = append(errs, heapErr{UID: photo.UID, Reason: "no primary file"}) + continue + } + file = photo.Files[0] + } + srcRel := file.Name + srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel) + if !sameOrUnder(srcAbs, cfg.OriginalsRoot) { + errs = append(errs, heapErr{UID: photo.UID, Reason: "path escapes originals"}) + continue + } + st, err := os.Stat(srcAbs) + if err != nil || !st.Mode().IsRegular() { + errs = append(errs, heapErr{UID: photo.UID, Reason: "file missing on disk"}) + continue + } + if filepath.Dir(srcAbs) == destAbs { + errs = append(errs, heapErr{UID: photo.UID, Reason: "already in target"}) + continue + } + _, name, ok := uniqueName(destAbs, filepath.Base(srcAbs)) + if !ok { + errs = append(errs, heapErr{UID: photo.UID, Reason: "too many collisions"}) + continue + } + dstAbs := filepath.Join(destAbs, name) + if mode == "move" { + if err := os.Rename(srcAbs, dstAbs); err != nil { + // Cross-device renames fail with EXDEV — fall back to + // copy+remove so a library that spans filesystems still + // works. + if err2 := copyFile(srcAbs, dstAbs); err2 != nil { + errs = append(errs, heapErr{UID: photo.UID, Reason: err.Error()}) + continue + } + if err2 := os.Remove(srcAbs); err2 != nil { + errs = append(errs, heapErr{UID: photo.UID, Reason: "rename ok, source remove failed: " + err2.Error()}) + continue + } + } + moved++ + } else { + if err := copyFile(srcAbs, dstAbs); err != nil { + errs = append(errs, heapErr{UID: photo.UID, Reason: err.Error()}) + continue + } + copied++ + } + sourceParents[filepath.Dir(srcRel)] = struct{}{} + } + + // Reindex the destination + every source parent so PhotoPrism's + // DB catches up. We do this in the background — the user gets + // their counts immediately; PhotoPrism's timeline updates as the + // reindex lands. + destRel, _ := filepath.Rel(cfg.OriginalsRoot, destAbs) + paths := map[string]struct{}{destRel: {}} + for p := range sourceParents { + paths[p] = struct{}{} + } + if subfolder != "" { + parent, _ := filepath.Rel(cfg.OriginalsRoot, targetAbs) + paths[parent] = struct{}{} + } + for p := range paths { + reindex := "/" + if p != "" && p != "." { + reindex = "/" + p + } + go fireReindex(cfg, pp, token, reindex) + } + + heapDeleted := false + if deleteHeap { + r, err := pp.call(context.Background(), http.MethodDelete, "/api/v1/albums/"+albumUID, token, nil) + if err == nil && r.OK { + heapDeleted = true + } else if err != nil { + errs = append(errs, heapErr{UID: albumUID, Reason: "album delete: " + err.Error()}) + } else { + errs = append(errs, heapErr{UID: albumUID, Reason: "album delete: HTTP " + itoa(r.Status)}) + } + } + + slog.Info("heap.convert", + "album", albumUID, + "mode", mode, + "moved", moved, + "copied", copied, + "errors", len(errs), + "heap_deleted", heapDeleted, + ) + c.JSON(http.StatusOK, gin.H{ + "moved": moved, + "copied": copied, + "errors": errs, + "heap_deleted": heapDeleted, + }) + } +} diff --git a/sidecar/handlers_marks.go b/sidecar/handlers_marks.go new file mode 100644 index 0000000..0ba002f --- /dev/null +++ b/sidecar/handlers_marks.go @@ -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 +// `{"": {"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, + }) + } +} diff --git a/sidecar/handlers_rename.go b/sidecar/handlers_rename.go new file mode 100644 index 0000000..f447491 --- /dev/null +++ b/sidecar/handlers_rename.go @@ -0,0 +1,143 @@ +package main + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/gin-gonic/gin" +) + +// renameBody mirrors the Node prototype's wire contract — a single +// `newName` field carrying the bare basename (no slashes). +type renameBody struct { + NewName string `json:"newName"` +} + +// ppPhoto is the partial PhotoPrism photo shape we need to find the +// primary file's on-disk location. Anything we don't read stays +// unspecified so version drift across PhotoPrism builds doesn't break +// JSON unmarshalling. +type ppPhoto struct { + Files []ppFile `json:"Files"` +} + +type ppFile struct { + Name string `json:"Name"` + Root string `json:"Root"` + Primary bool `json:"Primary"` +} + +func primaryFileOf(p *ppPhoto) (ppFile, bool) { + if p == nil { + return ppFile{}, false + } + for _, f := range p.Files { + if f.Primary { + return f, true + } + } + if len(p.Files) > 0 { + return p.Files[0], true + } + return ppFile{}, false +} + +func handleRename(cfg *Config, pp *ppClient) gin.HandlerFunc { + return func(c *gin.Context) { + token := ctxToken(c) + photoUID := c.Param("uid") + + var body renameBody + 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 filename"}) + return + } + + // Fetch the photo so we can resolve Files[0].Root + Name into a + // concrete on-disk path. PhotoPrism has no "file by UID" endpoint + // in this build, so the single-photo lookup is the cheapest path. + resp, err := pp.call(c.Request.Context(), http.MethodGet, "/api/v1/photos/"+photoUID, token, nil) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + if !resp.OK { + c.JSON(resp.Status, gin.H{"error": "photo not found"}) + return + } + var photo ppPhoto + if err := json.Unmarshal(resp.Body, &photo); err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "decode photo"}) + return + } + file, ok := primaryFileOf(&photo) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "no files on this photo"}) + return + } + + root := strings.TrimPrefix(file.Root, "/") + if root == "" || root == "/" { + root = "" + } + relPath := filepath.Join(root, file.Name) + oldAbs := filepath.Join(cfg.OriginalsRoot, relPath) + + if !ensureWithinOriginals(cfg.OriginalsRoot, oldAbs) { + c.JSON(http.StatusBadRequest, gin.H{"error": "path escapes originals root"}) + return + } + st, err := os.Stat(oldAbs) + if err != nil || !st.Mode().IsRegular() { + c.JSON(http.StatusNotFound, gin.H{"error": "file missing on disk"}) + return + } + newAbs := filepath.Join(filepath.Dir(oldAbs), newName) + if !ensureWithinOriginals(cfg.OriginalsRoot, newAbs) { + c.JSON(http.StatusBadRequest, gin.H{"error": "new path escapes originals root"}) + return + } + if _, err := os.Stat(newAbs); err == nil { + c.JSON(http.StatusConflict, gin.H{"error": "target filename already exists"}) + return + } else if !errors.Is(err, os.ErrNotExist) { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + newRel := filepath.Join(root, newName) + slog.Info("rename", "from", relPath, "to", newRel) + if err := os.Rename(oldAbs, newAbs); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Trigger reindex on the parent so PhotoPrism picks up the new + // filename and drops the orphan row for the old name. Best-effort. + reindexPath := "/" + if root != "" { + reindexPath = "/" + root + } + if err := pp.reindex(c.Request.Context(), token, reindexPath); err != nil { + slog.Warn("rename reindex failed", "err", err) + } + + c.JSON(http.StatusOK, gin.H{ + "ok": true, + "oldName": file.Name, + "newName": newName, + "oldRelPath": relPath, + "newRelPath": newRel, + }) + } +} diff --git a/sidecar/server.mjs b/sidecar/legacy/server.mjs similarity index 100% rename from sidecar/server.mjs rename to sidecar/legacy/server.mjs diff --git a/sidecar/main.go b/sidecar/main.go new file mode 100644 index 0000000..0b04d2c --- /dev/null +++ b/sidecar/main.go @@ -0,0 +1,113 @@ +// 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 := "127.0.0.1:" + 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 +} + diff --git a/sidecar/pp.go b/sidecar/pp.go new file mode 100644 index 0000000..e8a3298 --- /dev/null +++ b/sidecar/pp.go @@ -0,0 +1,115 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "time" +) + +// ppClient is the thin HTTP wrapper around PhotoPrism's /api/v1. It is +// deliberately *not* shared across requests with caching: each handler +// forwards the caller's X-Auth-Token, so a single shared http.Client (we +// reuse the stdlib default) plus per-call header injection is all we need. +type ppClient struct { + base string + h *http.Client +} + +func newPPClient(base string) *ppClient { + return &ppClient{ + base: base, + h: &http.Client{Timeout: 60 * time.Second}, + } +} + +// ppResp is the trimmed projection of an HTTP response that callers +// actually consume. Status + raw body are exposed so handlers can mirror +// PhotoPrism's status code or parse the body themselves. +type ppResp struct { + OK bool + Status int + Body []byte +} + +// call issues an authenticated request against PhotoPrism. body is +// optional; pass nil for GET/DELETE. We don't JSON-decode here — callers +// know the shape they want and decode lazily. +func (c *ppClient) call(ctx context.Context, method, urlPath, token string, body any) (*ppResp, error) { + u, err := url.Parse(c.base) + if err != nil { + return nil, err + } + rel, err := url.Parse(urlPath) + if err != nil { + return nil, err + } + full := u.ResolveReference(rel).String() + + var reader io.Reader + if body != nil { + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + reader = bytes.NewReader(buf) + } + + req, err := http.NewRequestWithContext(ctx, method, full, reader) + if err != nil { + return nil, err + } + req.Header.Set("X-Auth-Token", token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.h.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + buf, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return &ppResp{ + OK: resp.StatusCode >= 200 && resp.StatusCode < 300, + Status: resp.StatusCode, + Body: buf, + }, nil +} + +// validateSession is the cheapest probe that the supplied token is live: +// list one photo. 401 → bad/expired token. We never read the payload. +func (c *ppClient) validateSession(ctx context.Context, token string) bool { + if token == "" { + return false + } + r, err := c.call(ctx, http.MethodGet, "/api/v1/photos?count=1", token, nil) + if err != nil { + return false + } + return r.OK +} + +// reindex tells PhotoPrism to re-walk a single subpath of originals and +// reconcile its DB with the on-disk state. Callers fire this after any +// rename/create/delete so the timeline catches up. `cleanup: true` drops +// orphan rows (e.g. the row for the file's old name after a rename). +// +// Best-effort: errors are surfaced to the caller, who logs but does not +// abort — the file mutation has already happened on disk by the time +// reindex runs. +func (c *ppClient) reindex(ctx context.Context, token, parentRel string) error { + if parentRel == "" { + parentRel = "/" + } + _, err := c.call(ctx, http.MethodPost, "/api/v1/index", token, map[string]any{ + "path": parentRel, + "rescan": false, + "cleanup": true, + }) + return err +}