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:
134
lib/admin/alt-text.ts
Normal file
134
lib/admin/alt-text.ts
Normal 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' };
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user