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

3
.gitignore vendored
View File

@@ -72,6 +72,9 @@ docker-compose.override.yml
# Sidecar runtime state (per-user marks etc.) — generated, not seed data. # Sidecar runtime state (per-user marks etc.) — generated, not seed data.
/sidecar/data/ /sidecar/data/
# Sidecar Go build output.
/sidecar/mule-sidecar
# Photos (for development) # Photos (for development)
/photos/ /photos/

View File

@@ -33,6 +33,12 @@ services:
MARIADB_USER: ${PP_DB_USER:-photoprism} MARIADB_USER: ${PP_DB_USER:-photoprism}
MARIADB_PASSWORD: ${PP_DB_PASSWORD:?set PP_DB_PASSWORD in .env.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} 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: volumes:
- pp_mariadb_data:/var/lib/mysql - pp_mariadb_data:/var/lib/mysql
# The init script creates the mule_sidecar database + user that the Go # The init script creates the mule_sidecar database + user that the Go

View File

@@ -1,43 +1,96 @@
# mule-sidecar # 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`, | Method | Path | Purpose |
a Go + Gin + GORM service (matching PhotoPrism's stack) will eventually own: | ------ | --------------------------------------- | --------------------------------------------- |
| 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/<ts>/`. |
- Per-user heap sharing with pending invitations Auth: every endpoint except `healthz` requires the caller's
- Folder mutations under `originals/` (create / rename / delete / move) `X-Auth-Token` header. The sidecar holds no service credentials — it
- **File rename** on disk (PhotoPrism's `OriginalName` is a display-only rename) 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 ORIGINALS_ROOT=/path/to/photoprism/originals \
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 \
PHOTOPRISM_BASE_URL=http://localhost:2342 \ PHOTOPRISM_BASE_URL=http://localhost:2342 \
SIDECAR_PORT=8000 \ 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.
```

44
sidecar/auth.go Normal file
View File

@@ -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
}

60
sidecar/config.go Normal file
View File

@@ -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
}

63
sidecar/db.go Normal file
View File

@@ -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
}

213
sidecar/fs.go Normal file
View File

@@ -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
}

45
sidecar/go.mod Normal file
View File

@@ -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
)

101
sidecar/go.sum Normal file
View File

@@ -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=

230
sidecar/handlers_dups.go Normal file
View File

@@ -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})
}
}

178
sidecar/handlers_folders.go Normal file
View File

@@ -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)
}
}

235
sidecar/handlers_heap.go Normal file
View File

@@ -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,
})
}
}

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,
})
}
}

143
sidecar/handlers_rename.go Normal file
View File

@@ -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,
})
}
}

113
sidecar/main.go Normal file
View File

@@ -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
}

115
sidecar/pp.go Normal file
View File

@@ -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
}