release: v0.15.0 (#585)

Eighteen changes since v0.14.0. See CHANGELOG.md for the full list and the upgrade notes (bump IMAGE_CACHE_VERSION).
This commit is contained in:
Ralksta
2026-09-19 22:24:10 +02:00
committed by GitHub
parent e99d3f2f1b
commit c3f92ca4ec
74 changed files with 4791 additions and 2146 deletions

134
lib/admin/alt-text.ts Normal file
View File

@@ -0,0 +1,134 @@
/**
* Alt-text coverage — which published photos reach the site without one.
*
* Folio has no alt-text field of its own: the Immich description becomes the
* `alt` of a photo, and only while the `caption` EXIF group is switched on
* (`assetCaption` in lib/urls.ts). A photo without a description therefore
* renders with an empty alt, silently, and nothing in the admin said so.
*
* Pure, like lib/admin/doctor.ts: the route gathers the albums, this judges
* them. The report is admin-only, so it carries raw asset UUIDs — the same as
* /api/admin/thumbnail, which renders them.
*/
export interface AltTextAssetInput {
id: string;
type: 'IMAGE' | 'VIDEO';
originalFileName: string;
exifInfo?: { description?: string | null } | null;
}
export interface AltTextAlbumInput {
id: string;
name: string;
/** Where the album is published — `/<subpage-slug>`, or `/` for a standalone one. */
path: string;
assets: AltTextAssetInput[];
}
export interface AltTextGap {
assetId: string;
fileName: string;
}
export interface AltTextAlbumReport {
albumId: string;
albumName: string;
path: string;
/** Photos in the album — videos are not counted, they have no alt. */
total: number;
missing: AltTextGap[];
}
export interface AltTextReport {
/** Whether descriptions are used as alt text at all (`exif.caption`). */
captionsEnabled: boolean;
/** Distinct published photos — one that sits in two albums counts once. */
total: number;
described: number;
/** Only albums with gaps, the most gaps first. */
albums: AltTextAlbumReport[];
}
function hasDescription(asset: AltTextAssetInput): boolean {
return !!asset.exifInfo?.description?.trim();
}
export function buildAltTextReport(
albums: AltTextAlbumInput[],
captionsEnabled: boolean,
): AltTextReport {
const seen = new Map<string, boolean>();
const reports: AltTextAlbumReport[] = [];
for (const album of albums) {
const photos = album.assets.filter((a) => a.type === 'IMAGE');
for (const photo of photos) seen.set(photo.id, hasDescription(photo));
const missing = photos
.filter((p) => !hasDescription(p))
.map((p) => ({ assetId: p.id, fileName: p.originalFileName }));
if (missing.length) {
reports.push({
albumId: album.id,
albumName: album.name,
path: album.path,
total: photos.length,
missing,
});
}
}
reports.sort((a, b) => b.missing.length - a.missing.length);
return {
captionsEnabled,
total: seen.size,
described: [...seen.values()].filter(Boolean).length,
albums: reports,
};
}
/**
* The published path of each album: the first subpage that lists it, else `/`.
* An album can sit on several subpages; one path is enough to find it again.
*/
export function albumPaths(
subpages: Array<{ slug: string; albumIds: string[] }>,
): Map<string, string> {
const paths = new Map<string, string>();
for (const sp of subpages) {
for (const id of sp.albumIds) {
if (!paths.has(id)) paths.set(id, `/${sp.slug}`);
}
}
return paths;
}
/**
* The Immich web app's address, derived from the API URL Folio talks to.
*
* This is the address *Folio* reaches Immich at. On a Docker network that is
* often `http://immich:2283`, which the operator's browser cannot open — see
* `pickImmichWebUrl` for the address preferred when there is one.
*/
export function immichWebUrl(apiUrl: string): string {
return apiUrl.replace(/\/+$/, '').replace(/\/api$/, '');
}
/**
* Where links into Immich should point: Immich's own *External domain*
* (Administration → Settings → Server) when it is set — the address Immich
* itself uses for share links, and so one a browser can open — else the
* address derived from the API URL, which may be internal.
*/
export function pickImmichWebUrl(
externalDomain: string | null | undefined,
apiUrl: string,
): { url: string; source: 'external-domain' | 'api-url' } {
const external = externalDomain?.trim().replace(/\/+$/, '');
if (external && /^https?:\/\//i.test(external)) {
return { url: external, source: 'external-domain' };
}
return { url: immichWebUrl(apiUrl), source: 'api-url' };
}

View File

@@ -19,6 +19,11 @@ export interface DoctorFinding {
title: string;
/** One sentence: what was observed, and what to do about it. */
detail: string;
/**
* The Immich album IDs a finding is about, when it is about albums — so the
* diagnostics page can link straight to the album in the page builder.
*/
albumIds?: string[];
}
/** The worst level present — what the status badge should show. */
@@ -194,6 +199,7 @@ export function checkAlbumIds(configured: string[], known: AlbumRef[]): DoctorFi
level: 'error',
title: `${missing.length} of ${configured.length} album IDs do not exist in Immich`,
detail: `Those pages are silently empty. Check gallery.yaml for: ${missing.join(', ')}`,
albumIds: missing,
};
}
@@ -234,6 +240,7 @@ export function checkAlbumsShared(configured: string[], known: AlbumRef[]): Doct
detail:
'They are served to visitors regardless — the allowlist in gallery.yaml is what decides. ' +
`Worth a look if it was unintentional: ${unshared.map((a) => a.albumName).join(', ')}`,
albumIds: unshared.map((a) => a.id),
};
}

