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:
42
lib/admin/assetSelection.ts
Normal file
42
lib/admin/assetSelection.ts
Normal 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
127
lib/admin/contentVersion.ts
Normal 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;
|
||||
}
|
||||
@@ -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
78
lib/admin/envLocks.ts
Normal 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;
|
||||
}
|
||||
@@ -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
59
lib/admin/pageRefs.ts
Normal 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
27
lib/admin/pageSlugs.ts
Normal 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
285
lib/admin/pages-service.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
57
lib/admin/parsedFileCache.ts
Normal file
57
lib/admin/parsedFileCache.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
96
lib/admin/proofing-view.ts
Normal file
96
lib/admin/proofing-view.ts
Normal 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;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user