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

View File

@@ -0,0 +1,135 @@
import { describe, it, expect } from 'vitest';
import {
albumPaths,
buildAltTextReport,
immichWebUrl,
pickImmichWebUrl,
type AltTextAssetInput,
} from '../admin/alt-text';
const photo = (id: string, description: string | null = null): AltTextAssetInput => ({
id,
type: 'IMAGE',
originalFileName: `${id}.jpg`,
exifInfo: { description },
});
describe('buildAltTextReport', () => {
it('lists photos without a description, per album', () => {
const report = buildAltTextReport(
[
{
id: 'a1',
name: 'Island',
path: '/travel',
assets: [photo('p1', 'A fjord'), photo('p2')],
},
],
true,
);
expect(report.total).toBe(2);
expect(report.described).toBe(1);
expect(report.albums).toEqual([
{
albumId: 'a1',
albumName: 'Island',
path: '/travel',
total: 2,
missing: [{ assetId: 'p2', fileName: 'p2.jpg' }],
},
]);
});
/** A description of spaces renders as an empty alt all the same. */
it('treats a blank description as missing', () => {
const report = buildAltTextReport(
[{ id: 'a1', name: 'A', path: '/', assets: [photo('p1', ' ')] }],
true,
);
expect(report.described).toBe(0);
expect(report.albums[0].missing).toHaveLength(1);
});
/** Videos have no alt attribute to fill. */
it('does not count videos', () => {
const report = buildAltTextReport(
[
{
id: 'a1',
name: 'A',
path: '/',
assets: [{ id: 'v1', type: 'VIDEO', originalFileName: 'v1.mp4' }, photo('p1', 'x')],
},
],
true,
);
expect(report.total).toBe(1);
expect(report.albums).toEqual([]);
});
it('counts a photo that sits in two albums once', () => {
const shared = photo('p1');
const report = buildAltTextReport(
[
{ id: 'a1', name: 'A', path: '/', assets: [shared] },
{ id: 'a2', name: 'B', path: '/', assets: [shared] },
],
true,
);
expect(report.total).toBe(1);
expect(report.albums).toHaveLength(2);
});
it('puts the album with the most gaps first and leaves out complete ones', () => {
const report = buildAltTextReport(
[
{ id: 'few', name: 'Few', path: '/', assets: [photo('p1')] },
{ id: 'done', name: 'Done', path: '/', assets: [photo('p2', 'x')] },
{ id: 'many', name: 'Many', path: '/', assets: [photo('p3'), photo('p4')] },
],
true,
);
expect(report.albums.map((a) => a.albumId)).toEqual(['many', 'few']);
});
it('passes the caption setting through', () => {
expect(buildAltTextReport([], false).captionsEnabled).toBe(false);
});
});
describe('albumPaths', () => {
it('uses the first subpage that lists an album', () => {
const paths = albumPaths([
{ slug: 'travel', albumIds: ['a1'] },
{ slug: 'best-of', albumIds: ['a1', 'a2'] },
]);
expect(paths.get('a1')).toBe('/travel');
expect(paths.get('a2')).toBe('/best-of');
expect(paths.get('a3')).toBeUndefined();
});
});
describe('immichWebUrl', () => {
it('drops the /api suffix Folio appends', () => {
expect(immichWebUrl('http://immich:2283/api')).toBe('http://immich:2283');
expect(immichWebUrl('https://photos.example.com/api/')).toBe('https://photos.example.com');
});
});
describe('pickImmichWebUrl', () => {
it("prefers Immich's external domain", () => {
expect(pickImmichWebUrl('https://photos.example.com/', 'http://immich:2283/api')).toEqual({
url: 'https://photos.example.com',
source: 'external-domain',
});
});
it('falls back to the API address when the domain is unset or not a URL', () => {
for (const domain of ['', ' ', null, undefined, 'photos.example.com']) {
expect(pickImmichWebUrl(domain, 'http://immich:2283/api')).toEqual({
url: 'http://immich:2283',
source: 'api-url',
});
}
});
});

View File

