PhotoPrism's OSS edition has no way to map OIDC claims to BasePath, so
every freshly-registered OIDC user lands with BasePath="" and either
sees the whole library (admin) or nothing (guest) — never their own
subfolder.
Introduces a sidecar-driven reconciler with a single env knob the
admin sets in docker-compose / .env.photoprism:
USER_BASEPATHS="test:test, alice:family/alice, bob:bob"
(`user:originals-relative-path` pairs, comma-separated.) On boot and
every 60s thereafter the sidecar:
- mkdir -p's the target subdirectory under ORIGINALS_ROOT so
PhotoPrism's path: ACL filter has somewhere real to point;
- UPDATEs photoprism.auth_users.base_path for the matching row
where it differs (idempotent, missing users skipped — they
materialise on first OIDC login and the next pass catches them).
The reconciler uses a separate gorm connection scoped to the
`photoprism` schema with PhotoPrism's own DB user, since the existing
`sidecar` user only has grants on `mule_sidecar.*`. Connection stays
dormant when PP_DB_PASSWORD is empty — the feature is opt-in via env.
Compose changes: thread PP_DB_* + USER_BASEPATHS through to the
sidecar service. New users.go file isolates the reconciler logic;
main.go calls startUserBasepathReconciler() during boot.
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/ 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:
podman-compose --env-file .env.photoprism \
-f docker-compose.photoprism.yml \
-f docker-compose.photoprism.podman.yml \
up -d
This builds 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:
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 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:
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
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