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>
This commit is contained in:
Ralksta
2026-09-29 22:50:30 +02:00
committed by GitHub
parent 5f251923f0
commit ad8b09700b
297 changed files with 19398 additions and 1800 deletions

View File

@@ -0,0 +1,42 @@
/**
* Selection state of the asset picker's multi-select mode (#602), as plain
* functions so it can be tested without rendering the picker.
*
* The selection is an ordered list: the order you click in is the order the
* photos land in (hero list, photo grid), so a Set would lose information.
*/
export interface SelectionRules {
/** Assets the caller already uses; they cannot be picked again. */
disabled?: ReadonlySet<string>;
/** Most assets the caller can take; undefined means no limit. */
max?: number;
}
/**
* The selection after toggling `id`: removed when present, appended when not,
* unchanged when it is disabled or the limit is reached. With `max: 1` a new
* pick replaces the old one, which is how a single slot should feel.
*/
export function toggleSelection(
selected: readonly string[],
id: string,
rules: SelectionRules = {},
): string[] {
if (selected.includes(id)) return selected.filter((s) => s !== id);
if (rules.disabled?.has(id)) return [...selected];
if (rules.max === 1) return [id];
if (rules.max !== undefined && selected.length >= rules.max) return [...selected];
return [...selected, id];
}
/** Whether another asset can still be added without deselecting one first. */
export function canSelectMore(selected: readonly string[], max?: number): boolean {
return max === undefined || max === 1 || selected.length < max;
}
/** Client-side filter for lists the server returns whole (an album's assets). */
export function matchesQuery(asset: { originalFileName: string }, query: string): boolean {
const q = query.trim().toLowerCase();
return q === '' || asset.originalFileName.toLowerCase().includes(q);
}

127
lib/admin/contentVersion.ts Normal file
View File

