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>
337 lines
13 KiB
TypeScript
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 };
|
|
}
|