View File

@@ -17,9 +17,129 @@ import {
const JOURNAL_DIR = path.join(process.cwd(), 'content', 'journal');
const LEGACY_ESSAYS_DIR = path.join(process.cwd(), 'content', 'essays');
const BACKUP_DIR = path.join(JOURNAL_DIR, '.backups');
const MAX_BACKUPS = 10;
let tmpCounter = 0;
/**
* Names this service writes into .backups/, and the only ones it restores from.
*
* `<slug>.md.<timestamp>[.deleted|.pre-restore].bak`. `[\w-]` is exactly the
* isValidSlug alphabet and matches neither `/` nor `.`, so a traversal cannot
* match. Group 1 is the slug, group 2 the snapshot kind.
*/
const JOURNAL_BACKUP_NAME = /^([\w-]+)\.md\.[\w-]+\.(?:(deleted|pre-restore)\.)?bak$/;
export interface JournalBackup {
filename: string;
slug: string;
/** `save` rotates; `deleted` and `pre-restore` snapshots are never pruned. */
kind: 'save' | 'deleted' | 'pre-restore';
}
/** Copy an entry into .backups/. Throws when the copy fails. */
async function snapshotEntry(
filePath: string,
filename: string,
kind?: 'deleted' | 'pre-restore',
): Promise<void> {
await fs.mkdir(BACKUP_DIR, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const suffix = kind ? `.${kind}` : '';
// `filename` is always `<slug>.md` with a validated slug, but the validation
// lives in callers and another module; containedPath is the barrier the
// taint analysis can see, as for every other path in this file.
const backupPath = containedPath(BACKUP_DIR, `${filename}.${timestamp}${suffix}.bak`);
if (!backupPath) {
throw new Error(`Refusing to write a backup outside ${BACKUP_DIR}: "${filename}"`);
}
await fs.copyFile(filePath, backupPath);
}
/**
* Keep the newest MAX_BACKUPS save backups of one entry.
*
* Deleted and pre-restore snapshots are left alone, as yaml-service does with
* its pre-restore files: ten saves of a re-created entry must not push out the
* only copy of the one that was deleted.
*/
async function pruneEntryBackups(filename: string): Promise<void> {
const backups = (await fs.readdir(BACKUP_DIR))
.filter((f) => {
const match = JOURNAL_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(BACKUP_DIR, old)).catch(() => {});
}
}
/** Every journal backup on disk, newest filename first per entry. */
export async function listJournalBackups(): Promise<JournalBackup[]> {
let files: string[];
try {
files = await fs.readdir(BACKUP_DIR);
} catch {
return [];
}
return files
.map((filename) => {
const match = JOURNAL_BACKUP_NAME.exec(filename);
if (!match) return null;
const kind = (match[2] ?? 'save') as JournalBackup['kind'];
return { filename, slug: match[1], kind };
})
.filter((b): b is JournalBackup => b !== null)
.sort((a, b) => b.filename.localeCompare(a.filename));
}
/**
* Restore an entry from one of its backups, returning the restored slug.
*
* The entry's current state, if it has one, is snapshotted first. The restore
* always lands in content/journal/, which resolveJournalFilePath checks before
* the legacy essays/ directory.
*/
export async function restoreJournalBackup(backupFilename: string): Promise<string> {
const match = JOURNAL_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 filename = `${slug}.md`;
const source = containedPath(BACKUP_DIR, backupFilename);
const target = containedPath(JOURNAL_DIR, filename);
if (!source || !target) {
throw new Error(`Refusing to restore from an unrecognised backup name: "${backupFilename}"`);
}
// Read before touching anything, so a missing backup changes nothing.
const content = await fs.readFile(source, 'utf8');
try {
await fs.access(target);
await snapshotEntry(target, filename, 'pre-restore');
} catch (err) {
// No current file is fine — that is the deleted-entry case. A failed
// snapshot of an existing file is not.
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
await fs.mkdir(JOURNAL_DIR, { recursive: true });
const tmpPath = `${target}.${process.pid}.${++tmpCounter}.tmp`;
try {
await fs.writeFile(tmpPath, content, 'utf8');
await fs.rename(tmpPath, target);
} catch (err) {
await fs.unlink(tmpPath).catch(() => {});
throw err;
}
console.log(`[Journal] 🔄 Restored ${filename} from ${backupFilename}`);
return slug;
}
/**
* Join a filename onto a content directory, or return null if the result would
* escape it.
@@ -163,22 +283,8 @@ export async function writeJournalEntry(slug: string, rawMarkdown: string): Prom
// Create rolling backup if file already exists
try {
await fs.access(filePath);
const backupDir = path.join(JOURNAL_DIR, '.backups');
await fs.mkdir(backupDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupName = `${filename}.${timestamp}.bak`;
await fs.copyFile(filePath, path.join(backupDir, backupName));
// Prune backups
const backups = (await fs.readdir(backupDir))
.filter((f) => f.startsWith(`${filename}.`) && f.endsWith('.bak'))
.sort();
if (backups.length > MAX_BACKUPS) {
for (const old of backups.slice(0, backups.length - MAX_BACKUPS)) {
await fs.unlink(path.join(backupDir, old)).catch(() => {});
}
}
await snapshotEntry(filePath, filename);
await pruneEntryBackups(filename);
} catch {
// New file, no backup needed
}
@@ -196,7 +302,15 @@ export async function writeJournalEntry(slug: string, rawMarkdown: string): Prom
console.log(`[Journal] ✅ Saved ${filename}`);
}
/** Safely delete a journal entry */
/**
* Delete a journal entry, keeping a `.deleted.bak` copy the Backup Manager can
* restore.
*
* Saving always rotated a backup; deleting did not, so the one irreversible
* action in the journal was the one without a way back. The snapshot is taken
* first and is not optional: if it cannot be written, the delete is refused
* rather than carried out unprotected.
*/
export async function deleteJournalEntry(slug: string): Promise<boolean> {
if (!isValidSlug(slug)) {
throw new Error(`Invalid journal slug: "${slug}"`);
@@ -205,6 +319,13 @@ export async function deleteJournalEntry(slug: string): Promise<boolean> {
const filePath = resolveJournalFilePath(slug);
if (!filePath) return false;
try {
await fs.access(filePath);
} catch {
return false;
}
await snapshotEntry(filePath, `${slug}.md`, 'deleted');
try {
await fs.unlink(filePath);
console.log(`[Journal] 🗑️ Deleted ${slug}.md`);

View File

@@ -128,7 +128,7 @@ export async function listBackups(filename: string): Promise<string[]> {
* readable file over content/settings.yaml — which the admin GET endpoints then
* hand straight back.
*/
const BACKUP_FILENAME = /^(gallery|settings)\.yaml\.[\w-]+\.(pre-restore\.)?bak$/;
const BACKUP_FILENAME = /^(gallery\.yaml|settings\.yaml|about\.md)\.[\w-]+\.(pre-restore\.)?bak$/;
/** Restore a specific backup. */
export async function restoreBackup(backupFilename: string): Promise<void> {
@@ -144,7 +144,8 @@ export async function restoreBackup(backupFilename: string): Promise<void> {
// Derived from the matched group, never from a substring search on the input:
// `includes('gallery.yaml')` would let the caller pick the destination.
const originalFilename = `${match[1]}.yaml`;
// about.md shares this directory: the about route writes its backups here.
const originalFilename = match[1];
const targetPath = path.join(CONTENT_DIR, originalFilename);
// Create a backup of current state first