@@ -0,0 +1,127 @@
/**
* Concurrent-edit detection for the admin's content files (#601).
*
* Every editor holds a full snapshot of the file it edits and PUTs all of it,
* so without a check the tab that saves second silently reverts whatever the
* first one changed — as does a panel left open across a hand edit or a backup
* restore. Each GET therefore hands out a version of the file it read, the PUT
* sends it back in `If-Match`, and the write is refused with 409 when the file
* on disk is no longer that version.
*
* The version is a hash of the bytes, not the mtime: the mtime has a
* resolution the filesystem chooses (a second on some), survives a copy that
* changes the content (`cp -p`, a restore from a backup of equal age) and
* changes on a `touch` that does not. The files are small, so hashing one per
* save costs nothing that matters.
*
* A PUT without `If-Match` is written as before. That keeps an older admin tab
* (loaded before the upgrade) and scripts working, and the check is a guard
* against accidents, not an access control: an operator who wants to overwrite
* can always reload and save. `If-Match: *` means the same — write regardless.
*/
import crypto from 'crypto';
import fs from 'fs/promises';
import { NextResponse } from 'next/server';
/** The version of a file that does not exist. A save expecting it creates the file. */
export const ABSENT_VERSION = 'absent';
/**
* Version of some content; null content means "no file". A string is hashed
* as its UTF-8 bytes, which is what a save writes — so it fits content about
* to be written, not text decoded from a file (see readVersioned).
*/
export function versionOf(content: string | Buffer | null): string {
if (content === null) return ABSENT_VERSION;
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 32);
}
/**
* Read a text file together with the version of its bytes, for an editor's
* GET. The version has to come from the bytes, never from the decoded text:
* a file that is not valid UTF-8 (a Latin-1 "Über" from a hand edit) decodes
* lossily, the text re-encodes to other bytes, and a version taken from it
* would never equal what `fileVersion()` computes at save time — every save
* of that file would be refused as a conflict. Throws ENOENT like readFile.
*/
export async function readVersioned(filePath: string): Promise<{ text: string; version: string }> {
const bytes = await fs.readFile(filePath);
return { text: bytes.toString('utf8'), version: versionOf(bytes) };
}
/** Version of the file at `filePath` as it is on disk now. */
export async function fileVersion(filePath: string): Promise<string> {
try {
return versionOf(await fs.readFile(filePath));
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return ABSENT_VERSION;
throw err;
}
}
/** The file changed on disk since the editor loaded it. */
export class VersionConflictError extends Error {
constructor(readonly currentVersion: string) {
super('The file was changed elsewhere since it was loaded.');
this.name = 'VersionConflictError';
}
}
/**
* Throw a VersionConflictError when `filePath` is not at `baseVersion`.
* An undefined base version (no `If-Match`) skips the check.
*/
export async function assertVersion(
filePath: string,
baseVersion: string | undefined,
): Promise<void> {
if (baseVersion === undefined) return;
const current = await fileVersion(filePath);
if (current !== baseVersion) throw new VersionConflictError(current);
}
/**
* The base version a PUT sent, or undefined for "no check". Accepts the
* quoted ETag form (`"abc"`, `W/"abc"`) as well as a bare token; `*` and an
* empty header mean no check.
*/
export function baseVersionFrom(request: Request): string | undefined {
const raw = request.headers.get('if-match')?.trim();
if (!raw || raw === '*') return undefined;
return raw.replace(/^W\//, '').replace(/^"(.*)"$/, '$1');
}
/** The header form of a version. */
export function etag(version: string): string {
return `"${version}"`;
}
/** The 409 every content route answers a stale save with. */
export function conflictResponse(currentVersion: string): NextResponse {
return NextResponse.json(
{
error: 'This was changed elsewhere since you opened it.',
conflict: true,
version: currentVersion,
},
{ status: 409, headers: { ETag: etag(currentVersion) } },
);
}
/**
* Run content writes one at a time within this process.
*
* The version check and the write it guards must not interleave with another
* save, or two tabs saving in the same instant would both pass the check. A
* single queue for all content files is enough — admin saves are rare and
* short — and it also serialises a rename's write-then-delete. Like the rate
* limiter it is per process; Folio runs as one.
*/
let queue: Promise<unknown> = Promise.resolve();
export function serializeContentWrite<T>(fn: () => Promise<T>): Promise<T> {
const run = queue.then(fn, fn);
queue = run.catch(() => {});
return run;
}

View File

@@ -24,6 +24,11 @@ export interface DoctorFinding {
* diagnostics page can link straight to the album in the page builder.
*/
albumIds?: string[];
/**
* The settings section (`/admin/settings/<section>`) that holds the fix, when
* it depends on the finding rather than on its id.
*/
settingsSection?: string;
}
/** The worst level present — what the status badge should show. */
@@ -558,6 +563,53 @@ export function checkContact(
};
}
/** Where each value checked by `validateSettingValues` is edited. */
const SETTING_SECTIONS: Record<string, string> = {
url: 'seo',
'theme.accent': 'theme',
'grid.columns': 'grid',
'grid.gap': 'grid',
};
/** Human names for the same fields, for the finding's text. */
const SETTING_LABELS: Record<string, string> = {
url: 'Site URL',
'theme.accent': 'accent colour',
'grid.columns': 'grid columns',
'grid.gap': 'grid gap',
};
/**
* Values in settings.yaml the site does not use as written — an accent that is
* not hex, a column count outside 1–6, a site URL that is not a full address.
* The resolvers fall back or clamp, so nothing breaks and nothing says so; the
* panel now refuses to save them, but a hand-edited file or one saved before
* that still holds them. Takes `validateSettingValues(settings.yaml)`.
*/
export function checkSettingValues(errors: Array<{ field: string }>): DoctorFinding {
if (errors.length === 0) {
return {
id: 'settings-values',
level: 'ok',
title: 'Settings are used as written',
detail: 'Site URL, accent colour and grid values in settings.yaml are all valid.',
};
}
const names = errors.map((e) => SETTING_LABELS[e.field] ?? e.field);
return {
id: 'settings-values',
level: 'warn',
title:
errors.length === 1
? `The ${names[0]} setting is ignored`
: `${errors.length} settings are ignored`,
detail:
`settings.yaml holds a value the site cannot use for: ${names.join(', ')}. ` +
'It falls back to the default instead. Open the setting to see what it expects.',
settingsSection: SETTING_SECTIONS[errors[0].field],
};
}
/**
* A site that needs an Impressum almost always needs a privacy policy too
* (Art. 13 GDPR). Returns null while there is neither an Impressum nor a
@@ -637,3 +689,60 @@ export function checkImmichCalls(results: Array<{ endpoint: string; ok: boolean
detail: results.map((r) => r.endpoint).join(', '),
};
}
export interface PageSlugProblem {
slug: string;
/** One sentence from describeCollision(). */
reason: string;
}
/**
* Content pages (#722): a `- page:` reference in gallery.yaml whose file does
* not exist puts nothing in the menu, and a page whose slug is already taken
* is shadowed by the subpage or album — or, for a built-in route, never
* reached at all. Returns null while there are no pages and no references.
*/
export function checkContentPages(p: {
/** Slugs referenced from gallery.yaml. */
menuRefs: string[];
/** Slugs of the files in content/pages/. */
pages: string[];
collisions: PageSlugProblem[];
}): DoctorFinding | null {
if (!p.menuRefs.length && !p.pages.length) return null;
const existing = new Set(p.pages);
const missing = p.menuRefs.filter((slug) => !existing.has(slug));
const missingList = missing.map((s) => `"${s}"`).join(', ');
if (p.collisions.length) {
const n = p.collisions.length;
return {
id: 'content-pages',
level: 'error',
title: `${n} content ${n === 1 ? 'page has' : 'pages have'} a slug that is already taken`,
detail:
p.collisions.map((c) => c.reason).join(' ') +
' Rename the page in Pages so visitors can reach it.' +
(missing.length ? ` The menu also lists missing pages: ${missingList}.` : ''),
};
}
if (missing.length) {
return {
id: 'content-pages',
level: 'warn',
title: `The menu lists ${missing.length} missing ${missing.length === 1 ? 'page' : 'pages'}`,
detail:
`gallery.yaml references ${missingList}, but content/pages/ has no such file. ` +
'The menu skips the entry; remove the reference or create the page.',
};
}
const n = p.pages.length;
return {
id: 'content-pages',
level: 'ok',
title: `${n} content ${n === 1 ? 'page' : 'pages'}, every slug free`,
detail: `${p.menuRefs.length} of them in the menu.`,
};
}

