Files
immich-folio/lib/install.ts
Ralksta ad8b09700b release: v0.19.0
Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of
[CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md)
for the full list.

Highlights: client proofing links (#683), custom content pages (#738),
asset picker search (#740), concurrent-edit detection (#742), env-locked
settings (#739), an accessibility baseline (#741), three security fixes
with advisories (#737, #755, #759), and a pre-release quality pass
(#780–#797).

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
2026-09-29 22:50:30 +02:00

337 lines
13 KiB
TypeScript

/**
* Install wizard support.
*
* The wizard is the first-run path: /install walks a fresh deployment through
* connecting to Immich, picking albums (optional), and naming the site, then
* writes content/gallery.yaml, content/settings.yaml and a small
* content/install.json carrying the Immich credentials, the generated site
* secret and an optional admin password.
*
* content/install.json exists because a running process cannot pick up new
* process.env values — the wizard has to write configuration that takes effect
* without a restart. getInstallCredentials() merges it *under* the real
* environment, so env vars (Docker, .env.local) always win and rotating a
* credential is a matter of setting the corresponding env var.
*
* Once gallery.yaml exists and the credentials resolve, isInstalled() is true
* and /install — the page and both API routes — refuses to run again.
*/
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import yaml from 'js-yaml';
import { atomicWrite } from './atomicWrite';
import { env, normalizeApiUrl } from './env';
import { generateScryptHash } from './password';
import type { GalleryYaml, SettingsYaml } from './config/schema';
import { DEFAULT_PRESET } from './config/theme';
/** The directory user content is written into. Overridable for tests. */
function installContentDir(): string {
return env.INSTALL_CONTENT_DIR || path.join(process.cwd(), 'content');
}
const INSTALL_FILENAME = 'install.json';
// ── One-time setup token —─────────────────────────────────────
const SETUP_TOKEN_FILENAME = '.setup-token';
/**
* A random token that gates the install page and the install API routes. It is
* printed to the server log on first access; the operator reads it from the
* container / process logs and supplies it as a query parameter or an
* `x-setup-token` header.
*
* Without this token the install endpoints are reachable by anyone who knows
* the URL — which is the whole internet if the app is deployed before
* `gallery.yaml` exists.
*
* It lives in a file rather than a module variable because Next.js bundles the
* page and each route handler separately: a module-level token gives
* /install one value and /api/install another, inside the same process. The
* page would render the wizard and the API would then reject the very token it
* had just printed, leaving the wizard stuck on step 1 with no way forward.
* The file is the only state both bundles agree on — and it survives a restart,
* so the operator does not have to hunt through the logs again.
*/
function generateSetupToken(): string {
const dir = installContentDir();
const tokenPath = path.join(dir, SETUP_TOKEN_FILENAME);
try {
const existing = fs.readFileSync(tokenPath, 'utf8').trim();
if (existing) return existing;
} catch {
// Not created yet — fall through and mint one.
}
const token = crypto.randomBytes(24).toString('base64url');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(tokenPath, `${token}\n`, { mode: 0o600 });
return token;
}
let _tokenLogged = false;
export function getSetupToken(): string {
const token = generateSetupToken();
if (!_tokenLogged) {
_tokenLogged = true;
console.log('\n══════════════════════════════════════════════════════');
console.log(' Immich Folio — First-run setup token');
console.log('══════════════════════════════════════════════════════');
console.log(` Token: ${token}`);
console.log('');
console.log(' Append ?token= to the /install URL (or include it as');
console.log(' an X-Setup-Token header on API requests) to access');
console.log(' the install wizard.');
console.log('══════════════════════════════════════════════════════\n');
}
return token;
}
export function validateSetupToken(token: string | null): boolean {
if (!token) return false;
const expected = generateSetupToken();
try {
const a = Buffer.from(token, 'utf8');
const b = Buffer.from(expected, 'utf8');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
} catch {
return false;
}
}
// ── Credential management —────────────────────────────────━━━━
/** Credentials the running app should use, env vars taking precedence. */
export interface InstallCredentials {
apiUrl: string;
apiKey: string;
authSecret: string;
adminPassword: string;
}
/** Raw contents of content/install.json. */
interface InstallFileData {
apiUrl?: string;
apiKey?: string;
authSecret?: string;
adminPassword?: string;
}
let cachedInstallFile: { path: string; mtimeMs: number; data: InstallFileData } | null = null;
/** Where install.json lives; getConfig() watches its mtime. */
export function installFilePath(): string {
return path.join(installContentDir(), INSTALL_FILENAME);
}
function readInstallFile(): InstallFileData {
const installPath = installFilePath();
let mtimeMs = 0;
try {
mtimeMs = fs.statSync(installPath).mtimeMs;
} catch {
// File does not exist — no wizard-installed credentials.
cachedInstallFile = null;
return {};
}
if (
cachedInstallFile &&
cachedInstallFile.path === installPath &&
cachedInstallFile.mtimeMs === mtimeMs
) {
return cachedInstallFile.data;
}
let data: InstallFileData = {};
try {
data = JSON.parse(fs.readFileSync(installPath, 'utf8')) as InstallFileData;
} catch (err) {
console.warn('[Install] content/install.json is not valid JSON — ignoring it.', err);
}
cachedInstallFile = { path: installPath, mtimeMs, data };
return data;
}
/**
* Merge wizard-installed credentials with the real environment.
*
* Env wins: someone who sets IMMICH_API_URL/IMMICH_API_KEY/AUTH_SECRET/
* ADMIN_PASSWORD after installing keeps the env value, which is how rotation
* works without touching the wizard files.
*/
export function getInstallCredentials(): InstallCredentials {
const file = readInstallFile();
const apiUrl = env.IMMICH_API_URL || file.apiUrl || '';
return {
// Normalised on every read, not only when completeInstall writes it, so
// an install.json written before this fix heals without re-running the
// wizard. env.ts already normalises IMMICH_API_URL, so this is a no-op
// for that source and only does real work for file.apiUrl (#634).
apiUrl: apiUrl ? normalizeApiUrl(apiUrl) || apiUrl : '',
apiKey: env.IMMICH_API_KEY || file.apiKey || '',
authSecret: env.AUTH_SECRET || file.authSecret || '',
adminPassword: env.ADMIN_PASSWORD || file.adminPassword || '',
};
}
/**
* Whether a first-run install is complete.
*
* Mirrors the `needsSetup` definition in getConfig(): a gallery.yaml exists AND
* Immich credentials resolve. The wizard writes all three at once, so this flips
* to true the moment the install finishes — no restart required.
*/
export function isInstalled(): boolean {
const creds = getInstallCredentials();
if (!creds.apiUrl || !creds.apiKey) return false;
return fs.existsSync(path.join(installContentDir(), 'gallery.yaml'));
}
/** Whether a request pathname belongs to the install wizard. */
export function isInstallPath(pathname: string | null): boolean {
return pathname === '/install' || pathname?.startsWith('/install/') === true;
}
/**
* Normalize a user-supplied Immich base URL into the API base the client calls.
*
* The app stores the base URL (no /api) — see lib/env.ts — and appends /api at
* call time. Users sometimes paste a URL that already ends in /api, so accept
* both rather than double-appending. Returns null when the input is not a
* valid http(s) URL.
*/
export function normalizeApiBase(raw: string): string | null {
let url: URL;
try {
url = new URL(raw);
} catch {
return null;
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
const base = url.toString().replace(/\/+$/, '');
return base.endsWith('/api') ? base : `${base}/api`;
}
/** What the wizard collects on its final step. */
export interface InstallInput {
apiUrl: string;
apiKey: string;
siteTitle?: string;
siteSubtitle?: string;
theme?: string;
albums?: string[];
/** Asset IDs for the home page carousel, usually the covers of `albums`. */
hero?: string[];
adminPassword?: string;
}
/** Which of the two config files completeInstall actually wrote. */
export interface CompleteInstallResult {
galleryWritten: boolean;
settingsWritten: boolean;
}
/**
* Persist everything the install wizard collected.
*
* Writes gallery.yaml (albums optional — an empty gallery is a valid portfolio
* the owner can fill in later via /admin), settings.yaml, and install.json.
* Runs before caches are invalidated, so the caller owns that step.
*/
export async function completeInstall(input: InstallInput): Promise<CompleteInstallResult> {
const dir = installContentDir();
await fs.promises.mkdir(dir, { recursive: true });
const gallery: GalleryYaml = {
// A photography portfolio whose front page carries no photograph reads as
// unfinished, and the wizard already knows which albums were picked (#518).
// The caller seeds these from their Immich covers; an empty list is still
// valid, and the admin panel can change them afterwards.
hero: (input.hero ?? []).filter((id) => typeof id === 'string'),
albums: (input.albums ?? []).filter((id) => typeof id === 'string'),
};
const settings: SettingsYaml = {
title: input.siteTitle?.trim() || 'Gallery',
subtitle: input.siteSubtitle?.trim() || '',
theme: { preset: input.theme || DEFAULT_PRESET },
};
// Stored normalised, not raw: the route verifies the connection against
// normalizeApiBase(apiUrl), which strips a trailing slash, but this used
// to write input.apiUrl.trim() unchanged, so "https://host/api/" passed
// the ping and then every request after install went to
// ".../api//api/albums" and 404ed (#634).
const normalizedApiUrl = normalizeApiUrl(input.apiUrl.trim()) || input.apiUrl.trim();
const data: InstallFileData = {
apiUrl: normalizedApiUrl,
apiKey: input.apiKey.trim(),
authSecret: crypto.randomBytes(32).toString('hex'),
// Hashed, not stored as typed: the API key and the site secret have to be
// recoverable to be useful, but an admin password does not — and it is the
// one credential here a person picked and may well have reused elsewhere.
...(input.adminPassword
? { adminPassword: await generateScryptHash(input.adminPassword) }
: {}),
};
const header = (name: string) =>
`# ── ${name} (created by the Immich Folio install wizard) ──────────\n`;
const dumpOptions = { lineWidth: 120, quotingType: '"' as const, noRefs: true, sortKeys: false };
const galleryPath = path.join(dir, 'gallery.yaml');
const settingsPath = path.join(dir, 'settings.yaml');
// Only create gallery.yaml if it does not already exist yet, same as
// settings.yaml below. isInstalled() is false whenever Immich credentials
// fail to resolve — a bad IMMICH_API_URL, a corrupt install.json — whether
// or not gallery.yaml exists, so re-running the wizard after that is not
// evidence gallery.yaml needs replacing. Every subpage, section, password,
// assetOrder and per-album override a site has accumulated used to be
// overwritten unconditionally with this two-key skeleton (#627).
const galleryWritten = !fs.existsSync(galleryPath);
if (galleryWritten) {
await atomicWrite(galleryPath, header('Gallery Structure') + yaml.dump(gallery, dumpOptions));
}
// Only create settings.yaml if it does not already exist — a deployment that
// brings its own customised settings.yaml (but is missing gallery.yaml) must
// not have it silently replaced with the three-key skeleton the wizard writes.
const settingsWritten = !fs.existsSync(settingsPath);
if (settingsWritten) {
await atomicWrite(settingsPath, header('Site Settings') + yaml.dump(settings, dumpOptions));
}
// Written 0600 from the start, like the setup token below — not created
// world-readable and tightened afterwards, which briefly left the API key
// and auth secret readable by anyone on the box (GHSA-w293-x8pc-j4cv).
// `mode` is a no-op on Windows, same as the chmod it replaces was.
await atomicWrite(path.join(dir, INSTALL_FILENAME), `${JSON.stringify(data, null, 2)}\n`, {
mode: 0o600,
});
// The token exists to gate a setup that has not happened yet. Leaving it on
// disk would keep a valid credential lying around for a door that is now
// closed anyway (isInstalled() makes every install route answer 403).
try {
fs.unlinkSync(path.join(dir, SETUP_TOKEN_FILENAME));
} catch {
// Never created, or already removed.
}
// Drop the in-process cache so the next isInstalled()/getInstallCredentials()
// call reads the freshly written file.
cachedInstallFile = null;
return { galleryWritten, settingsWritten };
}