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:
@@ -183,6 +183,18 @@ export const serializeSlots = (slots: readonly PhotoSlot[]): string =>
|
||||
}
|
||||
}
|
||||
|
||||
// The Immich servers an account added to its LIBRARY — one JSON array per user,
|
||||
// each entry holding the address, the key its owner pasted and the albums they
|
||||
// ticked (see src/immich.ts). A column rather than a table because the list is
|
||||
// only ever read whole and written whole: there is nothing to query across
|
||||
// accounts, and a user's row is already the unit that is deleted together.
|
||||
{
|
||||
const cols = db.prepare('PRAGMA table_info(users)').all() as { name: string }[];
|
||||
if (!cols.some((c) => c.name === 'immich')) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN immich TEXT`);
|
||||
}
|
||||
}
|
||||
|
||||
// The mailed code, added once visitors were expected to prove an address
|
||||
// without leaving the page. Rows minted before it keep working as links: their
|
||||
// `code` is NULL, which no typed guess can match (see verifyEmailCode).
|
||||
@@ -624,6 +636,44 @@ export function listUsersWithCounts(): AdminUser[] {
|
||||
.all() as AdminUser[];
|
||||
}
|
||||
|
||||
// ---- Immich servers, per account ---------------------------------------------
|
||||
// What src/immich.ts stores for one server: the address, the key its owner
|
||||
// pasted, and the crossing choices the dialog made. `version` and `canDownload`
|
||||
// are the answers of the last probe, kept so the LIBRARY tree can draw without
|
||||
// asking Immich anything.
|
||||
export type ImmichServer = {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
key: string;
|
||||
type: 'api' | 'share';
|
||||
version: string | null;
|
||||
canDownload: boolean | null;
|
||||
albums: string[];
|
||||
};
|
||||
|
||||
// The key is in clear in this column, like the password hash beside it: the file
|
||||
// is the deployment's own disk. What matters is that it never travels back out —
|
||||
// the routes send a masked copy and nothing else (see src/immich.ts).
|
||||
export function immichServers(userId: number): ImmichServer[] {
|
||||
const row = db.prepare('SELECT immich FROM users WHERE id = ?').get(userId) as
|
||||
| { immich: string | null }
|
||||
| undefined;
|
||||
if (!row?.immich) return [];
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(row.immich);
|
||||
return Array.isArray(parsed) ? (parsed as ImmichServer[]) : [];
|
||||
} catch {
|
||||
// A column hand-edited into nonsense reads as "no server added", which is
|
||||
// the same thing the user sees and can fix from the dialog.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function setImmichServers(userId: number, servers: ImmichServer[]): void {
|
||||
db.prepare('UPDATE users SET immich = ? WHERE id = ?').run(JSON.stringify(servers), userId);
|
||||
}
|
||||
|
||||
// ---- moderation -------------------------------------------------------------
|
||||
// Blocking and removing both drop the account's live sessions: the state has to
|
||||
// take effect on the next request, not whenever the cookie happens to expire.
|
||||
|
||||
Reference in New Issue
Block a user