feat(immich): read Immich through a per-user read-only proxy

The browser cannot talk to Immich directly: the key must stay out of it,
COEP blocks the origin, and the app has no place to keep a key per user.
So the backend keeps it. `src/immich.ts` holds the whole surface — the
user's servers live in a JSON column on `users` (additive migration), and
every route reads the key from there and never takes a URL from the
browser except when probing one.

Albums, a page of assets, a thumbnail and an original, all behind the
normal session check. `probe` is the only route that touches a URL the
client named, and it validates it first (http/https only, no credentials,
no path, no query, no hash) so the browser cannot turn the backend into a
proxy to an arbitrary host. The key is masked down to its last four
characters everywhere it comes back out, and no log line carries it.

The share-link path is the same routes with `type: 'share'`, whose key
travels as `?key=`, so there is one code path per call rather than two.

test/immich.mjs runs a fake Immich on loopback — two keys with different
albums, one of them without `asset.download` — and checks 59 things
including that neither the responses nor the log leak a key.
This commit is contained in:
2026-10-10 15:52:42 +07:00
parent 7b52e3f3e3
commit d2e2ebe108
7 changed files with 921 additions and 1 deletions
+5
View File
@@ -23,6 +23,11 @@ services:
SMTP_PASS: ${SMTP_PASS:-}
SMTP_FROM: ${SMTP_FROM:-}
SMTP_SECURE: ${SMTP_SECURE:-}
# Immich, offered as a second photo source in LIBRARY. This is only the
# address the "add an Immich server" dialog starts with: the URL and the
# key belong to each account, are typed in the app, and are stored in the
# users table. Empty simply means the field starts blank.
IMMICH_URL: ${IMMICH_URL:-}
volumes:
# SQLite (WAL) lives on the host so a rebuild never loses accounts.
- ./data:/data