@@ -36,6 +36,8 @@ describe('restoreBackup rejects anything that is not a backup it produced', () =
'gallery.yaml', // the live file, not a backup
'gallery.yaml.2026-05-31T17-30-00-000Z.bak.txt',
'evil-gallery.yaml.2026-05-31T17-30-00-000Z.bak',
'about.yaml.2026-05-31T17-30-00-000Z.bak',
'trip.md.2026-05-31T17-30-00-000Z.bak', // journal backups go through the journal service
'',
];
@@ -66,6 +68,13 @@ describe('restoreBackup accepts the names it writes', () => {
expect(fs.copyFile).toHaveBeenCalled();
});
it('restores an about.md backup onto content/about.md', async () => {
await restoreBackup('about.md.2026-05-31T17-30-00-000Z.bak');
const calls = vi.mocked(fs.copyFile).mock.calls;
expect(String(calls[calls.length - 1][1])).toMatch(/content\/about\.md$/);
});
it('restores a pre-restore backup', async () => {
await expect(
restoreBackup('settings.yaml.2026-05-31T17-30-00-000Z.pre-restore.bak'),

View File

@@ -110,6 +110,7 @@ describe('checkAlbumIds', () => {
const f = checkAlbumIds(['a', 'ghost'], known);
expect(f.level).toBe('error');
expect(f.detail).toContain('ghost');
expect(f.albumIds).toEqual(['ghost']);
});
it('flags an empty gallery', () => {
@@ -132,6 +133,7 @@ describe('checkAlbumsShared', () => {
const f = checkAlbumsShared(['a', 'b'], known);
expect(f.level).toBe('warn');
expect(f.detail).toContain('Private trip');
expect(f.albumIds).toEqual(['b']);
});
/** An older Immich may not report the flag; silence beats a false alarm. */

View File

@@ -142,4 +142,45 @@ A closing paragraph with **bold** details.`;
expect(markdown).toContain('![photo-1:fullbleed](Caption)');
expect(markdown).toContain('![p1, p2](Pair caption)');
});
it('keeps a markdown link in the quote attribution for rendering (#559)', () => {
const markdown = '> Silence was absolute. -- [BBC Archive](https://example.com/archive)';
const parsed = parseEssayMarkdown(markdown);
const quote = parsed.blocks[0];
expect(quote.type).toBe('quote');
if (quote.type !== 'quote') return;
// The attribution stays Markdown; EssayView renders it with
// renderInlineMarkdown, which is what turns the link into an anchor.
expect(quote.author).toBe('[BBC Archive](https://example.com/archive)');
expect(renderInlineMarkdown(quote.author!)).toContain(
'<a href="https://example.com/archive" target="_blank" rel="noopener noreferrer">BBC Archive</a>',
);
});
it('survives a serialize/parse round-trip without losing the attribution link', () => {
const markdown = '> Silence was absolute. -- [BBC Archive](https://example.com/archive)';
const once = serializeEssayMarkdown(parseEssayMarkdown(markdown));
expect(once).toContain('> Silence was absolute. -- [BBC Archive](https://example.com/archive)');
const reparsed = parseEssayMarkdown(once);
const quote = reparsed.blocks[0];
expect(quote.type).toBe('quote');
if (quote.type === 'quote') {
expect(quote.author).toBe('[BBC Archive](https://example.com/archive)');
}
});
it('does not turn an unsafe scheme in the attribution into a link', () => {
const markdown = '> Careful. -- [x](javascript:alert(1))';
const parsed = parseEssayMarkdown(markdown);
const quote = parsed.blocks[0];
expect(quote.type).toBe('quote');
if (quote.type !== 'quote') return;
const html = renderInlineMarkdown(quote.author!);
expect(html).not.toContain('<a');
expect(html).not.toContain('href');
});
});

View File

@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import os from 'os';
import path from 'path';
import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from 'fs/promises';
/**
* Deleting a journal entry used to be a bare unlink, while saving rotated a
* backup — the one irreversible action was the one without a way back, and the
* Backup Manager could not restore journal backups at all.
*
* The service resolves content/ from process.cwd() at import time, so each test
* points cwd at a fresh temp directory and re-imports it.
*/
let root: string;
let journalDir: string;
let backupDir: string;
let service: typeof import('../admin/journal-service');
beforeEach(async () => {
root = await mkdtemp(path.join(os.tmpdir(), 'folio-journal-'));
journalDir = path.join(root, 'content', 'journal');
backupDir = path.join(journalDir, '.backups');
await mkdir(journalDir, { recursive: true });
vi.spyOn(process, 'cwd').mockReturnValue(root);
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.resetModules();
service = await import('../admin/journal-service');
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(root, { recursive: true, force: true });
});
const entry = (title: string) => `---\ntitle: "${title}"\n---\n\nBody of ${title}\n`;
describe('deleteJournalEntry', () => {
it('keeps a .deleted.bak copy before removing the file', async () => {
await writeFile(path.join(journalDir, 'trip.md'), entry('Trip'));
await expect(service.deleteJournalEntry('trip')).resolves.toBe(true);
await expect(readdir(journalDir)).resolves.not.toContain('trip.md');
const backups = await readdir(backupDir);
expect(backups).toHaveLength(1);
expect(backups[0]).toMatch(/^trip\.md\.[\w-]+\.deleted\.bak$/);
await expect(readFile(path.join(backupDir, backups[0]), 'utf8')).resolves.toBe(entry('Trip'));
});
it('returns false for a missing entry without writing a backup', async () => {
await expect(service.deleteJournalEntry('nope')).resolves.toBe(false);
await expect(readdir(backupDir)).rejects.toThrow();
});
it('refuses to delete when the backup cannot be written', async () => {
await writeFile(path.join(journalDir, 'trip.md'), entry('Trip'));
// A file where the backup directory should be makes mkdir and copy fail.
await writeFile(backupDir, 'not a directory');
await expect(service.deleteJournalEntry('trip')).rejects.toThrow();
await expect(readdir(journalDir)).resolves.toContain('trip.md');
});
});
describe('restoreJournalBackup', () => {
it('brings a deleted entry back under its slug', async () => {
await writeFile(path.join(journalDir, 'trip.md'), entry('Trip'));
await service.deleteJournalEntry('trip');
const [deleted] = await service.listJournalBackups();
expect(deleted).toMatchObject({ slug: 'trip', kind: 'deleted' });
await expect(service.restoreJournalBackup(deleted.filename)).resolves.toBe('trip');
await expect(readFile(path.join(journalDir, 'trip.md'), 'utf8')).resolves.toBe(entry('Trip'));
});
it('snapshots the current version before overwriting it', async () => {
await writeFile(path.join(journalDir, 'trip.md'), entry('Old'));
await service.writeJournalEntry('trip', entry('New'));
const [saved] = await service.listJournalBackups();
await service.restoreJournalBackup(saved.filename);
await expect(readFile(path.join(journalDir, 'trip.md'), 'utf8')).resolves.toBe(entry('Old'));
const kinds = (await service.listJournalBackups()).map((b) => b.kind).sort();
expect(kinds).toEqual(['pre-restore', 'save']);
});
const rejected = [
'../../../../etc/passwd',
'trip.md',
'../trip.md.2026-05-31T17-30-00-000Z.bak',
'a/trip.md.2026-05-31T17-30-00-000Z.bak',
'trip.md.2026-05-31T17-30-00-000Z.bak.txt',
'gallery.yaml.2026-05-31T17-30-00-000Z.bak',
'',
];
for (const name of rejected) {
it(`refuses ${JSON.stringify(name)}`, async () => {
await expect(service.restoreJournalBackup(name)).rejects.toThrow(/unrecognised backup name/i);
});
}
});
describe('backup rotation', () => {
// Backup names carry a millisecond timestamp; saves in a tight loop would
// share one and overwrite each other, which a person clicking Save cannot.
let clock = Date.parse('2026-09-01T00:00:00Z');
beforeEach(() => vi.useFakeTimers({ toFake: ['Date'] }));
afterEach(() => vi.useRealTimers());
const save = async (slug: string, body: string) => {
vi.setSystemTime((clock += 1000));
await service.writeJournalEntry(slug, body);
};
it('prunes save backups but never the deleted snapshot', async () => {
await writeFile(path.join(journalDir, 'trip.md'), entry('Gone'));
await service.deleteJournalEntry('trip');
// Re-create the slug and save it well past the rotation limit.
await save('trip', entry('v0'));
for (let i = 1; i <= 12; i++) {
await save('trip', entry(`v${i}`));
}
const backups = await service.listJournalBackups();
expect(backups.filter((b) => b.kind === 'save')).toHaveLength(10);
expect(backups.filter((b) => b.kind === 'deleted')).toHaveLength(1);
});
it('does not prune another entry whose slug shares a prefix', async () => {
await save('trip-2', entry('Other v0'));
await save('trip-2', entry('Other v1'));
await save('trip', entry('v0'));
for (let i = 1; i <= 12; i++) {
await save('trip', entry(`v${i}`));
}
const other = (await service.listJournalBackups()).filter((b) => b.slug === 'trip-2');
expect(other).toEqual([expect.objectContaining({ kind: 'save' })]);
});
});

View File

@@ -0,0 +1,177 @@
import { describe, it, expect } from 'vitest';
import {
findJpegMetadataRanges,
findIsobmffMetadataRanges,
findMetadataRanges,
stripMetadata,
isStrippable,
} from '../metadataStrip';
// ── kleine Bau-Helfer ───────────────────────────────────────────────────────
const cat = (...parts: Uint8Array[]) => {
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let o = 0;
for (const p of parts) {
out.set(p, o);
o += p.length;
}
return out;
};
const bytes = (...n: number[]) => new Uint8Array(n);
const ascii = (s: string) => new Uint8Array([...s].map((c) => c.charCodeAt(0)));
const be16 = (n: number) => bytes((n >> 8) & 0xff, n & 0xff);
const be32 = (n: number) => bytes((n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff);
/** JPEG-Segment: FFxx <len inkl. Längenfeld> <payload> */
const seg = (marker: number, payload: Uint8Array) =>
cat(bytes(0xff, marker), be16(payload.length + 2), payload);
/** ISOBMFF-Box mit korrekt berechneter Größe. */
const box = (type: string, ...content: Uint8Array[]) => {
const body = cat(...content);
return cat(be32(body.length + 8), ascii(type), body);
};
describe('isStrippable', () => {
it('kennt JPEG und die ISOBMFF-Familie', () => {
expect(isStrippable('image/jpeg')).toBe(true);
expect(isStrippable('IMAGE/AVIF')).toBe(true);
expect(isStrippable('image/jpeg; charset=binary')).toBe(true);
expect(isStrippable('image/png')).toBe(false);
expect(isStrippable('application/octet-stream')).toBe(false);
});
});
describe('JPEG', () => {
const exifPayload = cat(ascii('Exif\0\0'), ascii('MM\0*GPS 54.156, 15.3717'));
const iccPayload = cat(ascii('ICC_PROFILE\0'), bytes(1, 1, 2, 3, 4, 5));
const jfifPayload = cat(ascii('JFIF\0'), bytes(1, 2, 0, 0, 1, 0, 1, 0, 0));
const bildDaten = bytes(0x12, 0x34, 0x56, 0x78, 0x9a);
const bauJpeg = () =>
cat(
bytes(0xff, 0xd8), // SOI
seg(0xe0, jfifPayload), // APP0 JFIF — bleibt
seg(0xe1, exifPayload), // APP1 EXIF — muss weg
seg(0xe2, iccPayload), // APP2 ICC — bleibt (Farbprofil!)
seg(0xed, ascii('Photoshop 3.0\0IPTC')), // APP13 — muss weg
seg(0xfe, ascii('ein Kommentar')), // COM — muss weg
bytes(0xff, 0xda),
be16(8),
bytes(1, 0, 0, 63, 0), // SOS
bildDaten,
bytes(0xff, 0xd9), // EOI
);
it('findet EXIF, IPTC und Kommentar, aber nicht JFIF/ICC', () => {
const b = bauJpeg();
const ranges = findJpegMetadataRanges(b);
expect(ranges).toHaveLength(3);
// Das EXIF-Segment muss vollständig erfasst sein
const exifStart = b.indexOf(0x45); // 'E' von 'Exif'
expect(ranges[0].start).toBe(exifStart);
expect(ranges[0].end - ranges[0].start).toBe(exifPayload.length);
});
it('nullt die GPS-Daten und lässt alles andere unangetastet', () => {
const original = bauJpeg();
const gestrippt = stripMetadata(bauJpeg(), 'image/jpeg')!;
expect(gestrippt).not.toBeNull();
// Länge unverändert -> Content-Length bleibt gültig
expect(gestrippt.length).toBe(original.length);
const alsText = new TextDecoder('latin1').decode(gestrippt);
expect(alsText).not.toContain('GPS 54.156');
expect(alsText).not.toContain('Exif');
expect(alsText).not.toContain('IPTC');
expect(alsText).not.toContain('ein Kommentar');
// Farbprofil und JFIF müssen erhalten bleiben, sonst kippen die Farben
expect(alsText).toContain('ICC_PROFILE');
expect(alsText).toContain('JFIF');
// Bilddaten hinter SOS Byte für Byte identisch
const sos = original.length - bildDaten.length - 2;
expect(Array.from(gestrippt.slice(sos))).toEqual(Array.from(original.slice(sos)));
});
it('weist etwas zurück, das kein JPEG ist', () => {
expect(() => findJpegMetadataRanges(bytes(0x00, 0x01, 0x02, 0x03))).toThrow();
expect(findMetadataRanges(bytes(0x00, 0x01, 0x02, 0x03), 'image/jpeg')).toBeNull();
});
});
describe('AVIF / ISOBMFF', () => {
const exifNutzlast = ascii('MM\0*GEHEIME-GPS-KOORDINATEN');
const bildNutzlast = ascii('AV1-BILDDATEN-UNANTASTBAR');
/** infe v2: version+flags, item_ID(16), protection(16), item_type(4) */
const infe = (id: number, typ: string) =>
box('infe', bytes(2, 0, 0, 0), be16(id), be16(0), ascii(typ), bytes(0));
const bauAvif = () => {
// Erst ohne mdat bauen, um die Offsets zu kennen.
const ftyp = box('ftyp', ascii('avif'), be32(0), ascii('avifmif1'));
const iinf = box('iinf', bytes(0, 0, 0, 0), be16(2), infe(1, 'Exif'), infe(2, 'av01'));
// iloc v0: offset_size=4, length_size=4, base_offset_size=0
const ilocEintrag = (id: number, offset: number, len: number) =>
cat(be16(id), be16(0), be16(1), be32(offset), be32(len));
// Platzhalter, um die Gesamtlänge des Headers zu bestimmen
const ilocRoh = (o1: number, o2: number) =>
box(
'iloc',
bytes(0, 0, 0, 0),
bytes(0x44, 0x00),
be16(2),
ilocEintrag(1, o1, exifNutzlast.length),
ilocEintrag(2, o2, bildNutzlast.length),
);
const kopfLaenge = (iloc: Uint8Array) =>
ftyp.length + box('meta', bytes(0, 0, 0, 0), iinf, iloc).length + 8; // +8 mdat-Header
let iloc = ilocRoh(0, 0);
const basis = kopfLaenge(iloc);
iloc = ilocRoh(basis, basis + exifNutzlast.length);
const meta = box('meta', bytes(0, 0, 0, 0), iinf, iloc);
const mdat = box('mdat', exifNutzlast, bildNutzlast);
return cat(ftyp, meta, mdat);
};
it('findet das Exif-Item über iinf und iloc', () => {
const b = bauAvif();
const ranges = findIsobmffMetadataRanges(b);
expect(ranges).toHaveLength(1);
const gefunden = new TextDecoder('latin1').decode(b.slice(ranges[0].start, ranges[0].end));
expect(gefunden).toBe('MM\0*GEHEIME-GPS-KOORDINATEN');
});
it('nullt EXIF, lässt die Bilddaten und die Länge unberührt', () => {
const original = bauAvif();
const gestrippt = stripMetadata(bauAvif(), 'image/avif')!;
expect(gestrippt.length).toBe(original.length);
const alsText = new TextDecoder('latin1').decode(gestrippt);
expect(alsText).not.toContain('GEHEIME-GPS-KOORDINATEN');
expect(alsText).toContain('AV1-BILDDATEN-UNANTASTBAR');
// Die Container-Struktur muss intakt bleiben
expect(alsText).toContain('ftyp');
expect(alsText).toContain('iloc');
});
it('gibt null zurück, wenn es kein ISOBMFF ist', () => {
expect(findMetadataRanges(ascii('nicht mal ansatzweise'), 'image/avif')).toBeNull();
});
});
describe('unbekannte Formate', () => {
it('liefern null — lieber nicht ausliefern als leaken', () => {
expect(findMetadataRanges(bytes(0x89, 0x50, 0x4e, 0x47), 'image/png')).toBeNull();
expect(stripMetadata(bytes(1, 2, 3), 'application/octet-stream')).toBeNull();
});
});

View File

@@ -168,6 +168,58 @@ describe('assetAspectRatio', () => {
}),
).toBeUndefined();
});
/**
* A portrait frame off a Sony A7 V: the sensor is read out in landscape and
* the quarter turn lives in the orientation flag, so the stored dimensions
* are the displayed ones swapped.
*/
const rotated = (orientation?: string | null) => ({
exifInfo: {
make: null,
model: null,
lensModel: null,
focalLength: null,
fNumber: null,
exposureTime: null,
iso: null,
exifImageWidth: 7008,
exifImageHeight: 4672,
orientation,
latitude: null,
longitude: null,
city: null,
state: null,
country: null,
dateTimeOriginal: null,
description: null,
},
});
it.each([
['6', 'rotated 90 CW'],
['8', 'rotated 90 CCW'],
['5', 'mirrored and rotated 90 CCW'],
['7', 'mirrored and rotated 90 CW'],
])('swaps width and height for orientation %s (%s)', (orientation) => {
expect(assetAspectRatio(rotated(orientation))).toBeCloseTo(4672 / 7008);
});
it.each([
['1', 'upright'],
['2', 'mirrored horizontally'],
['3', 'rotated 180'],
['4', 'mirrored vertically'],
])('leaves the ratio alone for orientation %s (%s)', (orientation) => {
expect(assetAspectRatio(rotated(orientation))).toBeCloseTo(7008 / 4672);
});
it.each([
['null', null],
['absent', undefined],
])('treats a %s orientation as upright', (_label, orientation) => {
expect(assetAspectRatio(rotated(orientation))).toBeCloseTo(7008 / 4672);
});
});
describe('assetCaption', () => {

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

View File

@@ -51,6 +51,13 @@ export interface ImmichExifInfo {
iso: number | null;
exifImageWidth: number | null;
exifImageHeight: number | null;
/**
* EXIF orientation flag, as a numeric string ('1'..'8').
*
* Optional: Immich only fills it when the file carries the tag, and older
* responses omit the field entirely.
*/
orientation?: string | null;
latitude: number | null;
longitude: number | null;
city: string | null;

251
lib/metadataStrip.ts Normal file
View File

@@ -0,0 +1,251 @@
/**
* Removes camera metadata (EXIF, XMP, IPTC) from image bytes on the way out.
*
* Why this exists: `/api/image/:token?size=original` streams the untouched file
* from Immich, and the asset token authorises *the asset, not the size* — so a
* thumbnail token lifted from the public HTML plus `?size=original` returns the
* full original, GPS coordinates and all. `app/api/exif/[id]/route.ts`
* deliberately hands the browser only city and country; the original download
* walked straight past that.
*
* Why zero the bytes instead of cutting them out: the pixel data must stay
* bit-identical — re-encoding would cost quality, which is the whole reason we
* serve originals. Overwriting in place keeps every other byte and the total
* length untouched, so Content-Length stays valid and, crucially for AVIF, the
* absolute offsets in the `iloc` box keep pointing where they did. Removing an
* item would mean rewriting every offset in the container.
*
* A zeroed APPn segment is simply an application segment a decoder does not
* recognise, and decoders skip those. A zeroed ISOBMFF item payload is still
* addressed by `iloc`, but no longer parses as EXIF.
*
* NOT stripped: JPEG APP0 (JFIF) and APP2 (ICC colour profile). Dropping the
* ICC profile would visibly shift colours — that is a rendering change, not a
* privacy one.
*/
/** Half-open byte range [start, end). */
export type ByteRange = { start: number; end: number };
/** Formats whose metadata layout we can locate with confidence. */
export const STRIPPABLE_CONTENT_TYPES = [
'image/jpeg',
'image/avif',
'image/heic',
'image/heif',
] as const;
export function isStrippable(contentType: string): boolean {
const t = contentType.toLowerCase().split(';')[0].trim();
return (STRIPPABLE_CONTENT_TYPES as readonly string[]).includes(t);
}
const u16 = (b: Uint8Array, i: number) => (b[i] << 8) | b[i + 1];
const u32 = (b: Uint8Array, i: number) =>
((b[i] << 24) | (b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3]) >>> 0;
const fourcc = (b: Uint8Array, i: number) =>
String.fromCharCode(b[i], b[i + 1], b[i + 2], b[i + 3]);
/** Read a big-endian unsigned integer of `n` bytes (n <= 8). */
function uint(b: Uint8Array, i: number, n: number): number {
let v = 0;
for (let k = 0; k < n; k++) v = v * 256 + b[i + k];
return v;
}
// ── JPEG ────────────────────────────────────────────────────────────────────
// Segment layout: 0xFF <marker> <2-byte length incl. itself> <payload>.
// Metadata always precedes SOS (0xDA); everything after it is entropy-coded
// image data that must not be touched.
const JPEG_STRIP_MARKERS = new Set([
0xe1, // APP1 — EXIF and XMP
0xed, // APP13 — Photoshop/IPTC
0xfe, // COM — free-text comment
]);
export function findJpegMetadataRanges(b: Uint8Array): ByteRange[] {
if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8) {
throw new Error('not a JPEG (missing SOI)');
}
const ranges: ByteRange[] = [];
let pos = 2;
while (pos + 3 < b.length) {
if (b[pos] !== 0xff) throw new Error(`lost segment alignment at ${pos}`);
const marker = b[pos + 1];
// Standalone markers carry no length field.
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
pos += 2;
continue;
}
if (marker === 0xd9) break; // EOI
if (marker === 0xda) break; // SOS — image data follows
const len = u16(b, pos + 2);
if (len < 2) throw new Error(`bogus segment length ${len} at ${pos}`);
const payloadStart = pos + 4;
const segmentEnd = pos + 2 + len;
if (segmentEnd > b.length) break; // truncated head — stop where we are
if (JPEG_STRIP_MARKERS.has(marker) && segmentEnd > payloadStart) {
ranges.push({ start: payloadStart, end: segmentEnd });
}
pos = segmentEnd;
}
return ranges;
}
// ── ISOBMFF (AVIF / HEIC / HEIF) ────────────────────────────────────────────
// Boxes are <4-byte size><4-byte type>[<8-byte largesize>]<payload>. Metadata
// items live in `meta`: `iinf`/`infe` name them, `iloc` says where their bytes
// are. We zero exactly those byte ranges.
type Box = { type: string; start: number; contentStart: number; end: number };
function readBoxes(b: Uint8Array, from: number, to: number): Box[] {
const boxes: Box[] = [];
let pos = from;
while (pos + 8 <= to) {
let size = u32(b, pos);
let contentStart = pos + 8;
if (size === 1) {
if (pos + 16 > to) break;
size = uint(b, pos + 8, 8);
contentStart = pos + 16;
} else if (size === 0) {
size = to - pos; // extends to the end
}
if (size < 8) break;
const end = Math.min(pos + size, to);
boxes.push({ type: fourcc(b, pos + 4), start: pos, contentStart, end });
if (end <= pos) break;
pos = end;
}
return boxes;
}
/** item_ID -> item_type, from the `iinf` box. */
function readItemTypes(b: Uint8Array, iinf: Box): Map<number, string> {
const types = new Map<number, string>();
const version = b[iinf.contentStart];
let pos = iinf.contentStart + 4; // version + flags
if (version === 0) pos += 2;
else pos += 4; // entry_count
for (const infe of readBoxes(b, pos, iinf.end)) {
if (infe.type !== 'infe') continue;
const v = b[infe.contentStart];
let p = infe.contentStart + 4;
if (v < 2) continue; // versions 0/1 predate item_type
const itemId = v === 2 ? u16(b, p) : u32(b, p);
p += v === 2 ? 2 : 4;
p += 2; // item_protection_index
types.set(itemId, fourcc(b, p));
}
return types;
}
/** item_ID -> byte ranges, from the `iloc` box. */
function readItemLocations(b: Uint8Array, iloc: Box): Map<number, ByteRange[]> {
const out = new Map<number, ByteRange[]>();
const version = b[iloc.contentStart];
let p = iloc.contentStart + 4; // version + flags
const offsetSize = b[p] >> 4;
const lengthSize = b[p] & 0x0f;
const baseOffsetSize = b[p + 1] >> 4;
const indexSize = version === 1 || version === 2 ? b[p + 1] & 0x0f : 0;
p += 2;
const itemCount = version < 2 ? u16(b, p) : u32(b, p);
p += version < 2 ? 2 : 4;
for (let i = 0; i < itemCount && p < iloc.end; i++) {
const itemId = version < 2 ? u16(b, p) : u32(b, p);
p += version < 2 ? 2 : 4;
if (version === 1 || version === 2) p += 2; // construction_method
p += 2; // data_reference_index
const baseOffset = baseOffsetSize ? uint(b, p, baseOffsetSize) : 0;
p += baseOffsetSize;
const extentCount = u16(b, p);
p += 2;
const ranges: ByteRange[] = [];
for (let e = 0; e < extentCount; e++) {
if ((version === 1 || version === 2) && indexSize) p += indexSize;
const extentOffset = offsetSize ? uint(b, p, offsetSize) : 0;
p += offsetSize;
const extentLength = lengthSize ? uint(b, p, lengthSize) : 0;
p += lengthSize;
if (extentLength > 0) {
const start = baseOffset + extentOffset;
ranges.push({ start, end: start + extentLength });
}
}
if (ranges.length) out.set(itemId, ranges);
}
return out;
}
export function findIsobmffMetadataRanges(b: Uint8Array): ByteRange[] {
const top = readBoxes(b, 0, b.length);
if (!top.some((x) => x.type === 'ftyp')) throw new Error('not ISOBMFF (no ftyp)');
const meta = top.find((x) => x.type === 'meta');
if (!meta) return []; // no metadata box at all — nothing to strip
// `meta` is a FullBox: skip version + flags before its children.
const children = readBoxes(b, meta.contentStart + 4, meta.end);
const iinf = children.find((x) => x.type === 'iinf');
const iloc = children.find((x) => x.type === 'iloc');
if (!iinf || !iloc) return [];
const types = readItemTypes(b, iinf);
const locations = readItemLocations(b, iloc);
const ranges: ByteRange[] = [];
for (const [itemId, itemType] of types) {
// 'Exif' is EXIF; 'mime' items are XMP in every AVIF/HEIC writer we see.
if (itemType !== 'Exif' && itemType !== 'mime') continue;
for (const r of locations.get(itemId) ?? []) ranges.push(r);
}
return ranges;
}
// ── Entry point ─────────────────────────────────────────────────────────────
/**
* Byte ranges to blank out, or null when the format is not one we understand.
* Callers must treat null as "do not serve this original" — silently passing it
* through is what caused the leak in the first place.
*/
export function findMetadataRanges(b: Uint8Array, contentType: string): ByteRange[] | null {
const t = contentType.toLowerCase().split(';')[0].trim();
try {
if (t === 'image/jpeg') return findJpegMetadataRanges(b);
if (t === 'image/avif' || t === 'image/heic' || t === 'image/heif') {
return findIsobmffMetadataRanges(b);
}
} catch {
return null; // malformed or unexpected layout — refuse rather than leak
}
return null;
}
/** Overwrite `ranges` with zeroes. Mutates and returns `b`. */
export function blankRanges(b: Uint8Array, ranges: ByteRange[]): Uint8Array {
for (const { start, end } of ranges) {
const from = Math.max(0, Math.min(start, b.length));
const to = Math.max(0, Math.min(end, b.length));
if (to > from) b.fill(0, from, to);
}
return b;
}
/** Convenience for buffered callers and tests. */
export function stripMetadata(b: Uint8Array, contentType: string): Uint8Array | null {
const ranges = findMetadataRanges(b, contentType);
if (ranges === null) return null;
return blankRanges(b, ranges);
}

View File

@@ -128,12 +128,30 @@ export function assetCaption(
}
/**
* Compute the natural aspect ratio (width / height) from EXIF dimensions.
* EXIF orientations that rotate the frame a quarter turn.
*
* Most cameras expose the sensor in landscape whatever way the body is held,
* and record the rotation as a flag rather than rewriting the pixels. So a
* portrait frame arrives as 7008x4672 with orientation 6, and the browser turns
* it upright on display. Taking the stored dimensions at face value would call
* that photo landscape.
*
* 1-4 are upright or mirrored in place and leave the ratio alone; 5-8 all carry
* a 90 degree turn, so width and height swap.
*/
const ROTATED_ORIENTATIONS = new Set([5, 6, 7, 8]);
/**
* Compute the natural aspect ratio (width / height) from EXIF dimensions,
* as the image is displayed rather than as it is stored.
* Returns undefined if dimensions are not available.
*/
export function assetAspectRatio(asset: Pick<ImmichAsset, 'exifInfo'>): number | undefined {
const w = asset.exifInfo?.exifImageWidth;
const h = asset.exifInfo?.exifImageHeight;
if (w && h && h > 0) return w / h;
return undefined;
if (!w || !h || h <= 0) return undefined;
// Immich reports the flag as a string; anything unparseable is left upright.
const orientation = Number(asset.exifInfo?.orientation);
return ROTATED_ORIENTATIONS.has(orientation) ? h / w : w / h;
}