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:
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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user