78
lib/admin/envLocks.ts Normal file
View File

@@ -0,0 +1,78 @@
/**
* Settings fields that an environment variable overrides (#605).
*
* Only fields where the environment actually *wins* belong here — an edit in
* the panel would save, report success and then do nothing. `SITE_TITLE`,
* `SITE_SUBTITLE` and `SITE_URL` are deliberately absent: lib/config/index.ts
* resolves them as `settings.X ?? env.X`, so the field takes precedence and
* editing it works (see lib/siteUrl.ts).
*
* Client-safe: no `fs`, no `lib/env` import. The route passes the parsed env
* in, and only the variable *names* ever reach the browser.
*/
/** Dotted settings.yaml path → the environment variable that overrides it. */
export const ENV_LOCKS = {
sitePassword: 'SITE_PASSWORD',
'contact.notifyUrl': 'CONTACT_NOTIFY_URL',
} as const;
export type EnvLockedKey = keyof typeof ENV_LOCKS;
/** Locked path → variable name. Never carries the variable's value. */
export type EnvLocks = Partial<Record<EnvLockedKey, string>>;
/** The locks in effect for this environment: paths whose variable is set. */
export function resolveEnvLocks(env: object): EnvLocks {
const source = env as Record<string, unknown>;
const locks: EnvLocks = {};
for (const [path, name] of Object.entries(ENV_LOCKS) as [EnvLockedKey, string][]) {
const value = source[name];
if (typeof value === 'string' && value.trim() !== '') locks[path] = name;
}
return locks;
}
function getPath(obj: unknown, path: string[]): unknown {
let cur = obj;
for (const key of path) {
if (!cur || typeof cur !== 'object') return undefined;
cur = (cur as Record<string, unknown>)[key];
}
return cur;
}
/**
* The incoming settings with every locked path reset to what settings.yaml
* already holds. The panel disables those fields, so this only matters for a
* hand-crafted request or a stale draft — but a locked field must not change
* through a save whose effect the operator could never see.
*/
export function keepLockedValues<T extends object>(
incoming: T,
stored: object | null | undefined,
locks: EnvLocks,
): T {
const out = structuredClone(incoming) as Record<string, unknown>;
for (const dotted of Object.keys(locks)) {
const path = dotted.split('.');
const last = path[path.length - 1];
const previous = getPath(stored, path);
let parent: Record<string, unknown> | null = out;
for (const key of path.slice(0, -1)) {
const next: unknown = parent[key];
if (!next || typeof next !== 'object') {
if (previous === undefined) {
parent = null;
break;
}
parent[key] = {};
}
parent = parent[key] as Record<string, unknown>;
}
if (!parent) continue;
if (previous === undefined) delete parent[last];
else parent[last] = previous;
}
return out as T;
}

View File

