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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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/
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
44
sidecar/auth.go
Normal 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
60
sidecar/config.go
Normal 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
63
sidecar/db.go
Normal 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
213
sidecar/fs.go
Normal 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
45
sidecar/go.mod
Normal 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
101
sidecar/go.sum
Normal 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
230
sidecar/handlers_dups.go
Normal 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
178
sidecar/handlers_folders.go
Normal 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
235
sidecar/handlers_heap.go
Normal 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
206
sidecar/handlers_marks.go
Normal 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
143
sidecar/handlers_rename.go
Normal 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
113
sidecar/main.go
Normal 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
115
sidecar/pp.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user