# mule-sidecar

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.

## What it owns

| Method | Path                                    | Purpose                                       |
| ------ | --------------------------------------- | --------------------------------------------- |
| GET    | `/api/sidecar/healthz`                  | Unauthenticated liveness probe.               |
| GET    | `/api/sidecar/photos/marks`             | Every per-photo `{rating, color}` mark.       |
| GET    | `/api/sidecar/photos/:uid/marks`        | One photo's mark (or `{}` if none).           |
| PUT    | `/api/sidecar/photos/:uid/marks`        | Patch one photo's mark.                       |
| POST   | `/api/sidecar/photos/marks/bulk`        | Stamp the same mark onto many photos.         |
| POST   | `/api/sidecar/files/:uid/rename`        | Rename the primary file on disk + reindex.    |
| POST   | `/api/sidecar/folders`                  | Create a folder under `${ORIGINALS_ROOT}`.    |
| POST   | `/api/sidecar/folders/:rel/rename`      | Rename a folder (rel path URL-encoded).       |
| DELETE | `/api/sidecar/folders/:rel`             | Delete an **empty** folder.                   |
| POST   | `/api/sidecar/albums/:uid/convert`      | Move/copy every photo in a heap into folder X. |
| GET    | `/api/sidecar/duplicates/scan`          | Walk originals, return same-hash groups.      |
| POST   | `/api/sidecar/duplicates/archive`       | Move duplicate paths into `.duplicates/<ts>/`. |

Auth: every endpoint except `healthz` requires the caller's
`X-Auth-Token` header. The sidecar holds no service credentials — it
proxies the token straight back to PhotoPrism's `/api/v1/photos?count=1`
to confirm the session is live before doing anything destructive.

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.

## Run

The sidecar is a `sidecar` service in the PhotoPrism compose stack.
Bringing the whole stack up brings it up too:

```sh
podman-compose --env-file .env.photoprism \
  -f docker-compose.photoprism.yml \
  -f docker-compose.photoprism.podman.yml \
  up -d
```

This builds [Dockerfile](Dockerfile) (multi-stage `golang:1.25-alpine` →
`gcr.io/distroless/static`, ~12 MB final image), starts the container,
and binds `127.0.0.1:8000` to the service. The SvelteKit dev server
proxies `/api/sidecar/*` to that port transparently.

### Dev-iteration loop (host build)

For tight iteration without rebuilding the image on every change you
can run it as a host process — Go is already on the dev machine:

```sh
cd sidecar
go build -o mule-sidecar .

ORIGINALS_ROOT=/path/to/photoprism/originals \
PHOTOPRISM_BASE_URL=http://localhost:2342 \
SIDECAR_PORT=8000 \
./mule-sidecar
```

The host build connects to `mariadb` via the loopback port the compose
file publishes; stop `pp-sidecar` first so they don't fight for 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/
├── Dockerfile            multi-stage golang:1.25 → distroless/static
├── 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
```