@@ -7,6 +7,8 @@ import fs from 'fs/promises';
import nodeFs from 'fs';
import path from 'path';
import { atomicWrite } from '../atomicWrite';
import { assertVersion, readVersioned, serializeContentWrite, versionOf } from './contentVersion';
import { ParsedFileCache } from './parsedFileCache';
import {
parseJournalMarkdown,
calculateReadingTime,
@@ -184,10 +186,30 @@ export function loadEssayFromFile(filename: string): ParsedJournal | null {
}
}
/** What a listing needs from one file — everything but the name-derived fields. */
type EntryFacts = Omit<JournalEntrySummary, 'slug' | 'filename'>;
/**
* Parsed listing facts per file, reused while the file is unchanged: every
* public page render lists the journal for the header nav (see
* parsedFileCache.ts).
*/
const summaryCache = new ParsedFileCache<EntryFacts>((content) => {
const parsed = parseJournalMarkdown(content);
const { words, minutes } = calculateReadingTime(content);
return {
frontmatter: parsed.frontmatter,
excerpt: extractExcerpt(parsed),
wordCount: words,
readingTimeMinutes: minutes,
};
});
/** List all journal entries */
export async function listJournalEntries(): Promise<JournalEntrySummary[]> {
const entries: JournalEntrySummary[] = [];
const seenSlugs = new Set<string>();
const listed = new Set<string>();
const scanDir = async (dir: string) => {
try {
@@ -205,19 +227,10 @@ export async function listJournalEntries(): Promise<JournalEntrySummary[]> {
seenSlugs.add(slug);
try {
const content = await fs.readFile(path.join(dir, file), 'utf8');
const parsed = parseJournalMarkdown(content);
const { words, minutes } = calculateReadingTime(content);
const excerpt = extractExcerpt(parsed);
entries.push({
slug,
filename: file,
frontmatter: parsed.frontmatter,
excerpt,
wordCount: words,
readingTimeMinutes: minutes,
});
const filePath = path.join(dir, file);
listed.add(filePath);
const summary = await summaryCache.read(filePath);
entries.push({ slug, filename: file, ...summary });
} catch (err) {
console.error(`[Journal] Failed to parse ${file}:`, err);
}
@@ -229,15 +242,19 @@ export async function listJournalEntries(): Promise<JournalEntrySummary[]> {
await scanDir(JOURNAL_DIR);
await scanDir(LEGACY_ESSAYS_DIR);
summaryCache.retain(listed);
// Sort: descending by date, fallback to title/slug
return entries.sort((a, b) => {
const dateA = a.frontmatter.date || '';
const dateB = b.frontmatter.date || '';
if (dateA && dateB) return dateB.localeCompare(dateA);
if (dateA && dateB) return dateB.localeCompare(dateA) || a.slug.localeCompare(b.slug);
if (dateA) return -1;
if (dateB) return 1;
return (a.frontmatter.title || a.slug).localeCompare(b.frontmatter.title || b.slug);
return (
(a.frontmatter.title || a.slug).localeCompare(b.frontmatter.title || b.slug) ||
a.slug.localeCompare(b.slug)
);
});
}
@@ -246,14 +263,16 @@ export async function readJournalEntry(slug: string): Promise<{
slug: string;
rawMarkdown: string;
parsed: ParsedJournal;
/** Version of the file's bytes (#601), for the editor's If-Match. */
version: string;
} | null> {
const filePath = resolveJournalFilePath(slug);
if (!filePath) return null;
try {
const rawMarkdown = await fs.readFile(filePath, 'utf8');
const { text: rawMarkdown, version } = await readVersioned(filePath);
const parsed = parseJournalMarkdown(rawMarkdown);
return { slug, rawMarkdown, parsed };
return { slug, rawMarkdown, parsed, version };
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw err;
@@ -358,3 +377,28 @@ export async function deleteJournalEntry(slug: string): Promise<boolean> {
if (deletedAny) console.log(`[Journal] 🗑️ Deleted ${filename}`);
return deletedAny;
}
/**
* The editor's save (#601): write `slug`, refusing with a VersionConflictError
* when the file the editor loaded — `fromSlug`'s, for a rename — is no longer
* at `baseVersion`. A rename writes the new file and then deletes the old one.
* Check, write and delete run in the content write queue, so another save
* cannot land between them. Returns the version of what was written.
*/
export function saveJournalEntry(
slug: string,
rawMarkdown: string,
options: { baseVersion?: string; fromSlug?: string } = {},
): Promise<string> {
const fromSlug = options.fromSlug ?? slug;
return serializeContentWrite(async () => {
if (options.baseVersion !== undefined) {
const loaded = resolveJournalFilePath(fromSlug);
if (!loaded) throw new Error(`Invalid journal entry slug: "${fromSlug}"`);
await assertVersion(loaded, options.baseVersion);
}
await writeJournalEntry(slug, rawMarkdown);
if (fromSlug !== slug) await deleteJournalEntry(fromSlug);
return versionOf(rawMarkdown);
});
}

59
lib/admin/pageRefs.ts Normal file
View File

@@ -0,0 +1,59 @@
/**
* Keep gallery.yaml's `- page: <slug>` references in step with the page files
* (#722): a deleted page leaves the menu, a renamed one keeps its place.
*
* Server only. Writes go through writeGalleryYaml(), so the previous
* gallery.yaml is backed up first like every other admin save.
*/
import { readGalleryYamlVersioned, writeGalleryYaml } from './yaml-service';
import { VersionConflictError } from './contentVersion';
import { invalidateConfigCache, isPageRef } from '../config';
/**
* Pure: the `subpages:` list with every reference to `from` replaced by `to`,
* or dropped when `to` is null. Returns the same list when nothing matched.
*/
export function rewritePageRefs<T>(subpages: T[], from: string, to: string | null): T[] {
if (!subpages.some((e) => isPageRef(e) && e.page === from)) return subpages;
return subpages.flatMap((entry) => {
if (!isPageRef(entry) || entry.page !== from) return [entry];
return to === null ? [] : [{ ...entry, page: to } as T];
});
}
/** gallery.yaml's version before and after a menu rewrite (#601). */
export interface GalleryVersionChange {
from: string;
to: string;
}
/**
* Apply rewritePageRefs() to gallery.yaml on disk. Returns the version change
* when the file changed, null when it did not.
*
* The page builder is open while this runs and holds gallery.yaml's version.
* With `from` and `to` it can tell "only this rewrite happened since I loaded"
* (take the new version) from "someone else saved too" (keep the old one, so
* its next save is refused rather than clobbering). The write itself is
* checked against the version read here, and re-read on a conflict, so a
* builder save racing the rewrite is not overwritten either.
*/
export async function updatePageRefs(
from: string,
to: string | null,
): Promise<GalleryVersionChange | null> {
for (let attempt = 0; ; attempt++) {
const { data: gallery, version } = await readGalleryYamlVersioned();
if (!gallery || !Array.isArray(gallery.subpages)) return null;
const next = rewritePageRefs(gallery.subpages, from, to);
if (next === gallery.subpages) return null;
try {
const written = await writeGalleryYaml({ ...gallery, subpages: next }, version);
invalidateConfigCache();
return { from: version, to: written };
} catch (err) {
if (!(err instanceof VersionConflictError) || attempt >= 2) throw err;
}
}
}

27
lib/admin/pageSlugs.ts Normal file
View File

@@ -0,0 +1,27 @@
/**
* The slugs a content page must not take (#722), gathered from the live
* configuration: subpages, standalone albums and journal entries. The fixed
* routes are checked by pageSlugCollision() itself.
*
* Server only. Album slugs need Immich; when it cannot answer, the album list
* is empty rather than the whole check failing — a save during an Immich
* outage should not be refused for a reason nobody can see.
*/
import { getConfig } from '../config';
import { immich } from '../immich';
import { listJournalEntries } from './journal-service';
import type { SlugTakenBy } from '../pages';
export async function takenPageSlugs(): Promise<SlugTakenBy> {
const config = getConfig();
const [albums, journal] = await Promise.all([
immich.getStandaloneAlbums().catch(() => []),
listJournalEntries().catch(() => []),
]);
return {
subpages: config.subpages.map((sp) => sp.slug),
albums: albums.map((a) => a.slug),
journal: journal.map((e) => e.slug),
};
}

285
lib/admin/pages-service.ts Normal file
View File

@@ -0,0 +1,285 @@
/**
* Server-side storage for content pages (#722): `content/pages/<slug>.md`.
*
* Mirrors journal-service.ts — the same slug rule, the same containment check
* on every path, atomic writes, and rotating backups in
* `content/pages/.backups/` (ten saves per page; delete and pre-restore
* snapshots are never pruned).
*/
import fs from 'fs/promises';
import nodeFs from 'fs';
import path from 'path';
import { atomicWrite } from '../atomicWrite';
import { assertVersion, readVersioned, serializeContentWrite, versionOf } from './contentVersion';
import { isValidSlug, parseJournalMarkdown, type ParsedJournal } from '../journal';
import type { PageSummary } from '../pages';
import { ParsedFileCache } from './parsedFileCache';
const MAX_BACKUPS = 10;
/**
* Join a filename onto a directory, or return null if the result would escape
* it. isValidSlug() already rejects dots and separators; this is the second
* lock, and the barrier the taint analysis in CI can see.
*/
function containedPath(dir: string, filename: string): string | null {
const base = path.resolve(dir);
const resolved = path.resolve(base, filename);
if (resolved !== base && !resolved.startsWith(base + path.sep)) return null;
return resolved;
}
/**
* Resolved per call rather than at import: tests point the service at a
* temporary directory by changing the working directory.
*/
function pagesDir(): string {
return path.join(process.cwd(), 'content', 'pages');
}
function backupDir(): string {
return path.join(pagesDir(), '.backups');
}
/** `<slug>.md.<timestamp>[.deleted|.pre-restore].bak` — see journal-service. */
const PAGE_BACKUP_NAME = /^([\w-]+)\.md\.[\w-]+\.(?:(deleted|pre-restore)\.)?bak$/;
export interface PageBackup {
filename: string;
slug: string;
kind: 'save' | 'deleted' | 'pre-restore';
}
/** The file a slug lives in, or null for a slug that is not one. */
export function resolvePageFilePath(slug: string): string | null {
if (!isValidSlug(slug)) return null;
return containedPath(pagesDir(), `${slug}.md`);
}
async function snapshotPage(
filePath: string,
filename: string,
kind?: 'deleted' | 'pre-restore',
): Promise<void> {
await fs.mkdir(backupDir(), { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const suffix = kind ? `.${kind}` : '';
const backupPath = containedPath(backupDir(), `${filename}.${timestamp}${suffix}.bak`);
if (!backupPath) {
throw new Error(`Refusing to write a backup outside ${backupDir()}: "${filename}"`);
}
await fs.copyFile(filePath, backupPath);
}
async function prunePageBackups(filename: string): Promise<void> {
const backups = (await fs.readdir(backupDir()))
.filter((f) => {
const match = PAGE_BACKUP_NAME.exec(f);
return match !== null && `${match[1]}.md` === filename && match[2] === undefined;
})
.sort();
for (const old of backups.slice(0, Math.max(0, backups.length - MAX_BACKUPS))) {
await fs.unlink(path.join(backupDir(), old)).catch(() => {});
}
}
/** Every page backup on disk, newest first. */
export async function listPageBackups(): Promise<PageBackup[]> {
let files: string[];
try {
files = await fs.readdir(backupDir());
} catch {
return [];
}
return files
.map((filename) => {
const match = PAGE_BACKUP_NAME.exec(filename);
if (!match) return null;
return { filename, slug: match[1], kind: (match[2] ?? 'save') as PageBackup['kind'] };
})
.filter((b): b is PageBackup => b !== null)
.sort((a, b) => b.filename.localeCompare(a.filename));
}
/**
* The listed frontmatter per file, reused while the file is unchanged: every
* public page render lists the pages for the header nav (see
* parsedFileCache.ts).
*/
const frontmatterCache = new ParsedFileCache<PageSummary['frontmatter']>((raw) => {
const { title, description, password, draft } = parseJournalMarkdown(raw).frontmatter;
return { title, description, password, draft };
});
/** Every page, sorted by title. Unparseable files are skipped with a log line. */
export async function listPages(): Promise<PageSummary[]> {
let files: string[];
try {
files = await fs.readdir(pagesDir());
} catch {
return [];
}
const pages: PageSummary[] = [];
const listed = new Set<string>();
for (const file of files) {
if (!file.endsWith('.md')) continue;
const slug = file.slice(0, -3);
const filePath = resolvePageFilePath(slug);
if (!filePath) continue;
listed.add(filePath);
try {
pages.push({ slug, frontmatter: await frontmatterCache.read(filePath) });
} catch (err) {
console.error(`[Pages] Failed to read ${file}:`, err);
}
}
frontmatterCache.retain(listed);
return pages.sort((a, b) =>
(a.frontmatter.title || a.slug).localeCompare(b.frontmatter.title || b.slug),
);
}
/** Slugs of every page file, without parsing them. */
export function listPageSlugsSync(): string[] {
try {
return nodeFs
.readdirSync(pagesDir())
.filter((f) => f.endsWith('.md'))
.map((f) => f.slice(0, -3))
.filter(isValidSlug);
} catch {
return [];
}
}
export interface PageRecord {
slug: string;
rawMarkdown: string;
parsed: ParsedJournal;
/** Version of the file's bytes (#601), for the editor's If-Match. */
version: string;
}
/** One page, or null when there is no such file. */
export async function readPage(slug: string): Promise<PageRecord | null> {
const filePath = resolvePageFilePath(slug);
if (!filePath) return null;
try {
const { text: rawMarkdown, version } = await readVersioned(filePath);
return { slug, rawMarkdown, parsed: parseJournalMarkdown(rawMarkdown), version };
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw err;
}
}
/** Synchronous read, for the password lookup in lib/auth.ts. */
export function readPageSync(slug: string): PageRecord | null {
const filePath = resolvePageFilePath(slug);
if (!filePath) return null;
try {
const bytes = nodeFs.readFileSync(filePath);
const rawMarkdown = bytes.toString('utf8');
return {
slug,
rawMarkdown,
parsed: parseJournalMarkdown(rawMarkdown),
version: versionOf(bytes),
};
} catch {
return null;
}
}
/** Write a page atomically, snapshotting the previous version first. */
export async function writePage(slug: string, rawMarkdown: string): Promise<void> {
const filePath = resolvePageFilePath(slug);
if (!filePath) throw new Error(`Invalid page slug: "${slug}"`);
const filename = `${slug}.md`;
await fs.mkdir(pagesDir(), { recursive: true });
// Only ENOENT means "nothing to back up"; any other failure aborts the save
// before it overwrites the live file (the journal's #630).
let exists = true;
try {
await fs.access(filePath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
exists = false;
}
if (exists) {
await snapshotPage(filePath, filename);
await prunePageBackups(filename);
}
await atomicWrite(filePath, rawMarkdown);
console.log(`[Pages] ✅ Saved ${filename}`);
}
/**
* Delete a page, keeping a `.deleted.bak` copy. The snapshot is not optional:
* when it cannot be written, the delete is refused.
*/
export async function deletePage(slug: string): Promise<boolean> {
const filePath = resolvePageFilePath(slug);
if (!filePath) throw new Error(`Invalid page slug: "${slug}"`);
try {
await fs.access(filePath);
} catch {
return false;
}
await snapshotPage(filePath, `${slug}.md`, 'deleted');
await fs.unlink(filePath);
console.log(`[Pages] 🗑️ Deleted ${slug}.md`);
return true;
}
/** Restore a page from one of its backups, returning the restored slug. */
export async function restorePageBackup(backupFilename: string): Promise<string> {
const match = PAGE_BACKUP_NAME.exec(backupFilename);
if (!match || path.basename(backupFilename) !== backupFilename || !isValidSlug(match[1])) {
throw new Error(`Refusing to restore from an unrecognised backup name: "${backupFilename}"`);
}
const slug = match[1];
const source = containedPath(backupDir(), backupFilename);
const target = resolvePageFilePath(slug);
if (!source || !target) {
throw new Error(`Refusing to restore from an unrecognised backup name: "${backupFilename}"`);
}
const content = await fs.readFile(source, 'utf8');
try {
await fs.access(target);
await snapshotPage(target, `${slug}.md`, 'pre-restore');
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
await fs.mkdir(pagesDir(), { recursive: true });
await atomicWrite(target, content);
console.log(`[Pages] 🔄 Restored ${slug}.md from ${backupFilename}`);
return slug;
}
/**
* The editor's save (#601): write `slug`, refusing with a VersionConflictError
* when the file the editor loaded — `fromSlug`'s, for a rename — is no longer
* at `baseVersion`. A rename writes the new file and then deletes the old one.
* Check, write and delete run in the content write queue, so another save
* cannot land between them. Returns the version of what was written.
*/
export function savePage(
slug: string,
rawMarkdown: string,
options: { baseVersion?: string; fromSlug?: string } = {},
): Promise<string> {
const fromSlug = options.fromSlug ?? slug;
return serializeContentWrite(async () => {
if (options.baseVersion !== undefined) {
const loaded = resolvePageFilePath(fromSlug);
if (!loaded) throw new Error(`Invalid page slug: "${fromSlug}"`);
await assertVersion(loaded, options.baseVersion);
}
await writePage(slug, rawMarkdown);
if (fromSlug !== slug) await deletePage(fromSlug);
return versionOf(rawMarkdown);
});
}

View File

@@ -0,0 +1,57 @@
/**
* Per-file memo for the journal and page listings.
*
* `listJournalEntries()` and `listPages()` run on every public page render —
* the header nav asks whether a journal exists and which pages to link — and
* used to read and parse every markdown file each time. The parse is the
* expensive part and depends only on the file's bytes, so it is kept here,
* keyed on the file's identity and stamp: inode, size and mtime in
* nanoseconds.
*
* An admin save goes through atomicWrite (temp file + rename), which always
* produces a new inode, so a save is seen by the very next listing, in this
* module instance or any other. An edit in place changes mtime. The stat is
* taken before the read, so a write landing between the two at worst stores
* the new bytes under the old stamp — which the next stat no longer matches,
* so the file is simply read again. Stale bytes under a current stamp cannot
* happen.
*
* Values are handed out as copies: a caller that mutates an entry must not
* change what the next request sees.
*/
import fs from 'fs/promises';
interface Cached<T> {
stamp: string;
value: T;
}
export class ParsedFileCache<T> {
private readonly entries = new Map<string, Cached<T>>();
constructor(private readonly parse: (raw: string) => T) {}
/**
* Parsed contents of `filePath`, from the memo when the file is unchanged.
* Throws like `fs.readFile` for a missing or unreadable file, and like
* `parse` for one that does not parse — neither outcome is cached.
*/
async read(filePath: string): Promise<T> {
const st = await fs.stat(filePath, { bigint: true });
const stamp = `${st.ino}:${st.size}:${st.mtimeNs}`;
const hit = this.entries.get(filePath);
if (hit && hit.stamp === stamp) return structuredClone(hit.value);
const value = this.parse(await fs.readFile(filePath, 'utf8'));
this.entries.set(filePath, { stamp, value });
return structuredClone(value);
}
/** Drop entries for files a listing no longer found, so deletes do not pile up. */
retain(filePaths: Set<string>): void {
for (const key of this.entries.keys()) {
if (!filePaths.has(key)) this.entries.delete(key);
}
}
}

View File

@@ -0,0 +1,96 @@
/**
* What the admin panel sees of a proofing session: the stored record plus the
* album's name and the state, without the Immich lookups repeated per caller.
*/
import { immich, type ImmichAlbum } from '@/lib/immich';
import { imageUrl } from '@/lib/urls';
import {
downloadsRemaining,
sessionState,
type ProofSession,
type ProofState,
} from '@/lib/proofSessions';
export interface AdminProofSession {
id: string;
clientName: string;
albumId: string;
/** Null when Immich no longer has the album (or cannot be reached). */
albumName: string | null;
albumAssetCount: number | null;
/** Relative path of the client link. */
link: string;
createdAt: string;
expiresOn: string | null;
download: ProofSession['download'];
downloadLimit: number | null;
downloadsUsed: number;
downloadsRemaining: number | null;
selected: number;
updatedAt: string | null;
submittedAt: string | null;
state: ProofState;
}
export interface AdminProofPick {
assetId: string;
position: number;
fileName: string;
thumbUrl: string;
takenAt?: string;
}
/** Load each album once, tolerating ones that are gone or unreachable. */
export async function loadAlbums(ids: string[]): Promise<Map<string, ImmichAlbum | null>> {
const unique = Array.from(new Set(ids));
const albums = await Promise.all(
unique.map((id) => immich.getProofingAlbum(id).catch(() => null)),
);
return new Map(unique.map((id, i) => [id, albums[i]]));
}
export function toAdminSession(
session: ProofSession,
album: ImmichAlbum | null,
): AdminProofSession {
return {
id: session.id,
clientName: session.clientName,
albumId: session.albumId,
albumName: album?.albumName ?? null,
albumAssetCount: album ? album.assets.length : null,
link: `/proof/${session.token}`,
createdAt: session.createdAt,
expiresOn: session.expiresOn ?? null,
download: session.download,
downloadLimit: session.downloadLimit ?? null,
downloadsUsed: session.downloadsUsed,
downloadsRemaining: downloadsRemaining(session),
selected: session.selection.length,
updatedAt: session.updatedAt ?? null,
submittedAt: session.submittedAt ?? null,
state: sessionState(session),
};
}
/** The selected photos in album order, with what the export and preview need. */
export function picksFor(session: ProofSession, album: ImmichAlbum | null): AdminProofPick[] {
if (!album) return [];
const chosen = new Set(session.selection);
const media = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO');
const picks: AdminProofPick[] = [];
media.forEach((asset, index) => {
if (!chosen.has(asset.id)) return;
picks.push({
assetId: asset.id,
position: index + 1,
fileName: asset.originalFileName,
thumbUrl: imageUrl(asset.id, 'thumbnail'),
...(asset.localDateTime || asset.fileCreatedAt
? { takenAt: asset.localDateTime ?? asset.fileCreatedAt }
: {}),
});
});
return picks;
}

View File

@@ -8,35 +8,67 @@ import path from 'path';
import yaml from 'js-yaml';
import { atomicWrite } from '../atomicWrite';
import type { GalleryYaml, SettingsYaml } from '../config/schema';
import { assertVersion, readVersioned, serializeContentWrite, versionOf } from './contentVersion';
const CONTENT_DIR = path.join(process.cwd(), 'content');
const MAX_BACKUPS = 10; // Keep last 10 backups per file
/** Read gallery.yaml and return parsed content. */
export async function readGalleryYaml(): Promise<GalleryYaml | null> {
/** A parsed YAML file and the version (#601) of the bytes it was parsed from. */
export interface VersionedYaml<T> {
data: T | null;
version: string;
}
async function readYamlVersioned<T>(filename: string): Promise<VersionedYaml<T>> {
try {
const raw = await fs.readFile(path.join(CONTENT_DIR, 'gallery.yaml'), 'utf8');
return yaml.load(raw) as GalleryYaml;
const { text, version } = await readVersioned(path.join(CONTENT_DIR, filename));
return { data: yaml.load(text) as T, version };
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return { data: null, version: versionOf(null) };
}
throw err;
}
}
/** Read gallery.yaml and return parsed content. */
export async function readGalleryYaml(): Promise<GalleryYaml | null> {
return (await readGalleryYamlVersioned()).data;
}
/** gallery.yaml with its version, for the editor that will save it back. */
export function readGalleryYamlVersioned(): Promise<VersionedYaml<GalleryYaml>> {
return readYamlVersioned<GalleryYaml>('gallery.yaml');
}
/** Read settings.yaml and return parsed content. */
export async function readSettingsYaml(): Promise<SettingsYaml | null> {
try {
const raw = await fs.readFile(path.join(CONTENT_DIR, 'settings.yaml'), 'utf8');
return yaml.load(raw) as SettingsYaml;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw err;
}
return (await readSettingsYamlVersioned()).data;
}
/** Atomically write a YAML file with backup. */
async function writeYamlFile(filename: string, data: unknown): Promise<void> {
/** settings.yaml with its version, for the editor that will save it back. */
export function readSettingsYamlVersioned(): Promise<VersionedYaml<SettingsYaml>> {
return readYamlVersioned<SettingsYaml>('settings.yaml');
}
/**
* Atomically write a YAML file with backup, returning the new version.
*
* With a `baseVersion`, the write is refused with a VersionConflictError when
* the file on disk is no longer that version (#601) — checked inside the write
* queue, so no other save can slip in between the check and the rename.
*/
function writeYamlFile(filename: string, data: unknown, baseVersion?: string): Promise<string> {
return serializeContentWrite(() => writeYamlFileNow(filename, data, baseVersion));
}
async function writeYamlFileNow(
filename: string,
data: unknown,
baseVersion: string | undefined,
): Promise<string> {
const filePath = path.join(CONTENT_DIR, filename);
await assertVersion(filePath, baseVersion);
// Ensure content directory exists
await fs.mkdir(CONTENT_DIR, { recursive: true });
@@ -85,16 +117,17 @@ async function writeYamlFile(filename: string, data: unknown): Promise<void> {
await atomicWrite(filePath, content);
console.log(`[Admin] ✅ Saved ${filename}`);
return versionOf(content);
}
/** Write gallery.yaml. */
export async function writeGalleryYaml(data: GalleryYaml): Promise<void> {
await writeYamlFile('gallery.yaml', data);
/** Write gallery.yaml; see writeYamlFile for `baseVersion`. Returns the new version. */
export function writeGalleryYaml(data: GalleryYaml, baseVersion?: string): Promise<string> {
return writeYamlFile('gallery.yaml', data, baseVersion);
}
/** Write settings.yaml. */
export async function writeSettingsYaml(data: SettingsYaml): Promise<void> {
await writeYamlFile('settings.yaml', data);
/** Write settings.yaml; see writeYamlFile for `baseVersion`. Returns the new version. */
export function writeSettingsYaml(data: SettingsYaml, baseVersion?: string): Promise<string> {
return writeYamlFile('settings.yaml', data, baseVersion);
}
/** List available backups for a file. */