release: v0.20.0

This commit is contained in:
Ralksta
2026-10-02 18:14:23 +02:00
committed by GitHub
parent 81ce886b01
commit 5f831f719a
209 changed files with 13807 additions and 803 deletions

View File

@@ -0,0 +1,148 @@
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
import {
THEME_PRESETS,
resolveTheme,
accentForMode,
accentText,
contrastRatio,
} from '../config/theme';
/**
* Accent-coloured text (`--accent-text`), input borders and the draft badge
* are checked against every hex surface token of each preset and mode, read
* from app/tokens.css — the studio-modern red measured 3.66:1 as text on its
* own dark page, and the input hairlines 1.15–1.29:1.
*/
const root = process.cwd();
const tokens = fs.readFileSync(path.join(root, 'app', 'tokens.css'), 'utf8');
function block(selector: string): string {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`(?:^|\\n)${escaped}\\s*\\{([^}]*)\\}`).exec(tokens)?.[1] ?? '';
}
function tokenIn(css: string, name: string): string | undefined {
return new RegExp(`${name}:\\s*(#[0-9a-fA-F]{3,6})\\b`).exec(css)?.[1];
}
type Mode = 'dark' | 'light';
const SURFACES = ['--bg-primary', '--bg-secondary', '--bg-card', '--bg-card-hover'];
/** A preset's own value for a token, falling back to the mode's defaults. */
function token(preset: string, mode: Mode, name: string): string {
const own =
mode === 'dark'
? tokenIn(block(`[data-preset='${preset}']`), name)
: tokenIn(block(`[data-preset='${preset}'][data-theme='light']`), name);
const fallback = tokenIn(block(mode === 'dark' ? ':root' : "[data-theme='light']"), name);
const value = own ?? fallback;
if (!value) throw new Error(`${name} missing for ${preset}/${mode}`);
return value;
}
const MODES: Mode[] = ['dark', 'light'];
describe('accentText', () => {
for (const name of Object.keys(THEME_PRESETS)) {
for (const mode of MODES) {
it(`${name} (${mode}): accent text reaches 4.5:1 on every surface`, () => {
const text = accentText(accentForMode(resolveTheme(name), mode), mode);
for (const surface of SURFACES) {
expect(contrastRatio(text, token(name, mode, surface))!).toBeGreaterThanOrEqual(4.5);
}
});
}
}
it('lifts the studio-modern red on dark and darkens it on light', () => {
// #e60012 is 3.66:1 on #121212 — the reported failure.
expect(contrastRatio('#e60012', '#121212')!).toBeLessThan(4.5);
const dark = accentText('#e60012', 'dark');
const light = accentText('#e60012', 'light');
expect(dark).not.toBe('#e60012');
expect(light).not.toBe('#e60012');
expect(contrastRatio(dark, '#121212')!).toBeGreaterThanOrEqual(4.5);
expect(contrastRatio(light, '#fafaf8')!).toBeGreaterThanOrEqual(4.5);
});
it('keeps an accent that already passes', () => {
expect(accentText('#ffffff', 'dark')).toBe('#ffffff');
expect(accentText('#000000', 'light')).toBe('#000000');
expect(accentText('#c8c8c8', 'dark')).toBe('#c8c8c8');
});
it('reaches AA for any owner accent, including ones near the page colour', () => {
const samples = ['#000000', '#0a1f44', '#1d4ed8', '#ffea00', '#ffffff', '#808080', '#6366f1'];
for (const accent of samples) {
expect(contrastRatio(accentText(accent, 'dark'), '#262626')!).toBeGreaterThanOrEqual(4.5);
expect(contrastRatio(accentText(accent, 'light'), '#e8e8e3')!).toBeGreaterThanOrEqual(4.5);
}
});
it('widens short hex and passes non-hex through', () => {
expect(accentText('#fff', 'dark')).toBe('#ffffff');
expect(accentText('red', 'dark')).toBe('red');
});
it('matches the fallbacks written into tokens.css', () => {
expect(tokens).toContain(
`--accent-text: var(--accent-text-dark, ${accentText('#e60012', 'dark')})`,
);
expect(tokens).toContain(
`--accent-text: var(--accent-text-light, ${accentText('#c8000f', 'light')})`,
);
});
});
describe('public styles use the text token for accent-coloured text', () => {
const files = [
'app/globals.css',
'app/leaflet.css',
'app/journal/journal.css',
'app/contact/contact.css',
...fs.readdirSync(path.join(root, 'app', 'themes')).map((f) => `app/themes/${f}`),
...fs
.readdirSync(path.join(root, 'components'))
.filter((f) => f.endsWith('.module.css'))
.map((f) => `components/${f}`),
];
for (const file of files) {
it(`${file} has no text coloured with the raw accent`, () => {
const css = fs.readFileSync(path.join(root, file), 'utf8');
expect(css).not.toMatch(/(^|[^-])color:\s*var\(--(sm-)?accent[,)]/m);
});
}
});
describe('mode tokens', () => {
it('declares a color-scheme per mode', () => {
expect(block(':root')).toMatch(/color-scheme:\s*dark/);
expect(block("[data-theme='light']")).toMatch(/color-scheme:\s*light/);
});
it('input borders reach 3:1 on the page surfaces (WCAG 1.4.11)', () => {
expect(block(':root')).toMatch(/--input-border:\s*var\(--text-muted\)/);
for (const name of Object.keys(THEME_PRESETS)) {
for (const mode of MODES) {
const border = token(name, mode, '--text-muted');
for (const surface of ['--bg-primary', '--bg-secondary']) {
expect(contrastRatio(border, token(name, mode, surface))!).toBeGreaterThanOrEqual(3);
}
}
}
});
it('the warning colour reads at 4.5:1 on every surface', () => {
for (const name of Object.keys(THEME_PRESETS)) {
for (const mode of MODES) {
const warning = token(name, mode, '--warning');
for (const surface of SURFACES) {
expect(contrastRatio(warning, token(name, mode, surface))!).toBeGreaterThanOrEqual(4.5);
}
}
}
});
});

View File

@@ -0,0 +1,139 @@
import { describe, it, expect, vi } from 'vitest';
/**
* isAlbumZoomReachable (#467): the zoom route's gate. A route to the album the
* visitor could have taken, with every password on it given, on which zoom
* resolves on — asked per route, not as two separate questions.
*/
const ID = (n: number) => `00000000-0000-0000-0000-00000000000${n}`;
const config = vi.hoisted(() => ({
immich: { apiKey: 'k' },
authSecret: 'test-auth-secret-32-chars-long-min',
zoom: false,
standaloneAlbumZoom: {} as Record<string, boolean>,
standaloneAlbums: [] as string[],
albumPasswords: {} as Record<string, string>,
subpages: [] as Array<Record<string, unknown>>,
}));
vi.mock('@/lib/config', () => ({ getConfig: () => config }));
import { authenticate, isAlbumZoomReachable } from '@/lib/auth';
const none = () => undefined;
async function cookieFor(key: string, password: string, type: 'subpage' | 'album' = 'subpage') {
const header = (await authenticate(key, password, type))!;
const [name, rest] = header.split('=');
const token = rest.split(';')[0];
return (asked: string) => (asked === name ? token : undefined);
}
function setup(partial: Partial<typeof config>) {
Object.assign(config, {
zoom: false,
standaloneAlbumZoom: {},
standaloneAlbums: [],
albumPasswords: {},
subpages: [],
...partial,
});
}
describe('isAlbumZoomReachable', () => {
it('is off by default', () => {
setup({ standaloneAlbums: [ID(1)] });
expect(isAlbumZoomReachable(ID(1), none)).toBe(false);
});
it('follows the site setting for a standalone album', () => {
setup({ zoom: true, standaloneAlbums: [ID(1)] });
expect(isAlbumZoomReachable(ID(1), none)).toBe(true);
});
it('refuses an album that is not published anywhere', () => {
setup({ zoom: true });
expect(isAlbumZoomReachable(ID(1), none)).toBe(false);
});
it('lets an album switch it off against the site', () => {
setup({ zoom: true, standaloneAlbums: [ID(1)], standaloneAlbumZoom: { [ID(1)]: false } });
expect(isAlbumZoomReachable(ID(1), none)).toBe(false);
});
it('lets a subpage switch it on for its albums', () => {
setup({ subpages: [{ name: 'S', slug: 's', albumIds: [ID(1)], zoom: true }] });
expect(isAlbumZoomReachable(ID(1), none)).toBe(true);
});
it('serves a hidden (link-only) subpage like any other', () => {
setup({ subpages: [{ name: 'S', slug: 's', albumIds: [ID(1)], zoom: true, hidden: true }] });
expect(isAlbumZoomReachable(ID(1), none)).toBe(true);
});
it('refuses an album whose only subpage is offline', () => {
setup({
zoom: true,
subpages: [{ name: 'S', slug: 's', albumIds: [ID(1)], enabled: false }],
});
expect(isAlbumZoomReachable(ID(1), none)).toBe(false);
});
it('asks for the subpage password', async () => {
setup({
zoom: true,
subpages: [{ name: 'S', slug: 's', albumIds: [ID(1)], password: 'pw' }],
});
expect(isAlbumZoomReachable(ID(1), none)).toBe(false);
expect(isAlbumZoomReachable(ID(1), await cookieFor('s', 'pw'))).toBe(true);
});
it('asks for the album’s own password as well', async () => {
setup({ zoom: true, standaloneAlbums: [ID(1)], albumPasswords: { [ID(1)]: 'apw' } });
expect(isAlbumZoomReachable(ID(1), none)).toBe(false);
expect(isAlbumZoomReachable(ID(1), await cookieFor(ID(1), 'apw', 'album'))).toBe(true);
});
it('reads the album entry’s zoom on the route it is written on (review of #830)', async () => {
// Open page: album entry says off. Password page: album entry says on.
setup({
subpages: [
{ name: 'Public', slug: 'public', albumIds: [ID(1)], albumZoom: { [ID(1)]: false } },
{
name: 'Client',
slug: 'client',
albumIds: [ID(1)],
password: 'pw',
albumZoom: { [ID(1)]: true },
},
],
});
expect(isAlbumZoomReachable(ID(1), none)).toBe(false);
expect(isAlbumZoomReachable(ID(1), await cookieFor('client', 'pw'))).toBe(true);
});
it('answers for an essay subpage’s albums like for any other page', () => {
// Their album page, /<subpage>/<album>, offers zoom by the same rule.
setup({
subpages: [
{ name: 'E', slug: 'e', albumIds: [ID(1)], zoom: true, grid: { layout: 'essay' } },
],
});
expect(isAlbumZoomReachable(ID(1), none)).toBe(true);
});
it('does not combine an open route without zoom with a locked one that has it', async () => {
// Open subpage, zoom off; locked subpage, zoom on. A visitor who has only
// been through the open one gets no zoom.
setup({
subpages: [
{ name: 'Open', slug: 'open', albumIds: [ID(1)], zoom: false },
{ name: 'Locked', slug: 'locked', albumIds: [ID(1)], zoom: true, password: 'pw' },
],
});
expect(isAlbumZoomReachable(ID(1), none)).toBe(false);
expect(isAlbumZoomReachable(ID(1), await cookieFor('locked', 'pw'))).toBe(true);
});
});

View File

@@ -0,0 +1,121 @@
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
/**
* A Docker build from a checkout used to bake the checkout's content/ into the
* image: `COPY . .` took it into the build stage, the standalone trace copied
* it along, and the runner copied the whole directory. At runtime the mounted
* volume hides it, but the layers still hold gallery.yaml, install.json (the
* Immich API key), contact messages, proofing links and every backup — and
* `docker save` or a push hands them out.
*
* Three places keep it out now: .dockerignore, the builder dropping the traced
* copy, and the runner copying the templates by name. These tests pin all
* three; the matcher below follows Docker's .dockerignore rules (last match
* wins, `!` re-includes, a pattern that matches a directory covers everything
* below it).
*/
const ROOT = path.join(__dirname, '..', '..');
const dockerignore = fs.readFileSync(path.join(ROOT, '.dockerignore'), 'utf8');
const dockerfile = fs.readFileSync(path.join(ROOT, 'Dockerfile'), 'utf8');
type Rule = { negate: boolean; re: RegExp };
function toRegExp(pattern: string): RegExp {
let out = '';
for (let i = 0; i < pattern.length; i++) {
const c = pattern[i];
if (c === '*' && pattern[i + 1] === '*') {
out += '.*';
i++;
if (pattern[i + 1] === '/') i++;
} else if (c === '*') out += '[^/]*';
else if (c === '?') out += '[^/]';
else out += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
}
return new RegExp(`^${out}$`);
}
const rules: Rule[] = dockerignore
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'))
.map((line) => {
const negate = line.startsWith('!');
const pattern = path.posix.normalize((negate ? line.slice(1) : line).replace(/^\/+/, ''));
return { negate, re: toRegExp(pattern.replace(/\/$/, '')) };
});
/** Whether Docker leaves `file` out of the build context. */
function ignored(file: string): boolean {
const parts = file.split('/');
const candidates = parts.map((_, i) => parts.slice(0, i + 1).join('/'));
let result = false;
for (const rule of rules) {
if (candidates.some((candidate) => rule.re.test(candidate))) result = !rule.negate;
}
return result;
}
/** The part of the Dockerfile after `FROM … AS <stage>`, up to the next FROM. */
function stage(name: string): string {
const match = dockerfile.match(
new RegExp(`^FROM .* AS ${name}\\n([\\s\\S]*?)(?=^FROM |(?![\\s\\S]))`, 'm'),
);
return match?.[1] ?? '';
}
describe('content/ stays out of the Docker image', () => {
it.each([
'content/gallery.yaml',
'content/settings.yaml',
'content/about.md',
'content/privacy.md',
'content/install.json',
'content/.setup-token',
'content/proofing.json',
'content/analytics.json',
'content/favicon.svg',
'content/messages/1727640000000-abc.json',
'content/pages/prices.md',
'content/journal/iceland.md',
'content/essays/old.md',
'content/.backups/gallery.yaml.2026-09-29T10-00-00.bak',
'content/journal/.backups/iceland.md.bak',
'content/.fonts/inter.woff2',
'content/gallery.yaml.screenshot-bak',
])('leaves %s out of the build context', (file) => {
expect(ignored(file)).toBe(true);
});
it('keeps every tracked template in the build context', () => {
const templates = [
...fs.readdirSync(path.join(ROOT, 'content')).map((f) => `content/${f}`),
...fs.readdirSync(path.join(ROOT, 'content', 'journal')).map((f) => `content/journal/${f}`),
].filter((f) => f.endsWith('.example'));
expect(templates).toContain('content/gallery.yaml.example');
expect(templates.filter(ignored)).toEqual([]);
});
it('still sends the source the build needs', () => {
expect(ignored('app/layout.tsx')).toBe(false);
expect(ignored('lib/config/index.ts')).toBe(false);
expect(ignored('package.json')).toBe(false);
});
it('drops the traced copy of content/ from the standalone output', () => {
expect(stage('builder')).toMatch(/^RUN rm -rf \.next\/standalone\/content$/m);
});
it('copies only the templates into the runtime content/', () => {
const runner = stage('runner');
expect(runner).not.toBe('');
const contentCopies = runner.split('\n').filter((line) => /^COPY .*content/.test(line));
expect(contentCopies.length).toBeGreaterThan(0);
for (const line of contentCopies) {
expect(line).toMatch(/\/app\/content\/(journal\/)?\*\.example /);
}
});
});

View File

@@ -15,6 +15,8 @@ import {
checkPrivacy,
checkContentPages,
checkSettingValues,
checkDownloadMetadata,
checkZoomRenditions,
worstLevel,
} from '../admin/doctor';
@@ -499,5 +501,83 @@ describe('checkSettingValues (QA A-14)', () => {
expect(checkSettingValues([{ field: 'url' }]).settingsSection).toBe('seo');
expect(checkSettingValues([{ field: 'grid.gap' }]).settingsSection).toBe('grid');
expect(checkSettingValues([{ field: 'theme.accent' }]).settingsSection).toBe('theme');
expect(checkSettingValues([{ field: 'theme.radius' }]).settingsSection).toBe('theme');
expect(checkSettingValues([{ field: 'contact.retentionDays' }]).settingsSection).toBe('legal');
expect(checkSettingValues([{ field: 'navLinks.3.url' }]).settingsSection).toBe('footer');
});
it('counts the header links as one setting however many entries fail', () => {
const f = checkSettingValues([
{ field: 'navLinks.0.label' },
{ field: 'navLinks.0.url' },
{ field: 'navLinks.2.url' },
]);
expect(f.title).toBe('The header links setting is ignored');
});
});
describe('checkDownloadMetadata', () => {
const album = (id: string, uncleanable: number, uncleanableWithLocation = 0) => ({
id,
albumName: `Album ${id}`,
uncleanable,
uncleanableWithLocation,
});
it('says nothing while no album offers downloads', () => {
expect(checkDownloadMetadata([])).toBeNull();
});
it('is ok when every original can be cleaned', () => {
const f = checkDownloadMetadata([album('a', 0), album('b', 0)])!;
expect(f.level).toBe('ok');
expect(f.id).toBe('download-metadata');
expect(f.detail).toContain('2 download albums');
});
it('warns about originals served with all their metadata, and names the albums', () => {
const f = checkDownloadMetadata([album('a', 0), album('b', 3, 2), album('c', 1)])!;
expect(f.level).toBe('warn');
expect(f.title).toBe('4 downloadable originals keep all metadata');
expect(f.detail).toContain('GPS');
expect(f.detail).toContain('Immich has a location for 2 of them');
expect(f.detail).toContain('Album b (3)');
expect(f.albumIds).toEqual(['b', 'c']);
});
it('leaves out the location count when Immich knows none', () => {
const f = checkDownloadMetadata([album('a', 1)])!;
expect(f.title).toBe('1 downloadable original keeps all metadata');
expect(f.detail).not.toContain('Immich has a location');
});
});
describe('checkZoomRenditions (#467)', () => {
const album = (id: string, needRendition: number) => ({ id, albumName: id, needRendition });
it('says nothing while no zoom album needs a rendition, or nothing is known', () => {
expect(checkZoomRenditions([], 'missing')).toBeNull();
expect(checkZoomRenditions([album('a', 0)], 'missing')).toBeNull();
expect(checkZoomRenditions([album('a', 3)], null)).toBeNull();
});
it('is fine when Immich has JPEG renditions', () => {
expect(checkZoomRenditions([album('a', 3)], 'ok')?.level).toBe('ok');
});
it('warns, naming the albums, when Immich has none', () => {
const finding = checkZoomRenditions([album('a', 3), album('b', 0), album('c', 1)], 'missing')!;
expect(finding.level).toBe('warn');
expect(finding.title).toMatch(/^4 photos cannot be zoomed/);
expect(finding.detail).toContain('Full-size');
expect(finding.albumIds).toEqual(['a', 'c']);
});
it('warns about a WebP rendition, which the zoom route refuses (review of #830)', () => {
const finding = checkZoomRenditions([album('a', 2)], { contentType: 'image/webp' })!;
expect(finding.level).toBe('warn');
expect(finding.title).toMatch(/not JPEG/);
expect(finding.detail).toContain('image/webp');
expect(finding.detail).toMatch(/Format to JPEG/);
});
});

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { safeDownloadName, contentDisposition } from '@/lib/downloadName';
import { safeDownloadName, contentDisposition, editedDownloadName } from '@/lib/downloadName';
const LF = String.fromCharCode(10);
const CRLF = String.fromCharCode(13, 10);
@@ -85,3 +85,27 @@ describe('contentDisposition', () => {
expect(contentDisposition(undefined)).toContain('filename="photo"');
});
});
describe('editedDownloadName (#831)', () => {
it('names an edited HEIC after the JPEG Immich renders from it', () => {
expect(editedDownloadName('IMG_9262.HEIC', 'image/jpeg')).toBe('IMG_9262.jpg');
expect(editedDownloadName('DSC_0001.NEF', 'image/jpeg; charset=binary')).toBe('DSC_0001.jpg');
});
it('keeps a name that already fits', () => {
expect(editedDownloadName('scan.jpeg', 'image/jpeg')).toBe('scan.jpeg');
expect(editedDownloadName('IMG_0001.JPG', 'image/jpeg')).toBe('IMG_0001.JPG');
expect(editedDownloadName('shot.avif', 'image/avif')).toBe('shot.avif');
});
it('adds an extension to a name without one', () => {
expect(editedDownloadName('scan', 'image/jpeg')).toBe('scan.jpg');
expect(editedDownloadName('.hidden', 'image/jpeg')).toBe('.hidden.jpg');
});
it('leaves the name alone for a type it does not know', () => {
expect(editedDownloadName('clip.mov', 'video/quicktime')).toBe('clip.mov');
expect(editedDownloadName('IMG_1.HEIC', null)).toBe('IMG_1.HEIC');
expect(editedDownloadName(undefined, 'image/jpeg')).toBeUndefined();
});
});

View File

@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest';
import { formatLens } from '../exif';
import { assetExifSummary } from '../urls';
import type { ImmichAsset } from '../immich';
/**
* Phones report the device as the lens, so the tile overlay and the lightbox
* strip read "IPHONE 14 PRO · IPHONE 14 PRO BACK TRIPLE CAMERA 6.86MM F/1.78".
*/
describe('formatLens', () => {
it('drops a camera model the lens repeats', () => {
expect(formatLens('iPhone 14 Pro back triple camera 6.86mm f/1.78', 'iPhone 14 Pro')).toBe(
'back triple camera 6.86mm f/1.78',
);
expect(formatLens('IPHONE 14 PRO back camera', 'iPhone 14 Pro')).toBe('back camera');
});
it('removes the model anywhere in the lens, as a whole phrase', () => {
expect(formatLens('Wide iPhone 14 Pro 24mm', 'iPhone 14 Pro')).toBe('Wide 24mm');
});
it('does not match inside another word', () => {
expect(formatLens('Pixel 7 Pro camera', 'Pixel 7 P')).toBe('Pixel 7 Pro camera');
expect(formatLens('XF23mmF1.4 R', 'X')).toBe('XF23mmF1.4 R');
});
it('leaves a separate lens alone', () => {
expect(formatLens('RF24-70mm F2.8 L IS USM', 'Canon EOS R6')).toBe('RF24-70mm F2.8 L IS USM');
});
it('empties a lens that is only the model', () => {
expect(formatLens('iPhone 14 Pro', 'iPhone 14 Pro')).toBe('');
});
it('keeps the lens when there is no camera to repeat', () => {
expect(formatLens('iPhone 14 Pro back camera', null)).toBe('iPhone 14 Pro back camera');
expect(formatLens(null, 'iPhone 14 Pro')).toBe('');
});
it('treats regex characters in the model literally', () => {
expect(formatLens('Model (X) lens', 'Model (X)')).toBe('lens');
expect(formatLens('Model X lens', 'Model (X)')).toBe('Model X lens');
});
});
describe('assetExifSummary', () => {
const asset = (exif: Record<string, unknown>) =>
({ exifInfo: exif }) as unknown as Pick<ImmichAsset, 'exifInfo'>;
it('does not repeat the camera in the tile lens', () => {
const summary = assetExifSummary(
asset({
model: 'iPhone 14 Pro',
lensModel: 'iPhone 14 Pro back triple camera 6.86mm f/1.78',
focalLength: 6.86,
}),
);
expect(summary).toEqual({
camera: 'iPhone 14 Pro',
lens: 'back triple camera 6.86mm f/1.78',
focalLength: '6.86mm',
});
});
it('omits a lens that only names the camera', () => {
const summary = assetExifSummary(asset({ model: 'iPhone 14 Pro', lensModel: 'iPhone 14 Pro' }));
expect(summary).toEqual({ camera: 'iPhone 14 Pro', lens: undefined, focalLength: undefined });
});
});

View File

@@ -0,0 +1,492 @@
/**
* Builders for real-world-shaped metadata fixtures: TIFF/EXIF with a GPS IFD
* in either byte order, XMP packets, JPEGs and HEIF files. Shared by
* lib/__tests__/locationScrub.test.ts and the download route tests.
*/
export 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;
};
export const bytes = (...n: number[]) => new Uint8Array(n);
export const ascii = (s: string) => new Uint8Array([...s].map((c) => c.charCodeAt(0)));
export const utf8 = (s: string) => new TextEncoder().encode(s);
export const be16 = (n: number) => bytes((n >> 8) & 0xff, n & 0xff);
export const be32 = (n: number) =>
bytes((n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff);
/** Index of `needle` in `hay`, or -1. */
export function indexOf(hay: Uint8Array, needle: Uint8Array, from = 0): number {
outer: for (let i = from; i + needle.length <= hay.length; i++) {
for (let k = 0; k < needle.length; k++) if (hay[i + k] !== needle[k]) continue outer;
return i;
}
return -1;
}
export const contains = (hay: Uint8Array, needle: Uint8Array) => indexOf(hay, needle) >= 0;
export const latin1 = (b: Uint8Array) => Buffer.from(b).toString('latin1');
// ── TIFF ─────────────────────────────────────────────────────────────────────
export const TYPE = { BYTE: 1, ASCII: 2, SHORT: 3, LONG: 4, RATIONAL: 5, UNDEFINED: 7 } as const;
const TYPE_SIZE: Record<number, number> = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 7: 1 };
export type TiffEntry =
| { tag: number; type: number; count: number; value: (le: boolean) => Uint8Array }
| { tag: number; ifd: string };
export interface TiffIfd {
name: string;
entries: TiffEntry[];
next?: string;
}
const u16 = (le: boolean, n: number) => (le ? bytes(n & 0xff, (n >> 8) & 0xff) : be16(n));
const u32 = (le: boolean, n: number) =>
le ? bytes(n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff) : be32(n);
export const asciiValue = (s: string) => ({
type: TYPE.ASCII,
count: s.length + 1,
value: () => ascii(`${s}\0`),
});
export const shortValue = (...n: number[]) => ({
type: TYPE.SHORT,
count: n.length,
value: (le: boolean) => cat(...n.map((v) => u16(le, v))),
});
export const byteValue = (...n: number[]) => ({
type: TYPE.BYTE,
count: n.length,
value: () => bytes(...n),
});
export const rationalValue = (...pairs: Array<[number, number]>) => ({
type: TYPE.RATIONAL,
count: pairs.length,
value: (le: boolean) => cat(...pairs.flatMap(([a, b]) => [u32(le, a), u32(le, b)])),
});
export const undefinedValue = (data: Uint8Array) => ({
type: TYPE.UNDEFINED,
count: data.length,
value: () => data,
});
/** Lay out IFDs after the header, out-of-line values after the IFDs. */
export function buildTiff(le: boolean, ifds: TiffIfd[]): Uint8Array {
const offsets = new Map<string, number>();
let pos = 8;
for (const ifd of ifds) {
offsets.set(ifd.name, pos);
pos += 2 + ifd.entries.length * 12 + 4;
}
const data: Uint8Array[] = [];
let dataPos = pos;
const tables: Uint8Array[] = [];
for (const ifd of ifds) {
const parts: Uint8Array[] = [u16(le, ifd.entries.length)];
for (const e of ifd.entries) {
if ('ifd' in e) {
parts.push(u16(le, e.tag), u16(le, TYPE.LONG), u32(le, 1), u32(le, offsets.get(e.ifd)!));
continue;
}
const value = e.value(le);
expectLength(value.length, TYPE_SIZE[e.type] * e.count);
parts.push(u16(le, e.tag), u16(le, e.type), u32(le, e.count));
if (value.length <= 4) {
const inline = new Uint8Array(4);
inline.set(value);
parts.push(inline);
} else {
parts.push(u32(le, dataPos));
data.push(value);
dataPos += value.length;
if (value.length % 2) {
data.push(bytes(0));
dataPos++;
}
}
}
parts.push(u32(le, ifd.next ? offsets.get(ifd.next)! : 0));
tables.push(cat(...parts));
}
const header = le
? cat(ascii('II'), bytes(42, 0), u32(le, 8))
: cat(ascii('MM'), bytes(0, 42), u32(le, 8));
return cat(header, ...tables, ...data);
}
function expectLength(actual: number, expected: number) {
if (actual !== expected)
throw new Error(`fixture value is ${actual} bytes, expected ${expected}`);
}
export const LAT = rationalValue([52, 1], [31, 1], [1234, 100]);
export const LON = rationalValue([13, 1], [24, 1], [5678, 100]);
export const ALT = rationalValue([3456, 100]);
/** A camera EXIF block: IFD0, EXIF IFD, GPS IFD and an IFD1. */
export function cameraTiff(le: boolean, opts: { withGps?: boolean; xmp?: Uint8Array } = {}) {
const withGps = opts.withGps ?? true;
const ifd0: TiffEntry[] = [
{ tag: 0x010f, ...asciiValue('Canon') },
{ tag: 0x0110, ...asciiValue('Canon EOS R5') },
{ tag: 0x0112, ...shortValue(1) },
];
if (opts.xmp) ifd0.push({ tag: 0x02bc, ...undefinedValue(opts.xmp) });
ifd0.push({ tag: 0x8298, ...asciiValue('(c) Jane Doe') });
ifd0.push({ tag: 0x8769, ifd: 'exif' });
if (withGps) ifd0.push({ tag: 0x8825, ifd: 'gps' });
const ifds: TiffIfd[] = [
{ name: 'ifd0', entries: ifd0, next: 'ifd1' },
{
name: 'exif',
entries: [
{ tag: 0x829a, ...rationalValue([1, 250]) },
{ tag: 0x829d, ...rationalValue([28, 10]) },
{ tag: 0x8827, ...shortValue(100) },
{ tag: 0xa434, ...asciiValue('RF24-70mm F2.8 L IS USM') },
],
},
];
if (withGps) {
ifds.push({
name: 'gps',
entries: [
{ tag: 0x0000, ...byteValue(2, 3, 0, 0) },
{ tag: 0x0001, ...asciiValue('N') },
{ tag: 0x0002, ...LAT },
{ tag: 0x0003, ...asciiValue('E') },
{ tag: 0x0004, ...LON },
{ tag: 0x0005, ...byteValue(0) },
{ tag: 0x0006, ...ALT },
],
});
}
ifds.push({ name: 'ifd1', entries: [{ tag: 0x0103, ...shortValue(6) }] });
return buildTiff(le, ifds);
}
export interface ParsedEntry {
type: number;
count: number;
value: Uint8Array;
}
export interface ParsedTiff {
ifd0: Map<number, ParsedEntry>;
exif: Map<number, ParsedEntry> | null;
gps: Map<number, ParsedEntry> | null;
ifd1: Map<number, ParsedEntry> | null;
}
/** A small, strict TIFF reader standing in for exiftool. */
export function parseTiff(t: Uint8Array): ParsedTiff {
const le = t[0] === 0x49;
const r16 = (o: number) => (le ? t[o] | (t[o + 1] << 8) : (t[o] << 8) | t[o + 1]);
const r32 = (o: number) =>
le
? (t[o] | (t[o + 1] << 8) | (t[o + 2] << 16) | (t[o + 3] << 24)) >>> 0
: ((t[o] << 24) | (t[o + 1] << 16) | (t[o + 2] << 8) | t[o + 3]) >>> 0;
if (r16(2) !== 42) throw new Error('not TIFF');
const readIfd = (off: number) => {
const map = new Map<number, ParsedEntry>();
const count = r16(off);
if (off + 2 + count * 12 + 4 > t.length) throw new Error('IFD out of bounds');
let lastTag = -1;
for (let i = 0; i < count; i++) {
const e = off + 2 + i * 12;
const tag = r16(e);
if (tag <= lastTag) throw new Error('IFD entries out of order');
lastTag = tag;
const type = r16(e + 2);
const n = r32(e + 4);
const size = TYPE_SIZE[type] * n;
const at = size <= 4 ? e + 8 : r32(e + 8);
if (at + size > t.length) throw new Error('value out of bounds');
map.set(tag, { type, count: n, value: t.slice(at, at + size) });
}
return { map, next: r32(off + 2 + count * 12) };
};
const ifd0 = readIfd(r32(4));
const ptr = (m: Map<number, ParsedEntry>, tag: number) => {
const e = m.get(tag);
return e
? (le
? e.value[0] | (e.value[1] << 8) | (e.value[2] << 16) | (e.value[3] << 24)
: (e.value[0] << 24) | (e.value[1] << 16) | (e.value[2] << 8) | e.value[3]) >>> 0
: 0;
};
const exifOff = ptr(ifd0.map, 0x8769);
const gpsOff = ptr(ifd0.map, 0x8825);
return {
ifd0: ifd0.map,
exif: exifOff ? readIfd(exifOff).map : null,
gps: gpsOff ? readIfd(gpsOff).map : null,
ifd1: ifd0.next ? readIfd(ifd0.next).map : null,
};
}
export const asciiOf = (e: ParsedEntry | undefined) =>
e ? latin1(e.value).replace(/\0+$/, '') : undefined;
// ── XMP ──────────────────────────────────────────────────────────────────────
export const XMP_PACKET = `<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:exif="http://ns.adobe.com/exif/1.0/"
xmlns:tiff="http://ns.adobe.com/tiff/1.0/"
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:Iptc4xmpExt="http://iptc.org/std/Iptc4xmpExt/2008-02-29/"
xmlns:drone-dji="http://www.dji.com/drone-dji/1.0/"
tiff:Make="Canon"
exif:GPSLatitude="52,31.2057N"
exif:GPSLongitude='13,24.0946E'
drone-dji:GpsLatitude="52.520095"
photoshop:City="Berlin"
photoshop:Country="Germany">
<exif:GPSAltitude>3456/100</exif:GPSAltitude>
<exif:GPSAltitudeRef>0</exif:GPSAltitudeRef>
<exif:GPSTimeStamp>2024-05-01T10:00:00Z</exif:GPSTimeStamp>
<exif:GPSVersionID/>
<dc:rights><rdf:Alt><rdf:li xml:lang="x-default">© Jane Doe</rdf:li></rdf:Alt></dc:rights>
<Iptc4xmpExt:LocationCreated>
<rdf:Bag>
<rdf:li rdf:parseType="Resource">
<Iptc4xmpExt:City>Berlin</Iptc4xmpExt:City>
<Iptc4xmpExt:GPSLatitude>52.520095</Iptc4xmpExt:GPSLatitude>
<Iptc4xmpExt:GPSLongitude>13.401577</Iptc4xmpExt:GPSLongitude>
</rdf:li>
</rdf:Bag>
</Iptc4xmpExt:LocationCreated>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>`;
/** Strings from the packet above that must not survive. */
export const XMP_LOCATION_VALUES = [
'52,31.2057N',
'13,24.0946E',
'52.520095',
'13.401577',
'3456/100',
];
// ── JPEG ─────────────────────────────────────────────────────────────────────
/** JPEG segment: FFxx <length incl. itself> <payload>. */
export const seg = (marker: number, payload: Uint8Array) =>
cat(bytes(0xff, marker), be16(payload.length + 2), payload);
export const exifSegment = (tiff: Uint8Array) => seg(0xe1, cat(ascii('Exif\0\0'), tiff));
export const xmpSegment = (packet: string) =>
seg(0xe1, cat(ascii('http://ns.adobe.com/xap/1.0/\0'), utf8(packet)));
export const SOS = cat(bytes(0xff, 0xda), be16(8), bytes(1, 1, 0, 0, 0x3f, 0));
/** Entropy-coded data with byte stuffing and a restart marker. */
export const SCAN = bytes(0x12, 0xff, 0x00, 0x34, 0xff, 0xd0, 0x56, 0x78, 0xff, 0x00, 0x9a);
export const EOI = bytes(0xff, 0xd9);
export const ICC = cat(ascii('ICC_PROFILE\0'), bytes(1, 1), ascii('colour profile bytes'));
export const JFIF = cat(ascii('JFIF\0'), bytes(1, 2, 0, 0, 1, 0, 1, 0, 0));
/** Photoshop APP13 with an IPTC record and an EXIF resource. */
export function photoshopSegment(exif: Uint8Array) {
const iptc = cat(bytes(0x1c, 0x02, 0x74), be16(18), ascii('Copyright Jane Doe'));
const resource = (id: number, data: Uint8Array) =>
cat(
ascii('8BIM'),
be16(id),
bytes(0, 0),
be32(data.length),
data,
data.length % 2 ? bytes(0) : bytes(),
);
return seg(0xed, cat(ascii('Photoshop 3.0\0'), resource(0x0404, iptc), resource(0x0422, exif)));
}
/** A JPEG as a camera or phone writes it, with a second image appended (MPF). */
export function cameraJpeg(le: boolean) {
const primaryHead = cat(
bytes(0xff, 0xd8),
seg(0xe0, JFIF),
exifSegment(cameraTiff(le)),
xmpSegment(XMP_PACKET),
seg(
0xe1,
cat(
ascii('http://ns.adobe.com/xmp/extension/\0'),
ascii('GUID'),
ascii('exif:GPSLatitude="1"'),
),
),
seg(0xe2, ICC),
seg(
0xeb,
cat(
ascii('JP'),
bytes(0, 1),
be32(1),
be32(40),
ascii('jumbjumd'),
ascii('c2pa GPS 52.520095'),
),
),
photoshopSegment(cameraTiff(!le)),
seg(0xdb, bytes(0, ...new Array(64).fill(1))),
);
const primary = cat(primaryHead, SOS, SCAN, EOI);
const secondary = cat(bytes(0xff, 0xd8), exifSegment(cameraTiff(le)), SOS, SCAN, EOI);
return {
file: cat(primary, secondary),
primaryHeadLength: primaryHead.length,
primaryLength: primary.length,
};
}
/** Every APPn segment of the JPEG at `soi`, up to SOS. */
export function jpegSegments(b: Uint8Array, soi = 0) {
const out: Array<{ marker: number; payload: Uint8Array; start: number }> = [];
let pos = soi + 2;
while (pos + 4 <= b.length && b[pos] === 0xff && b[pos + 1] !== 0xda) {
const len = (b[pos + 2] << 8) | b[pos + 3];
out.push({ marker: b[pos + 1], payload: b.slice(pos + 4, pos + 2 + len), start: pos + 4 });
pos += 2 + len;
}
return out;
}
/** The TIFF block of the first EXIF APP1 in the JPEG at `soi`. */
export function exifOf(b: Uint8Array, soi = 0): Uint8Array | null {
const s = jpegSegments(b, soi).find(
(x) => x.marker === 0xe1 && latin1(x.payload.slice(0, 6)) === 'Exif\0\0',
);
return s ? s.payload.slice(6) : null;
}
// ── HEIF ─────────────────────────────────────────────────────────────────────
export const box = (type: string, ...content: Uint8Array[]) => {
const body = cat(...content);
return cat(be32(body.length + 8), ascii(type), body);
};
const infe = (id: number, type: string, extra: Uint8Array = bytes(0)) =>
box('infe', bytes(2, 0, 0, 0), be16(id), be16(0), ascii(type), extra);
export const IMAGE_DATA = ascii('HEVC-IMAGE-DATA-THAT-MUST-NOT-CHANGE');
/**
* A HEIC with an image item, an `Exif` item and an XMP `mime` item. Item data
* goes to `mdat`, or for `idatExif` the EXIF goes into `idat` (method 1).
* `metaLast` puts `meta` after `mdat`; `c2pa` appends a manifest `uuid` box.
*/
export function cameraHeic(
opts: {
le?: boolean;
idatExif?: boolean;
metaLast?: boolean;
c2pa?: boolean;
exifPayload?: Uint8Array;
} = {},
) {
const le = opts.le ?? false;
const exifItem = opts.exifPayload ?? cat(be32(6), ascii('Exif\0\0'), cameraTiff(le));
const xmpItem = utf8(XMP_PACKET);
const ftyp = box('ftyp', ascii('heic'), be32(0), ascii('mif1heic'));
const iinf = box(
'iinf',
bytes(0, 0, 0, 0),
be16(3),
infe(1, 'hvc1'),
infe(2, 'Exif'),
infe(3, 'mime', cat(bytes(0), ascii('application/rdf+xml\0'))),
);
// iloc v1: offset_size 4, length_size 4, base_offset_size 0, index_size 0.
const entry = (id: number, method: number, offset: number, length: number) =>
cat(be16(id), be16(method), be16(0), be16(1), be32(offset), be32(length));
const buildMeta = (o: { image: number; exif: number; xmp: number }) =>
box(
'meta',
bytes(0, 0, 0, 0),
box('hdlr', bytes(0, 0, 0, 0), be32(0), ascii('pict'), be32(0), be32(0), be32(0), bytes(0)),
iinf,
box(
'iloc',
bytes(1, 0, 0, 0),
bytes(0x44, 0x00),
be16(3),
entry(1, 0, o.image, IMAGE_DATA.length),
entry(2, opts.idatExif ? 1 : 0, o.exif, exifItem.length),
entry(3, 0, o.xmp, xmpItem.length),
),
...(opts.idatExif ? [box('idat', exifItem)] : []),
);
const mdatBody = opts.idatExif ? cat(IMAGE_DATA, xmpItem) : cat(IMAGE_DATA, exifItem, xmpItem);
const metaLength = buildMeta({ image: 0, exif: 0, xmp: 0 }).length;
const mdatStart = ftyp.length + (opts.metaLast ? 0 : metaLength) + 8;
const offsets = {
image: mdatStart,
exif: opts.idatExif ? 0 : mdatStart + IMAGE_DATA.length,
xmp: mdatStart + IMAGE_DATA.length + (opts.idatExif ? 0 : exifItem.length),
};
const meta = buildMeta(offsets);
const mdat = box('mdat', mdatBody);
const c2pa = opts.c2pa
? box(
'uuid',
bytes(
0xd8,
0xfe,
0xc3,
0xd6,
0x1b,
0x0e,
0x48,
0x3c,
0x92,
0x97,
0x58,
0x28,
0x87,
0x7e,
0xc4,
0x81,
),
ascii('c2pa manifest GPS 52.520095'),
)
: bytes();
const file = opts.metaLast ? cat(ftyp, mdat, meta, c2pa) : cat(ftyp, meta, mdat, c2pa);
return { file, imageAt: offsets.image };
}
/** The TIFF block of the EXIF item, read back by its known offset prefix. */
export function heicExif(file: Uint8Array): Uint8Array | null {
const at = indexOf(file, cat(be32(6), ascii('Exif\0\0')));
return at < 0 ? null : file.slice(at + 10);
}
/** Feed `data` as a stream cut into `size`-byte chunks. */
export function chunked(data: Uint8Array, size: number): ReadableStream<Uint8Array> {
let pos = 0;
return new ReadableStream<Uint8Array>({
pull(controller) {
if (pos >= data.length) {
controller.close();
return;
}
controller.enqueue(data.slice(pos, pos + size));
pos += size;
},
});
}

View File

@@ -251,6 +251,84 @@ describe('ImmichClient', () => {
await expect(immich.streamAsset('asset-1')).rejects.toBeInstanceOf(ImmichUnavailableError);
});
// #467: Immich answers a missing full-size rendition with a redirect — to
// the preview, or to the original for a web format. Neither may be passed
// off as the rendition, so the redirect is not followed.
it('streamFullsize does not follow Immich’s redirect, and reports no rendition', async () => {
const cancel = vi.fn(async () => {});
mockFetch.mockResolvedValueOnce({
ok: false,
status: 302,
body: { cancel },
headers: { get: () => '/api/assets/asset-1/thumbnail?size=preview' },
});
await expect(immich.streamFullsize('asset-1')).resolves.toBeNull();
const [url, init] = mockFetch.mock.calls.at(-1)!;
expect(url).toMatch(/\/assets\/asset-1\/thumbnail\?size=fullsize$/);
expect(init.redirect).toBe('manual');
expect(cancel).toHaveBeenCalled();
});
it('streamFullsize streams a rendition Immich has', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
body: new ReadableStream(),
headers: { get: (h: string) => (h === 'Content-Type' ? 'image/jpeg' : '42') },
});
const result = await immich.streamFullsize('asset-1');
expect(result?.contentType).toBe('image/jpeg');
});
// #831: Immich applies an edit made in its editor only when asked.
describe('edited photos (#831)', () => {
const ok = () => ({
ok: true,
status: 200,
body: new ReadableStream(),
headers: { get: () => 'image/jpeg' },
});
const lastUrl = () => mockFetch.mock.calls.at(-1)![0] as string;
it.each([
['thumbnail', '/assets/asset-1/thumbnail?size=thumbnail&edited=true'],
['preview', '/assets/asset-1/thumbnail?size=preview&edited=true'],
['original', '/assets/asset-1/original?edited=true'],
] as const)('asks for the edited %s', async (size, path) => {
mockFetch.mockResolvedValueOnce(ok());
await immich.streamAsset('asset-1', size, true);
expect(lastUrl()).toBe(`http://immich.test/api${path}`);
});
it.each([
['thumbnail', '/assets/asset-1/thumbnail?size=thumbnail'],
['preview', '/assets/asset-1/thumbnail?size=preview'],
['original', '/assets/asset-1/original'],
] as const)('requests a %s that is not edited exactly as before', async (size, path) => {
mockFetch.mockResolvedValueOnce(ok());
await immich.streamAsset('asset-1', size);
expect(lastUrl()).toBe(`http://immich.test/api${path}`);
mockFetch.mockResolvedValueOnce(ok());
await immich.streamAsset('asset-1', size, false);
expect(lastUrl()).toBe(`http://immich.test/api${path}`);
});
it('asks for the edited full-size rendition, still without following redirects', async () => {
mockFetch.mockResolvedValueOnce(ok());
await immich.streamFullsize('asset-1', true);
expect(lastUrl()).toBe(
'http://immich.test/api/assets/asset-1/thumbnail?size=fullsize&edited=true',
);
expect(mockFetch.mock.calls.at(-1)![1].redirect).toBe('manual');
});
});
it('streamAsset still follows redirects', async () => {
mockFetch.mockResolvedValueOnce(streamRes(404));
await immich.streamAsset('asset-1', 'original');
expect(mockFetch.mock.calls.at(-1)![1].redirect).toBe('follow');
});
it('streamVideo returns null for a genuinely missing video', async () => {
mockFetch.mockResolvedValueOnce(streamRes(404));
await expect(immich.streamVideo('asset-1')).resolves.toBeNull();

View File

@@ -36,6 +36,39 @@ afterEach(async () => {
const entry = (title: string) => `---\ntitle: "${title}"\n---\n\nBody of ${title}\n`;
describe('saveJournalEntry (QA A-19)', () => {
it('takes no backup for a save that changes nothing', async () => {
await service.writeJournalEntry('trip', entry('v0'));
await service.writeJournalEntry('trip', entry('v1'));
expect(await readdir(backupDir)).toHaveLength(1);
await service.writeJournalEntry('trip', entry('v1'));
expect(await readdir(backupDir)).toHaveLength(1);
});
it("keeps a renamed entry's history under its new slug, not as a deleted entry", async () => {
await writeFile(path.join(journalDir, 'trip.md'), entry('Trip'));
await service.saveJournalEntry('journey', entry('Journey'), { fromSlug: 'trip' });
const files = await readdir(journalDir);
expect(files).toContain('journey.md');
expect(files).not.toContain('trip.md');
const backups = await service.listJournalBackups();
expect(backups).toEqual([expect.objectContaining({ slug: 'journey', kind: 'save' })]);
await expect(readFile(path.join(backupDir, backups[0].filename), 'utf8')).resolves.toBe(
entry('Trip'),
);
});
it('retires a legacy copy of the renamed entry too', async () => {
await mkdir(essaysDir, { recursive: true });
await writeFile(path.join(essaysDir, 'trip.md'), entry('Trip'));
await service.saveJournalEntry('journey', entry('Journey'), { fromSlug: 'trip' });
await expect(readdir(essaysDir)).resolves.not.toContain('trip.md');
expect((await service.listJournalBackups()).map((b) => b.slug)).toEqual(['journey']);
});
});
describe('deleteJournalEntry', () => {
it('keeps a .deleted.bak copy before removing the file', async () => {
await writeFile(path.join(journalDir, 'trip.md'), entry('Trip'));

View File

@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import {
parseJournalMarkdown,
serializeJournalMarkdown,
type JournalBlock,
type ParsedJournal,
} from '../journal';
const journal = (blocks: JournalBlock[]): ParsedJournal => ({
frontmatter: { title: 'T' },
blocks,
referencedAssetIds: [],
});
const roundTrip = (blocks: JournalBlock[]) =>
parseJournalMarkdown(serializeJournalMarkdown(journal(blocks))).blocks;
/**
* A text block whose markdown began with `![` was written verbatim and came
* back from the next load as a photo block — its text gone, a broken photo in
* its place. The serializer now escapes such a line and the parser takes the
* escape off again.
*/
describe('text blocks that start like a photo', () => {
it('stay text across a save and a reload', () => {
const blocks = roundTrip([{ type: 'paragraph', html: '![draft] placeholder' }]);
expect(blocks).toEqual([{ type: 'paragraph', html: '![draft] placeholder' }]);
});
it('survive a second round trip unchanged', () => {
const once = roundTrip([{ type: 'paragraph', html: '![todo] <em>pick</em> a photo' }]);
expect(once).toEqual([{ type: 'paragraph', html: '![todo] <em>pick</em> a photo' }]);
expect(roundTrip(once)).toEqual(once);
});
it('keep a backslash that was really typed in front', () => {
const blocks = roundTrip([{ type: 'paragraph', html: '\\![not escaped] by accident' }]);
expect(blocks).toEqual([{ type: 'paragraph', html: '\\![not escaped] by accident' }]);
});
it('writes the escape into the file, and leaves real photos alone', () => {
const md = serializeJournalMarkdown(
journal([
{ type: 'paragraph', html: '![draft]' },
{ type: 'photo', assetId: 'abc', layout: 'wide' },
]),
);
expect(md).toContain('\\![draft]');
expect(md).toContain('![abc:wide]()');
expect(parseJournalMarkdown(md).blocks.map((b) => b.type)).toEqual(['paragraph', 'photo']);
});
it('does not touch `![` in the middle of a paragraph', () => {
const md = serializeJournalMarkdown(journal([{ type: 'paragraph', html: 'see ![x]' }]));
expect(md).toContain('see ![x]');
expect(md).not.toContain('\\!');
});
});

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { formatJournalDate } from '../journalDate';
import { formatJournalDate, journalDateTime } from '../journalDate';
/**
* The /journal index formatted `new Date(frontmatter.date)` in the server's
@@ -49,3 +49,22 @@ describe('formatJournalDate', () => {
expect(formatJournalDate(' ', 'en-US')).toBeNull();
});
});
/** The `datetime` of the `<time>` the index and the entry header render (P-21). */
describe('journalDateTime', () => {
it('gives the calendar day, without the time a value may carry', () => {
expect(journalDateTime('2026-03-15')).toBe('2026-03-15');
expect(journalDateTime(' 2026-03-15T23:30:00+01:00 ')).toBe('2026-03-15');
});
it('gives a year-month value as a month', () => {
expect(journalDateTime('2026-03')).toBe('2026-03');
});
it.each(['15.03.2026', 'Spring 2026', '2026-02-30', '2026-13', '', undefined])(
'has no machine-readable form for %s',
(raw) => {
expect(journalDateTime(raw)).toBeNull();
},
);
});

View File

@@ -0,0 +1,93 @@
import { describe, it, expect } from 'vitest';
import { justifiedTileStyle, JUSTIFIED_FALLBACK_RATIO } from '../justifiedRow';
/**
* Evaluates a flex-basis calc() the way the browser would, with the custom
* properties a frame sets (or their fallbacks when it sets none).
*/
function basisPx(basis: string, vars: Record<string, number>): number {
const expr = basis
.replace(/var\(--([\w-]+),\s*([\d.]+)px\)/g, (_, name: string, fallback: string) =>
String(vars[name] ?? Number(fallback)),
)
.replace(/^calc/, '');
expect(expr).toMatch(/^[\d.\s()*+-]+$/);
return Function(`return ${expr}`)() as number;
}
/**
* Lays out one row the way `flex-wrap` does for a row that fits: every tile
* starts at its basis and the leftover width is shared by flex-grow.
*/
function layoutRow(ratios: number[], rowWidth: number, vars: Record<string, number>) {
const tiles = ratios.map((r) => justifiedTileStyle(r));
const bases = tiles.map((t) => basisPx(t.flexBasis, vars));
const free = rowWidth - bases.reduce((a, b) => a + b, 0);
const grow = tiles.reduce((a, t) => a + t.flexGrow, 0);
const rowHeight = vars['grid-row-height'] ?? 300;
return tiles.map((t, i) => {
const width = bases[i] + (free * t.flexGrow) / grow;
// The photo inside the tile, once the mat around it is taken off.
const photoW = width - (vars['photo-mat-x'] ?? 0);
const photoH = rowHeight - (vars['photo-mat-y'] ?? 0);
return { width, photoW, photoH };
});
}
describe('justifiedTileStyle', () => {
it('grows by the aspect ratio', () => {
expect(justifiedTileStyle(0.75).flexGrow).toBe(0.75);
});
it('falls back to ~3:2 for assets without dimensions', () => {
for (const ar of [undefined, 0, -1, NaN, Infinity]) {
expect(justifiedTileStyle(ar).flexGrow).toBe(JUSTIFIED_FALLBACK_RATIO);
}
});
it('is unchanged without a frame: row height times the ratio', () => {
const basis = justifiedTileStyle(1.5).flexBasis;
expect(basisPx(basis, {})).toBe(450);
expect(basisPx(basis, { 'grid-row-height': 240 })).toBe(360);
});
it('sizes the photo inside a passepartout mat, not the whole tile', () => {
// 8px mat, 32px caption band, 1px border: 18px across, 42px down.
const vars = { 'grid-row-height': 300, 'photo-mat-x': 18, 'photo-mat-y': 42 };
const basis = basisPx(justifiedTileStyle(1.5).flexBasis, vars);
expect(basis).toBe((300 - 42) * 1.5 + 18);
});
it('keeps every photo in a filled row at its own aspect ratio, scaled alike', () => {
const vars = { 'grid-row-height': 300, 'photo-mat-x': 18, 'photo-mat-y': 42 };
const ratios = [1.5, 0.6667, 1.7778];
// A row with no leftover width: every photo shows at exactly its ratio.
const exact = ratios.reduce((a, r) => a + (300 - 42) * r + 18, 0);
for (const tile of layoutRow(ratios, exact, vars).map(
(t, i) => t.photoW / t.photoH / ratios[i],
)) {
expect(tile).toBeCloseTo(1, 10);
}
// A row with leftover width: every photo widens by the same factor, so a
// portrait is not cropped harder than the landscapes beside it.
const stretched = layoutRow(ratios, exact + 120, vars).map(
(t, i) => t.photoW / t.photoH / ratios[i],
);
for (const s of stretched) expect(s).toBeCloseTo(stretched[0], 10);
});
it('would crop unevenly if the tile, mat included, were sized from the row height', () => {
// Regression guard for the formula itself: the old basis (row height ×
// ratio, no mat terms) gives photos in the same row different stretch.
const vars = { 'grid-row-height': 300, 'photo-mat-x': 18, 'photo-mat-y': 42 };
const ratios = [1.5, 0.6667];
const oldBases = ratios.map((r) => 300 * r);
const rowWidth = oldBases.reduce((a, b) => a + b, 0);
const oldStretch = oldBases.map((w, i) => (w - 18) / (300 - 42) / ratios[i]);
expect(Math.abs(oldStretch[0] - oldStretch[1])).toBeGreaterThan(0.05);
const nowStretch = layoutRow(ratios, rowWidth, vars).map(
(t, i) => t.photoW / t.photoH / ratios[i],
);
expect(nowStretch[0]).toBeCloseTo(nowStretch[1], 10);
});
});

View File

@@ -93,6 +93,13 @@ describe('lightboxActionFor', () => {
expect(lightboxActionFor('H')).toBe('shortcutList');
});
it('zooms with + (or = without Shift), - and 0 (#467)', () => {
expect(lightboxActionFor('+')).toBe('zoomIn');
expect(lightboxActionFor('=')).toBe('zoomIn');
expect(lightboxActionFor('-')).toBe('zoomOut');
expect(lightboxActionFor('0')).toBe('zoomReset');
});
it('takes both cases of every letter key, since Shift must not matter', () => {
for (const [lower, upper] of [
['i', 'I'],

View File

@@ -0,0 +1,612 @@
import { describe, it, expect } from 'vitest';
import {
scrubLocation,
scrubLocationStream,
scrubTiffGps,
scrubXmpLocation,
isLocationScrubbable,
Pending,
HEAD_CAP,
} from '../locationScrub';
import {
ALT,
ICC,
JFIF,
LAT,
LON,
SCAN,
XMP_LOCATION_VALUES,
XMP_PACKET,
ascii,
asciiOf,
be32,
bytes,
cameraHeic,
cameraJpeg,
cameraTiff,
cat,
chunked,
contains,
exifOf,
exifSegment,
heicExif,
indexOf,
jpegSegments,
latin1,
parseTiff,
seg,
SOS,
EOI,
utf8,
xmpSegment,
be16,
} from './fixtures/location';
const LAT_BYTES = (le: boolean) => LAT.value(le);
const LON_BYTES = (le: boolean) => LON.value(le);
const ALT_BYTES = (le: boolean) => ALT.value(le);
/** Run the stream scrubber over `data`, delivered in `size`-byte chunks. */
async function viaStream(data: Uint8Array, size: number): Promise<Uint8Array | null> {
const result = await scrubLocationStream(chunked(data, size));
if (!result.ok) return null;
return new Uint8Array(await new Response(result.stream).arrayBuffer());
}
/** Camera data that must come through untouched. */
function expectCameraData(tiff: Uint8Array) {
const t = parseTiff(tiff);
expect(asciiOf(t.ifd0.get(0x010f))).toBe('Canon');
expect(asciiOf(t.ifd0.get(0x0110))).toBe('Canon EOS R5');
expect(asciiOf(t.ifd0.get(0x8298))).toBe('(c) Jane Doe');
expect(asciiOf(t.exif?.get(0xa434))).toBe('RF24-70mm F2.8 L IS USM');
expect(t.exif?.get(0x8827)).toBeDefined();
expect(t.ifd1?.get(0x0103)).toBeDefined(); // IFD1 still chained
return t;
}
function expectNoGps(tiff: Uint8Array, le: boolean) {
const t = parseTiff(tiff);
expect(t.ifd0.has(0x8825)).toBe(false);
expect(t.exif?.has(0x8825)).toBe(false);
expect(t.gps).toBeNull();
expect(contains(tiff, LAT_BYTES(le))).toBe(false);
expect(contains(tiff, LON_BYTES(le))).toBe(false);
expect(contains(tiff, ALT_BYTES(le))).toBe(false);
}
function expectXmpScrubbed(text: string) {
expect(text).not.toMatch(/:GPS|:Gps/);
for (const v of XMP_LOCATION_VALUES) expect(text).not.toContain(v);
expect(text).toContain('photoshop:City="Berlin"');
expect(text).toContain('photoshop:Country="Germany"');
expect(text).toContain('<Iptc4xmpExt:City>Berlin</Iptc4xmpExt:City>');
expect(text).toContain('© Jane Doe');
expect(text).toContain('tiff:Make="Canon"');
}
describe('scrubTiffGps', () => {
for (const le of [true, false]) {
const order = le ? 'little-endian (II)' : 'big-endian (MM)';
it(`removes the GPS IFD and keeps the camera tags, ${order}`, () => {
const tiff = cameraTiff(le);
const before = parseTiff(tiff);
expect(before.gps?.get(0x0002)).toBeDefined(); // the fixture really has GPS
const out = tiff.slice();
expect(scrubTiffGps(out, 0, out.length)).toBe(true);
expect(out.length).toBe(tiff.length);
expectNoGps(out, le);
const after = expectCameraData(out);
// Values are byte-identical, not just present.
for (const tag of [0x010f, 0x0110, 0x0112, 0x8298]) {
expect(after.ifd0.get(tag)).toEqual(before.ifd0.get(tag));
}
expect(after.exif).toEqual(before.exif);
});
it(`leaves a file without GPS byte-identical, ${order}`, () => {
const tiff = cameraTiff(le, { withGps: false });
const out = tiff.slice();
expect(scrubTiffGps(out, 0, out.length)).toBe(true);
expect(out).toEqual(tiff);
});
}
it('scrubs XMP embedded in TIFF tag 700', () => {
const tiff = cameraTiff(false, { xmp: utf8(XMP_PACKET) });
const out = tiff.slice();
expect(scrubTiffGps(out, 0, out.length)).toBe(true);
const xmp = parseTiff(out).ifd0.get(0x02bc)!;
expectXmpScrubbed(Buffer.from(xmp.value).toString('utf8'));
});
it('refuses what it cannot walk', () => {
const bad = (mutate: (t: Uint8Array) => void) => {
const t = cameraTiff(true);
mutate(t);
return scrubTiffGps(t, 0, t.length);
};
expect(bad((t) => t.set(ascii('XX'), 0))).toBe(false); // byte order
expect(bad((t) => (t[2] = 43))).toBe(false); // BigTIFF
expect(bad((t) => t.set([0xff, 0xff, 0, 0], 4))).toBe(false); // IFD0 out of range
expect(scrubTiffGps(bytes(0x49, 0x49), 0, 2)).toBe(false);
});
it('refuses a GPS IFD whose values point outside the block', () => {
const t = cameraTiff(false);
const gps = parseTiff(t);
expect(gps.gps).not.toBeNull();
// Find the GPSLatitude entry (tag 2, RATIONAL) and point it past the end.
const entry = indexOf(t, bytes(0x00, 0x02, 0x00, 0x05, 0x00, 0x00, 0x00, 0x03));
t.set(be32(0x7fffffff), entry + 8);
expect(scrubTiffGps(t, 0, t.length)).toBe(false);
});
});
describe('scrubXmpLocation', () => {
it('blanks every location property, keeps the length and the rest', () => {
const packet = utf8(XMP_PACKET);
const out = packet.slice();
expect(scrubXmpLocation(out, 0, out.length)).toBe(true);
expect(out.length).toBe(packet.length);
expectXmpScrubbed(Buffer.from(out).toString('utf8'));
});
it('leaves a packet without location untouched', () => {
const packet = utf8('<x:xmpmeta><rdf:Description photoshop:City="Berlin"/></x:xmpmeta>');
const out = packet.slice();
expect(scrubXmpLocation(out, 0, out.length)).toBe(true);
expect(out).toEqual(packet);
});
it('refuses an unclosed location element', () => {
const out = utf8('<rdf:Description><exif:GPSLatitude>52,31N</rdf:Description>');
expect(scrubXmpLocation(out, 0, out.length)).toBe(false);
});
it('refuses UTF-16, which it cannot read byte-wise', () => {
const out = new Uint8Array(Buffer.from('<exif:GPSLatitude>1</exif:GPSLatitude>', 'utf16le'));
expect(scrubXmpLocation(out, 0, out.length)).toBe(false);
});
it('tolerates trailing NUL padding', () => {
const out = cat(utf8('<a exif:GPSLatitude="1"/>'), bytes(0, 0, 0));
expect(scrubXmpLocation(out, 0, out.length)).toBe(true);
expect(latin1(out)).not.toContain('GPS');
});
});
describe('JPEG', () => {
for (const le of [true, false]) {
const order = le ? 'little-endian' : 'big-endian';
it(`removes location and keeps everything else, ${order} EXIF`, async () => {
const { file, primaryHeadLength, primaryLength } = cameraJpeg(le);
const out = (await scrubLocation(file))!;
expect(out).not.toBeNull();
expect(out.length).toBe(file.length);
// EXIF: GPS gone, camera data intact.
const tiff = exifOf(out)!;
expectNoGps(tiff, le);
expectCameraData(tiff);
// XMP: GPS gone, city/country/rights intact.
const xmp = jpegSegments(out).find((s) =>
latin1(s.payload).startsWith('http://ns.adobe.com/xap/1.0/'),
)!;
expectXmpScrubbed(Buffer.from(xmp.payload).toString('utf8'));
// JFIF and ICC are byte-identical.
const segs = jpegSegments(out);
expect(segs.find((s) => s.marker === 0xe0)!.payload).toEqual(JFIF);
expect(segs.find((s) => s.marker === 0xe2)!.payload).toEqual(ICC);
// Extended XMP and the C2PA manifest are zeroed whole.
const ext = segs.filter((s) => s.marker === 0xe1)[2];
expect(ext.payload.every((x) => x === 0)).toBe(true);
const jumbf = segs.find((s) => s.marker === 0xeb)!;
expect(jumbf.payload.every((x) => x === 0)).toBe(true);
// APP13: the IPTC record stays, its EXIF resource loses GPS.
const app13 = segs.find((s) => s.marker === 0xed)!;
expect(latin1(app13.payload)).toContain('Copyright Jane Doe');
const irbExif = app13.payload.slice(indexOf(app13.payload, ascii(le ? 'MM' : 'II')));
expectNoGps(irbExif, !le);
expectCameraData(irbExif);
// Scan data and everything after the primary head is identical apart
// from the appended image's metadata.
expect(out.slice(primaryHeadLength, primaryLength)).toEqual(
file.slice(primaryHeadLength, primaryLength),
);
// The appended (MPF) image: GPS gone there too, camera data kept.
const second = exifOf(out, primaryLength)!;
expectNoGps(second, le);
expectCameraData(second);
expect(out.slice(out.length - SCAN.length - 2)).toEqual(
file.slice(file.length - SCAN.length - 2),
);
// Nothing location-shaped anywhere in the file.
const text = latin1(out);
expect(text).not.toContain('52.520095');
expect(contains(out, LAT_BYTES(le))).toBe(false);
expect(contains(out, LAT_BYTES(!le))).toBe(false);
});
}
it('gives the same bytes however the stream is chunked', async () => {
const { file } = cameraJpeg(true);
const whole = (await scrubLocation(file))!;
for (const size of [1, 2, 3, 7, 64, 1000, 4096]) {
expect(await viaStream(file, size)).toEqual(whole);
}
});
it('zeroes an EXIF segment it cannot parse, and only that segment', async () => {
const broken = cat(ascii('MM'), bytes(0, 42), be32(0x7fffff00), ascii('GPS 52.520095'));
const file = cat(
bytes(0xff, 0xd8),
seg(0xe0, JFIF),
exifSegment(broken),
xmpSegment(XMP_PACKET),
seg(0xe2, ICC),
SOS,
SCAN,
EOI,
);
const out = (await scrubLocation(file))!;
expect(out.length).toBe(file.length);
const segs = jpegSegments(out);
expect(segs[1].payload.every((x) => x === 0)).toBe(true);
expectXmpScrubbed(Buffer.from(segs[2].payload).toString('utf8'));
expect(segs[3].payload).toEqual(ICC);
expect(latin1(out)).not.toContain('52.520095');
});
it('zeroes an unknown APP1 in the primary image', async () => {
const file = cat(bytes(0xff, 0xd8), seg(0xe1, ascii('FLIR\0GPS 52.520095')), SOS, SCAN, EOI);
const out = (await scrubLocation(file))!;
expect(latin1(out)).not.toContain('52.520095');
});
it('accepts fill bytes before a marker', async () => {
const file = cat(bytes(0xff, 0xd8, 0xff, 0xff), exifSegment(cameraTiff(true)), SOS, SCAN, EOI);
const out = (await scrubLocation(file))!;
expect(out).not.toBeNull();
expectNoGps(exifOf(cat(bytes(0xff, 0xd8), out.slice(4)))!, true);
});
it('refuses a JPEG whose head never reaches the image data', async () => {
const truncated = cat(bytes(0xff, 0xd8), exifSegment(cameraTiff(true))).slice(0, 60);
expect(await scrubLocation(truncated)).toBeNull();
const noSos = cat(bytes(0xff, 0xd8), seg(0xe0, JFIF));
expect(await scrubLocation(noSos)).toBeNull();
const garbage = cat(bytes(0xff, 0xd8), seg(0xe0, JFIF), bytes(0x12, 0x34, 0x56, 0x78));
expect(await scrubLocation(garbage)).toBeNull();
});
});
describe('HEIF / AVIF', () => {
const check = (out: Uint8Array, file: Uint8Array, imageAt: number, le: boolean) => {
expect(out.length).toBe(file.length);
const tiff = heicExif(out)!;
expect(tiff).not.toBeNull();
expectNoGps(tiff, le);
expectCameraData(tiff);
expectXmpScrubbed(latin1(out.slice(indexOf(out, ascii('<?xpacket')))));
expect(out.slice(imageAt, imageAt + 20)).toEqual(file.slice(imageAt, imageAt + 20));
};
for (const le of [true, false]) {
it(`removes location from the Exif and XMP items, ${le ? 'II' : 'MM'}`, async () => {
const { file, imageAt } = cameraHeic({ le });
check((await scrubLocation(file))!, file, imageAt, le);
});
}
it('handles EXIF stored in idat (construction method 1)', async () => {
const { file, imageAt } = cameraHeic({ idatExif: true });
check((await scrubLocation(file))!, file, imageAt, false);
});
it('handles a meta box after mdat', async () => {
const { file, imageAt } = cameraHeic({ metaLast: true });
check((await scrubLocation(file))!, file, imageAt, false);
});
it('zeroes a C2PA manifest box', async () => {
const { file } = cameraHeic({ c2pa: true });
const out = (await scrubLocation(file))!;
expect(latin1(out)).not.toContain('52.520095');
expect(latin1(out)).not.toContain('c2pa manifest');
});
it('gives the same bytes however the stream is chunked', async () => {
const { file } = cameraHeic({ c2pa: true });
const whole = (await scrubLocation(file))!;
for (const size of [1, 5, 13, 100, 4096]) {
expect(await viaStream(file, size)).toEqual(whole);
}
const { file: last } = cameraHeic({ metaLast: true, c2pa: true });
const lastWhole = (await scrubLocation(last))!;
for (const size of [1, 7, 333]) {
expect(await viaStream(last, size)).toEqual(lastWhole);
}
});
it('zeroes an Exif item it cannot parse', async () => {
const payload = cat(be32(6), ascii('Exif\0\0'), ascii('XXnot-a-tiff GPS 52.520095'));
const { file } = cameraHeic({ exifPayload: payload });
const out = (await scrubLocation(file))!;
expect(out.length).toBe(file.length);
expect(latin1(out)).not.toContain('52.520095');
expect(latin1(out)).not.toContain('not-a-tiff');
});
it('refuses a file whose meta box cannot be parsed', async () => {
const { file } = cameraHeic();
const broken = file.slice();
const iloc = indexOf(broken, ascii('iloc'));
broken.set([0xff, 0xff], iloc + 4 + 4 + 2); // item_count far beyond the box
expect(await scrubLocation(broken)).toBeNull();
});
it('refuses a truncated meta box', async () => {
const { file } = cameraHeic();
const meta = indexOf(file, ascii('meta'));
expect(await scrubLocation(file.slice(0, meta + 40))).toBeNull();
});
});
describe('other formats', () => {
it('passes PNG, TIFF/DNG, video and unknown bytes through unchanged', async () => {
const samples = [
cat(bytes(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a), ascii('IHDR...GPS')),
cameraTiff(true),
cat(be32(20), ascii('ftypqt '), be32(0), ascii('qt '), ascii('moov....')),
ascii('image-bytes'),
bytes(),
];
for (const sample of samples) {
const result = await scrubLocationStream(chunked(sample, 3));
expect(result.ok && result.format).toBe('passthrough');
expect(await viaStream(sample, 3)).toEqual(sample);
}
});
it('reports which formats it edits', async () => {
const jpeg = await scrubLocationStream(
chunked(cat(bytes(0xff, 0xd8), seg(0xe0, JFIF), SOS, EOI), 4),
);
expect(jpeg.ok && jpeg.format).toBe('jpeg');
const heic = await scrubLocationStream(chunked(cameraHeic().file, 4));
expect(heic.ok && heic.format).toBe('heif');
});
});
describe('isLocationScrubbable', () => {
it('judges by the original MIME type, then the file name', () => {
expect(isLocationScrubbable({ type: 'IMAGE', originalMimeType: 'image/jpeg' })).toBe(true);
expect(isLocationScrubbable({ type: 'IMAGE', originalMimeType: 'image/heic' })).toBe(true);
expect(isLocationScrubbable({ type: 'IMAGE', originalMimeType: 'image/avif' })).toBe(true);
expect(isLocationScrubbable({ type: 'IMAGE', originalMimeType: 'image/x-adobe-dng' })).toBe(
false,
);
expect(isLocationScrubbable({ type: 'IMAGE', originalMimeType: 'image/png' })).toBe(false);
expect(isLocationScrubbable({ type: 'VIDEO', originalMimeType: 'video/mp4' })).toBe(false);
expect(isLocationScrubbable({ type: 'IMAGE', originalFileName: 'IMG_0001.HEIC' })).toBe(true);
expect(isLocationScrubbable({ type: 'IMAGE', originalFileName: 'DSC_0001.NEF' })).toBe(false);
});
});
describe('XMP matching stays linear', () => {
const time = (packet: string) => {
const b = utf8(packet);
const started = performance.now();
const ok = scrubXmpLocation(b, 0, b.length);
return { ok, ms: performance.now() - started, text: latin1(b) };
};
it('scans long name runs without retrying at every character', () => {
for (const size of [64 * 1024, 1024 * 1024]) {
const run = time(`<x:xmpmeta exif:GPSLatitude="1">${'a'.repeat(size)}</x:xmpmeta>`);
expect(run.ok).toBe(true);
expect(run.text).not.toContain('GPS');
expect(run.ms).toBeLessThan(100);
const colons = time(`<x:xmpmeta exif:GPSLatitude="1">${'a:'.repeat(size / 2)}</x:xmpmeta>`);
expect(colons.ok).toBe(true);
expect(colons.ms).toBeLessThan(100);
}
});
it('blanks deeply nested location elements in one pass', () => {
const depth = 20000;
const nested = time(
`<x:xmpmeta>${'<exif:GPSArea>'.repeat(depth)}${'</exif:GPSArea>'.repeat(depth)}</x:xmpmeta>`,
);
expect(nested.ok).toBe(true);
expect(nested.text).not.toContain('GPS');
expect(nested.ms).toBeLessThan(100);
});
});
describe('place names below city level', () => {
const STREET = 'Hauptstrasse 12';
it('blanks sub-city XMP place names and keeps city, state and country', () => {
const packet = `<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF><rdf:Description rdf:about=""
Iptc4xmpCore:Location="${STREET}"
Iptc4xmpCore:CountryCode="DE"
photoshop:City="Berlin" photoshop:State="Berlin" photoshop:Country="Germany">
<xmpDM:shotLocation>${STREET}</xmpDM:shotLocation>
<Iptc4xmpExt:LocationShown><rdf:Bag><rdf:li rdf:parseType="Resource">
<Iptc4xmpExt:Sublocation>${STREET}</Iptc4xmpExt:Sublocation>
<Iptc4xmpExt:LocationName>Café am Markt</Iptc4xmpExt:LocationName>
<Iptc4xmpExt:City>Berlin</Iptc4xmpExt:City>
<Iptc4xmpExt:ProvinceState>Berlin</Iptc4xmpExt:ProvinceState>
<Iptc4xmpExt:CountryName>Germany</Iptc4xmpExt:CountryName>
<Iptc4xmpExt:CountryCode>DE</Iptc4xmpExt:CountryCode>
</rdf:li></rdf:Bag></Iptc4xmpExt:LocationShown>
</rdf:Description></rdf:RDF></x:xmpmeta>`;
const b = utf8(packet);
expect(scrubXmpLocation(b, 0, b.length)).toBe(true);
const text = Buffer.from(b).toString('utf8');
expect(b.length).toBe(utf8(packet).length);
expect(text).not.toContain(STREET);
expect(text).not.toContain('Café am Markt');
expect(text).not.toMatch(/:(Sub)?[Ll]ocation\b|shotLocation|LocationName/);
for (const kept of [
'Iptc4xmpCore:CountryCode="DE"',
'photoshop:City="Berlin"',
'photoshop:State="Berlin"',
'photoshop:Country="Germany"',
'<Iptc4xmpExt:City>Berlin</Iptc4xmpExt:City>',
'<Iptc4xmpExt:ProvinceState>Berlin</Iptc4xmpExt:ProvinceState>',
'<Iptc4xmpExt:CountryName>Germany</Iptc4xmpExt:CountryName>',
'<Iptc4xmpExt:CountryCode>DE</Iptc4xmpExt:CountryCode>',
'<Iptc4xmpExt:LocationShown>',
]) {
expect(text).toContain(kept);
}
});
it('blanks the IIM sub-location (2:92) in place and keeps the rest of the record', async () => {
const dataset = (n: number, value: string) =>
cat(bytes(0x1c, 0x02, n), be16(value.length), ascii(value));
const iim = cat(
dataset(90, 'Berlin'),
dataset(92, STREET),
dataset(101, 'Germany'),
dataset(116, 'Copyright Jane Doe'),
);
const irb = cat(
ascii('Photoshop 3.0\0'),
ascii('8BIM'),
be16(0x0404),
bytes(0, 0),
be32(iim.length),
iim,
iim.length % 2 ? bytes(0) : bytes(),
);
const file = cat(bytes(0xff, 0xd8), seg(0xed, irb), SOS, SCAN, EOI);
const out = (await scrubLocation(file))!;
expect(out.length).toBe(file.length);
const text = latin1(out);
expect(text).not.toContain(STREET);
expect(text).toContain(`\x1c\x02\x5c\x00\x0f${' '.repeat(STREET.length)}`);
for (const kept of ['Berlin', 'Germany', 'Copyright Jane Doe']) expect(text).toContain(kept);
});
});
describe('vendor APPn segments', () => {
const GOPRO = cat(ascii('GoPro\0'), ascii('GPS5 52.520095 13.401577'));
const KODAK = cat(ascii('Meta\0\0'), ascii('GPS 52.520095'));
const MPF = cat(ascii('MPF\0'), ascii('MM'), bytes(0, 42), be32(8), ascii('mp-index'));
const ADOBE = cat(ascii('Adobe'), bytes(0, 100, 0, 0, 0, 0, 1));
const head = () =>
cat(
seg(0xe0, JFIF),
seg(0xe2, MPF),
seg(0xe2, ICC),
seg(0xe3, KODAK),
seg(0xe6, GOPRO),
seg(0xe0, ascii('AVI1\0GPS 52.520095')),
seg(0xee, ADOBE),
);
const check = (out: Uint8Array, soi: number) => {
const segs = jpegSegments(out, soi);
const payload = (marker: number, n = 0) => segs.filter((x) => x.marker === marker)[n].payload;
expect(payload(0xe0)).toEqual(JFIF);
expect(payload(0xe2, 0)).toEqual(MPF);
expect(payload(0xe2, 1)).toEqual(ICC);
expect(payload(0xee)).toEqual(ADOBE);
for (const zeroed of [payload(0xe3), payload(0xe6), payload(0xe0, 1)]) {
expect(zeroed.every((x) => x === 0)).toBe(true);
}
};
it('zeroes APPn segments off the allowlist and keeps JFIF, ICC, MPF and Adobe', async () => {
const primary = cat(bytes(0xff, 0xd8), head(), SOS, SCAN, EOI);
const secondary = cat(bytes(0xff, 0xd8), head(), SOS, SCAN, EOI);
const file = cat(primary, secondary);
const out = (await scrubLocation(file))!;
expect(out.length).toBe(file.length);
expect(latin1(out)).not.toContain('52.520095');
check(out, 0);
check(out, primary.length);
for (const size of [1, 9, 100]) expect(await viaStream(file, size)).toEqual(out);
});
});
describe('progressive JPEG', () => {
const DHT = seg(0xc4, bytes(0x10, ...new Array(16).fill(0), 0));
it('scrubs an APPn segment between scans, and only inside the image', async () => {
const trailer = cat(ascii('TRAILER'), bytes(0xff, 0xe1, 0x00, 0x10), ascii('not a segment!'));
const file = cat(
bytes(0xff, 0xd8),
seg(0xe0, JFIF),
SOS,
SCAN,
exifSegment(cameraTiff(true)),
xmpSegment(XMP_PACKET),
seg(0xe6, ascii('GoPro\0GPS5')),
DHT,
SOS,
SCAN,
EOI,
trailer,
);
const out = (await scrubLocation(file))!;
expect(out.length).toBe(file.length);
const between = indexOf(out, ascii('Exif\0\0'));
const tiff = parseTiff(out.slice(between + 6));
expect(tiff.gps).toBeNull();
expect(asciiOf(tiff.ifd0.get(0x010f))).toBe('Canon');
expect(latin1(out)).not.toMatch(/exif:GPS|GoPro/);
// Bytes after EOI that merely look like a marker are not touched.
expect(out.slice(out.length - trailer.length)).toEqual(trailer);
for (const size of [1, 3, 50, 777]) expect(await viaStream(file, size)).toEqual(out);
});
});
describe('padding between segments', () => {
it('accepts NUL padding and FF fill bytes between segments', async () => {
const file = cat(
bytes(0xff, 0xd8),
seg(0xe0, JFIF),
bytes(0, 0, 0),
exifSegment(cameraTiff(false)),
bytes(0xff, 0xff, 0xff),
seg(0xe2, ICC),
bytes(0x00),
SOS,
SCAN,
EOI,
);
const out = (await scrubLocation(file))!;
expect(out).not.toBeNull();
expect(out.length).toBe(file.length);
expect(contains(out, LAT_BYTES(false))).toBe(false);
expect(latin1(out)).toContain('Canon EOS R5');
expect(contains(out, ICC)).toBe(true);
});
});
describe('Pending', () => {
it('never allocates more than the head cap plus one chunk', () => {
const pending = new Pending();
const chunk = new Uint8Array(1024 * 1024);
while (pending.length < HEAD_CAP) pending.push(chunk);
pending.push(chunk);
expect(pending.capacity).toBeLessThanOrEqual(HEAD_CAP + chunk.length);
});
});

View File

@@ -28,7 +28,7 @@ vi.mock('@/lib/cache', () => ({
cache: { get: () => undefined, set: () => {} },
}));
import { getMapData } from '@/lib/mapService';
import { getMapData, visibleMapCounts } from '@/lib/mapService';
import { listedAlbumIds } from '@/lib/config/schema';
const ALBUM = { id: 'album-1', albumName: 'Trip', slug: 'trip' };
@@ -71,6 +71,24 @@ describe('getMapData coordinate filtering (#635)', () => {
expect(locations[0].albums[0].photoCount).toBe(1);
});
it('carries the cover’s edit state for its image URL (#831)', async () => {
const place = { latitude: 52.5, longitude: 13.4, city: 'Berlin', country: 'Germany' };
getAlbum.mockResolvedValue({
id: ALBUM.id,
assets: [{ ...asset('a', place), isEdited: true, updatedAt: '2026-09-04T19:22:09.220Z' }],
});
const [edited] = await getMapData();
expect(edited.albums[0].coverAssetId).toBe('a');
expect(edited.albums[0].coverEdit).toEqual({
isEdited: true,
updatedAt: '2026-09-04T19:22:09.220Z',
});
getAlbum.mockResolvedValue({ id: ALBUM.id, assets: [asset('b', place)] });
const [plain] = await getMapData();
expect(plain.albums[0].coverEdit).toBeUndefined();
});
it('still excludes a photo with no coordinates at all', async () => {
getAlbum.mockResolvedValue({
id: ALBUM.id,
@@ -191,3 +209,42 @@ describe('listedAlbumIds', () => {
expect([...ids].sort()).toEqual(['s', 'shared', 'v']);
});
});
/**
* The /map header counted protected subpages and albums that /api/map drops
* for a viewer who has not unlocked them, so the numbers did not match the map.
*/
describe('visibleMapCounts', () => {
const site = {
standaloneAlbums: ['solo', 'locked-solo'],
subpages: [
{ slug: 'travel', albumIds: ['t1', 't2'], enabled: true },
{ slug: 'clients', albumIds: ['c1'], enabled: true },
{ slug: 'offline', albumIds: ['o1'], enabled: false },
{ slug: 'secret', albumIds: ['h1'], enabled: true, hidden: true },
],
};
const lockedKeys = new Set(['subpage:clients', 'album:locked-solo', 'album:t2']);
const locked = (key: string, type: 'subpage' | 'album') => !lockedKeys.has(`${type}:${key}`);
it('counts everything listed when nothing is locked', () => {
expect(visibleMapCounts(site, () => true)).toEqual({ collections: 2, albums: 5 });
});
it('leaves out locked subpages, their albums and locked albums', () => {
// travel (t1), solo — clients, c1, locked-solo and t2 stay locked.
expect(visibleMapCounts(site, locked)).toEqual({ collections: 1, albums: 2 });
});
it('gates an album by the same subpage the map links it to', () => {
const shared = {
standaloneAlbums: [],
subpages: [
{ slug: 'clients', albumIds: ['x'], enabled: true },
{ slug: 'travel', albumIds: ['x'], enabled: true },
],
};
// /api/map gates `x` by `clients`, its first listed subpage.
expect(visibleMapCounts(shared, locked).albums).toBe(0);
});
});

View File

@@ -90,6 +90,30 @@ describe('pages-service', () => {
expect((await service.readPage('workshops'))?.parsed.frontmatter.title).toBe('Workshops');
});
it('takes no backup for a save that changes nothing (QA A-19)', async () => {
await service.writePage('pricing', page('v0'));
await service.writePage('pricing', page('v1'));
expect(await readdir(backupDir)).toHaveLength(1);
await service.writePage('pricing', page('v1'));
await service.writePage('pricing', page('v1'));
expect(await readdir(backupDir)).toHaveLength(1);
});
it("keeps a renamed page's history under its new slug, not as a deleted page (QA A-19)", async () => {
await service.writePage('pricing', page('Pricing'));
await service.savePage('rates', page('Rates'), { fromSlug: 'pricing' });
expect(await service.readPage('pricing')).toBeNull();
expect((await service.readPage('rates'))?.parsed.frontmatter.title).toBe('Rates');
const backups = await service.listPageBackups();
expect(backups).toEqual([expect.objectContaining({ slug: 'rates', kind: 'save' })]);
// Restoring it rolls the renamed page back instead of reviving the old slug.
await service.restorePageBackup(backups[0].filename);
expect((await service.readPage('rates'))?.parsed.frontmatter.title).toBe('Pricing');
expect(await service.readPage('pricing')).toBeNull();
});
it('refuses to restore from a name it did not write', async () => {
await expect(service.restorePageBackup('../gallery.yaml')).rejects.toThrow(/unrecognised/);
await expect(service.restorePageBackup('pricing.md')).rejects.toThrow(/unrecognised/);

View File

@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
import { THEME_PRESETS, contrastRatio } from '../config/theme';
/**
* The passepartout frame prints the EXIF line on the mat, in --mat-text on
* --mat-bg. Until v0.20.0 the mat never showed — the photo covered it, and the
* line sat on the photo at 1.3–2.4:1 — so its colours had never been checked.
*
* Tokens are resolved the way the cascade does on <html>, where every block
* of app/tokens.css matches at once: a preset's light block beats everything,
* then the later of the one-attribute blocks wins — and every preset block
* comes after the generic light block, so a preset that sets a mat token for
* dark mode only would carry it into light mode too.
*/
const css = fs.readFileSync(path.join(process.cwd(), 'app', 'tokens.css'), 'utf8');
function block(selector: string): string | undefined {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`(?:^|\\n)${escaped}\\s*\\{([^}]*)\\}`).exec(css)?.[1];
}
function declared(selector: string, name: string): string | undefined {
const body = block(selector);
if (!body) return undefined;
return new RegExp(`--${name}:\\s*([^;]+);`).exec(body)?.[1].trim();
}
function token(preset: string, mode: 'dark' | 'light', name: string): string | undefined {
const chain =
mode === 'dark'
? [`[data-preset='${preset}']`, ':root']
: [
`[data-preset='${preset}'][data-theme='light']`,
`[data-preset='${preset}']`,
"[data-theme='light']",
':root',
];
for (const selector of chain) {
const value = declared(selector, name);
if (value === undefined) continue;
const ref = /^var\(--([\w-]+)\)$/.exec(value);
return ref ? token(preset, mode, ref[1]) : value;
}
return undefined;
}
// WCAG AA for text at the caption's size (0.6875–0.75rem).
const TEXT_MIN = 4.5;
describe('passepartout mat tokens', () => {
it('are defined for both modes', () => {
expect(declared(':root', 'mat-bg')).toBeDefined();
expect(declared("[data-theme='light']", 'mat-bg')).toBeDefined();
});
it('are no longer hard-coded in the theme files', () => {
const themes = path.join(process.cwd(), 'app', 'themes');
const globals = fs.readFileSync(path.join(process.cwd(), 'app', 'globals.css'), 'utf8');
const sources = [
globals,
...fs.readdirSync(themes).map((f) => fs.readFileSync(path.join(themes, f), 'utf8')),
];
for (const source of sources) {
for (const rule of source.matchAll(/\[data-photo-frame='passepartout'\][^{]*\{([^}]*)\}/g)) {
expect(rule[1]).not.toMatch(/(background|border|color)[^;]*#[0-9a-f]{3,6}/i);
}
}
});
for (const preset of Object.keys(THEME_PRESETS)) {
for (const mode of ['dark', 'light'] as const) {
it(`${preset} (${mode}): the EXIF line is readable on the mat`, () => {
const bg = token(preset, mode, 'mat-bg')!;
const fg = token(preset, mode, 'mat-text')!;
expect(bg).toMatch(/^#[0-9a-f]{6}$/i);
expect(fg).toMatch(/^#[0-9a-f]{6}$/i);
expect(contrastRatio(fg, bg)!).toBeGreaterThanOrEqual(TEXT_MIN);
});
}
}
it('keeps the light mat light in a preset that tints it', () => {
// classic sets a warm dark mat; without its own light value that dark
// mat would win over the generic light one.
expect(token('classic', 'light', 'mat-bg')).toBe('#fffdf5');
expect(token('classic', 'dark', 'mat-bg')).toBe('#1a1816');
});
it('tones the mat to the wall in the gallery presets', () => {
// kunsthalle hangs on grey, salon on oxblood and plaster rose; the default
// black and white boards cut a hole in either wall.
for (const preset of ['kunsthalle', 'salon']) {
expect(token(preset, 'dark', 'mat-bg')).not.toBe(token('studio', 'dark', 'mat-bg'));
expect(token(preset, 'light', 'mat-bg')).not.toBe(token('studio', 'light', 'mat-bg'));
expect(token(preset, 'dark', 'mat-bg')).not.toBe(token(preset, 'light', 'mat-bg'));
}
});
});

View File

@@ -0,0 +1,173 @@
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
import { PRESET_REGISTRY, PRESET_IDS } from '../config/presets';
import {
THEME_PRESETS,
DEFAULT_PRESET,
resolveTheme,
contrastRatio,
accentForMode,
} from '../config/theme';
import { parseFamilies, MAX_FAMILIES } from '../fonts';
/**
* A preset is declared once, in PRESET_REGISTRY. The places that cannot import
* the registry — the stylesheets, the docs, a plain-node script — are held to
* it here, so adding a preset fails loudly where a step was missed instead of
* shipping a preset with no palette or no stylesheet.
*/
const root = process.cwd();
const read = (rel: string) => fs.readFileSync(path.join(root, rel), 'utf8');
const tokensCss = read('app/tokens.css').replace(/\/\*[\s\S]*?\*\//g, '');
const globalsCss = read('app/globals.css');
/** Custom properties declared by the rule with exactly this selector. */
function block(selector: string): Record<string, string> {
const out: Record<string, string> = {};
for (const m of tokensCss.matchAll(/([^{}]+)\{([^}]*)\}/g)) {
if (m[1].trim() !== selector) continue;
for (const d of m[2].matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) out[d[1]] = d[2].trim();
}
return out;
}
/** The surface and text tokens a preset palette has to define in each mode. */
const PALETTE_TOKENS = [
'--bg-primary',
'--bg-secondary',
'--bg-card',
'--bg-card-hover',
'--bg-overlay',
'--text-primary',
'--text-secondary',
'--text-muted',
'--border-subtle',
'--border-hover',
];
/**
* Surfaces the text tokens are held to 4.5:1 on. Light mode includes
* --bg-card-hover: studio-modern's light --text-muted (#73736e) measured 4.17:1
* on --bg-secondary and 4.36:1 on --bg-card-hover before it was darkened.
* Dark mode leaves --bg-card-hover out, where muted text sits at 4.1–4.3:1 in
* several presets.
*/
const TEXT_SURFACES = {
dark: ['--bg-primary', '--bg-secondary', '--bg-card'],
light: ['--bg-primary', '--bg-secondary', '--bg-card', '--bg-card-hover'],
} as const;
/**
* The first seven presets use their accent for dots, rules and fills, held to
* 3:1 by preset-contrast.test.ts. Presets added since also set links and
* labels in it, so their accent has to read as text: 4.5:1 on every surface.
*/
const ACCENT_AS_DECORATION_ONLY = new Set([
'studio-modern',
'studio',
'minimal',
'editorial',
'classic',
'noir',
'monograph',
]);
describe('preset registry', () => {
it('has unique ids and contains the default preset', () => {
expect(new Set(PRESET_IDS).size).toBe(PRESET_IDS.length);
expect(PRESET_IDS).toContain(DEFAULT_PRESET);
});
it('is what THEME_PRESETS and resolveTheme serve', () => {
expect(Object.keys(THEME_PRESETS)).toEqual([...PRESET_IDS]);
for (const id of PRESET_IDS) expect(resolveTheme(id).preset).toBe(id);
});
it('leaves only studio without its own styles', () => {
expect(PRESET_REGISTRY.filter((p) => !p.ownStyles).map((p) => p.id)).toEqual(['studio']);
});
it('keeps scripts/contrast-audit.mjs in step', () => {
const list = /const PRESETS = \[([^\]]*)\]/.exec(read('scripts/contrast-audit.mjs'))?.[1];
expect(list).toBeDefined();
const ids = [...list!.matchAll(/'([^']+)'/g)].map((m) => m[1]);
expect(ids).toEqual([...PRESET_IDS]);
});
for (const preset of PRESET_REGISTRY) {
describe(preset.id, () => {
it('has a label, an install tagline and an admin card description', () => {
expect(preset.label.trim()).not.toBe('');
expect(preset.tagline.trim()).not.toBe('');
expect(preset.description.trim()).not.toBe('');
for (const c of [preset.card.bg, preset.card.tile, preset.card.accent]) {
expect(c).toMatch(/^#[0-9a-f]{6}$/i);
}
});
it('names fonts lib/fonts.ts can serve', () => {
const { heading, body, caption } = preset.theme.fonts;
const families = parseFamilies([heading, body, caption]);
expect(families).not.toBeNull();
expect(families!.length).toBeLessThanOrEqual(MAX_FAMILIES);
});
it('is documented', () => {
expect(read('docs/theming.md')).toContain(`| **${preset.id}**`);
expect(read('content/settings.yaml.example')).toContain(preset.id);
});
if (!preset.ownStyles) return;
it('has a stylesheet that globals.css imports', () => {
expect(fs.existsSync(path.join(root, 'app/themes', `${preset.id}.css`))).toBe(true);
expect(globalsCss).toContain(`@import url('./themes/${preset.id}.css');`);
});
it('defines its palette in app/tokens.css for both colour modes', () => {
const dark = block(`[data-preset='${preset.id}']`);
const light = block(`[data-preset='${preset.id}'][data-theme='light']`);
for (const token of PALETTE_TOKENS) {
expect(dark[token], `${token} (dark)`).toBeDefined();
expect(light[token], `${token} (light)`).toBeDefined();
}
});
it('keeps its text tokens at 4.5:1 on every surface, in both modes', () => {
for (const mode of ['dark', 'light'] as const) {
const vars = {
...block(`[data-preset='${preset.id}']`),
...(mode === 'light' ? block(`[data-preset='${preset.id}'][data-theme='light']`) : {}),
};
for (const text of ['--text-primary', '--text-secondary', '--text-muted']) {
for (const surface of TEXT_SURFACES[mode]) {
const ratio = contrastRatio(vars[text], vars[surface]);
// Palettes written as rgba() are measured by the browser audit
// (npm run audit:contrast), not here.
if (ratio === null) continue;
expect(ratio, `${mode} ${text} on ${surface}`).toBeGreaterThanOrEqual(4.5);
}
}
}
});
if (ACCENT_AS_DECORATION_ONLY.has(preset.id)) return;
it('keeps its accent readable as text on every surface, in both modes', () => {
for (const mode of ['dark', 'light'] as const) {
const vars = {
...block(`[data-preset='${preset.id}']`),
...(mode === 'light' ? block(`[data-preset='${preset.id}'][data-theme='light']`) : {}),
};
const accent = accentForMode(resolveTheme(preset.id), mode);
for (const surface of ['--bg-primary', '--bg-secondary', '--bg-card']) {
const ratio = contrastRatio(accent, vars[surface]);
expect(ratio, `${mode} accent on ${surface}`).toBeGreaterThanOrEqual(4.5);
}
}
});
});
}
});

View File

@@ -35,13 +35,16 @@ const config = (over: Partial<AppConfig> = {}) =>
analytics: false,
contact: { enabled: false, retentionDays: 90 },
proofing: { enabled: false },
subpages: [],
privacy: { enabled: true },
...over,
}) as unknown as AppConfig;
describe('processingFacts', () => {
it('lists only what this installation actually does', () => {
const topics = processingFacts(config(), { hasPasswords: false }).map((f) => f.topic);
const topics = processingFacts(config(), { hasPasswords: false, hasProofingLinks: false }).map(
(f) => f.topic,
);
expect(topics).toEqual(['Photos', 'Server log', 'Fonts', 'Browser storage']);
});
@@ -51,7 +54,7 @@ describe('processingFacts', () => {
map: true,
contact: { enabled: true, retentionDays: 30, notifyUrl: 'https://ntfy.sh/secret' },
}),
{ hasPasswords: true, CDN_URL: 'https://cdn.example.net' },
{ hasPasswords: true, hasProofingLinks: false, CDN_URL: 'https://cdn.example.net' },
);
const third = facts.filter((f) => f.thirdParty).map((f) => f.topic);
// The notification carries nothing about the sender (#702), so the
@@ -65,8 +68,58 @@ describe('processingFacts', () => {
expect(facts.some((f) => f.topic === 'Cookies')).toBe(true);
});
it('keeps anonymous favourites in the browser, also when only a subpage turns them on', () => {
const storage = (c: AppConfig) =>
processingFacts(c, { hasPasswords: false, hasProofingLinks: false }).find(
(f) => f.topic === 'Browser storage',
)!.detail;
expect(storage(config())).not.toContain('favourites');
expect(storage(config({ proofing: { enabled: true } } as Partial<AppConfig>))).toContain(
'favourites',
);
const subpageOnly = config({
subpages: [{ proofing: true }],
} as unknown as Partial<AppConfig>);
expect(storage(subpageOnly)).toContain('favourites');
expect(storage(subpageOnly)).toContain('localStorage');
});
it('says that proofing links store the client’s selection on this server', () => {
const none = processingFacts(config({ proofing: { enabled: true } } as Partial<AppConfig>), {
hasPasswords: false,
hasProofingLinks: false,
});
expect(none.some((f) => f.topic === 'Proofing links')).toBe(false);
// Links do not depend on the proofing switch, so the fact does not either.
const facts = processingFacts(config(), { hasPasswords: false, hasProofingLinks: true });
const links = facts.find((f) => f.topic === 'Proofing links')!;
expect(links.detail).toContain('content/proofing.json');
expect(links.detail).toContain('client name');
expect(links.detail).toContain('until you delete the link');
expect(links.thirdParty).toBe(false);
expect(starterHeadings('de', facts)).toContain('## Bildauswahl durch Kunden');
expect(facts.find((f) => f.topic === 'Browser storage')!.detail).not.toContain(
'never sent to the server',
);
});
it('names the proofing webhook host, since its notification carries the client name', () => {
const links = processingFacts(config(), {
hasPasswords: false,
hasProofingLinks: true,
proofingWebhookUrl: 'https://discord.com/api/webhooks/123/secret',
}).find((f) => f.topic === 'Proofing links')!;
expect(links.thirdParty).toBe(true);
expect(links.detail).toContain('discord.com');
expect(links.detail).not.toContain('secret');
});
it('turns the facts into headings in the site language, and nothing else', () => {
const facts = processingFacts(config({ map: true }), { hasPasswords: false });
const facts = processingFacts(config({ map: true }), {
hasPasswords: false,
hasProofingLinks: false,
});
const de = starterHeadings('de', facts);
expect(de).toContain('## Kartendienst');
expect(de).not.toContain('Kontaktformular');

View File

@@ -44,6 +44,7 @@ describe('proxy', () => {
beforeEach(() => {
mockUnlocked.mockReturnValue(true);
mockCdnOrigin.mockReturnValue(null);
mockConfig.mockReturnValue(null);
});
describe('CDN mode', () => {
@@ -256,6 +257,44 @@ describe('proxy', () => {
expect(rewrittenTo(run('/install'))).toBeNull();
});
it('serves the legal notice and the privacy policy to a locked site', () => {
mockUnlocked.mockReturnValue(false);
mockConfig.mockReturnValue({
contact: { enabled: true },
legal: { enabled: true },
map: true,
privacy: { enabled: true },
});
// The gate page is public and links both; an Impressum behind a password
// is not "unmittelbar erreichbar". The pages still get their CSP.
// The contact form goes with them: the Impressum links it as its second
// contact channel.
for (const pathname of ['/impressum', '/privacy', '/contact']) {
const res = run(pathname);
expect(rewrittenTo(res)).toBeNull();
expect(res.headers.get('Content-Security-Policy')).toBeTruthy();
}
// Exact paths only: nothing below or beside them slips through.
for (const pathname of ['/', '/contact/x', '/impressum/x', '/privacy-trip', '/japan']) {
expect(rewrittenTo(run(pathname))).toContain('/gate');
}
});
it('still answers 404 for a legal or contact page that is switched off on a locked site', () => {
mockUnlocked.mockReturnValue(false);
mockConfig.mockReturnValue({
contact: { enabled: false },
legal: { enabled: false },
map: false,
privacy: { enabled: false },
});
for (const pathname of ['/impressum', '/privacy', '/contact']) {
const res = run(pathname);
expect(rewrittenTo(res)).toBeNull();
expect(res.status).toBe(404);
}
});
it('does not rewrite the gate to itself', () => {
mockUnlocked.mockReturnValue(false);
expect(rewrittenTo(run('/gate'))).toBeNull();
@@ -346,11 +385,20 @@ describe('isKnownMissing', () => {
? hasRoute(path.join(dir, entry.name))
: /^(page|route)\.(tsx?|jsx?)$/.test(entry.name),
);
const routeDirs = fs
.readdirSync(appDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name !== '[...path]')
.filter((entry) => hasRoute(path.join(appDir, entry.name)))
.map((entry) => entry.name);
// A route group such as app/(home) adds no URL segment: its children are
// top-level routes themselves.
const topLevelDirs = (dir: string): string[] =>
fs
.readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name !== '[...path]')
.flatMap((entry) =>
/^\(.*\)$/.test(entry.name)
? topLevelDirs(path.join(dir, entry.name))
: hasRoute(path.join(dir, entry.name))
? [entry.name]
: [],
);
const routeDirs = topLevelDirs(appDir);
expect(routeDirs.length).toBeGreaterThan(5);
for (const name of routeDirs) expect(TOP_LEVEL_ROUTES.has(name), name).toBe(true);
});

View File

@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { validateSettingValues } from '../config/settingValues';
import { resolveTheme } from '../config/theme';
import { resolveContact, sanitizeNavLinks } from '../config';
import { normaliseSiteUrl } from '../siteUrl';
const fields = (input: unknown) => validateSettingValues(input).map((e) => e.field);
@@ -50,4 +51,48 @@ describe('validateSettingValues', () => {
expect(fields({ grid: { columns: 1, gap: 0 } })).toEqual([]);
expect(fields({ grid: { columns: 6, gap: 48 } })).toEqual([]);
});
it('refuses a corner radius resolveTheme would clamp, and only that', () => {
for (const radius of [-1, 65, 200, Number.NaN]) {
expect(fields({ theme: { radius } })).toEqual(['theme.radius']);
expect(resolveTheme({ preset: 'minimal', radius }).radius).not.toBe(radius);
}
for (const radius of [0, 8, 1.5, 64]) {
expect(fields({ theme: { radius } })).toEqual([]);
expect(resolveTheme({ preset: 'minimal', radius }).radius).toBe(radius);
}
});
it('refuses a retention resolveContact would round or clamp, and only that', () => {
for (const retentionDays of [0, -3, 366, 30.5]) {
expect(fields({ contact: { retentionDays } })).toEqual(['contact.retentionDays']);
expect(resolveContact({ retentionDays }).retentionDays).not.toBe(retentionDays);
}
for (const retentionDays of [1, 90, 365]) {
expect(fields({ contact: { retentionDays } })).toEqual([]);
expect(resolveContact({ retentionDays }).retentionDays).toBe(retentionDays);
}
expect(fields({ contact: { enabled: true } })).toEqual([]);
});
it('refuses exactly the header links sanitizeNavLinks drops, per input', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const links = [
{ label: 'Shop', url: 'https://shop.example' },
{ label: 'Blog', url: 'blog.example' },
{ label: ' ', url: 'http://ok.example' },
{ label: 'Evil', url: 'javascript:alert(1)' },
{ label: '', url: '' },
];
expect(fields({ navLinks: links })).toEqual([
'navLinks.1.url',
'navLinks.2.label',
'navLinks.3.url',
'navLinks.4.label',
'navLinks.4.url',
]);
// The resolver keeps exactly the entries with no error.
expect(sanitizeNavLinks(links)).toEqual([{ label: 'Shop', url: 'https://shop.example' }]);
warn.mockRestore();
});
});

View File

@@ -0,0 +1,86 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { THEME_INIT_SCRIPT } from '../themeScript';
/**
* The inline script that sets the colour mode before the first paint (T-10).
* Without it a visitor who stored "light" — or runs a light OS under
* `mode: auto` — saw the server's dark guess until ThemeToggle's bundle ran.
*/
let osLight = false;
function run(defaultTheme: string | null, serverTheme: string | null = defaultTheme) {
const html = document.documentElement;
html.removeAttribute('data-theme');
html.removeAttribute('data-default-theme');
if (defaultTheme !== null) html.setAttribute('data-default-theme', defaultTheme);
if (serverTheme !== null && serverTheme !== 'auto') html.setAttribute('data-theme', serverTheme);
new Function(THEME_INIT_SCRIPT)();
return html.getAttribute('data-theme');
}
// Node 26 defines its own global localStorage (undefined without
// --localstorage-file), which shadows jsdom's; a Map-backed stub stands in.
function stubStorage() {
const store = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
});
}
beforeEach(() => {
osLight = false;
stubStorage();
vi.stubGlobal('matchMedia', (query: string) => ({
media: query,
matches: query === '(prefers-color-scheme: light)' ? osLight : false,
}));
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('THEME_INIT_SCRIPT', () => {
it('applies a stored choice over the configured mode', () => {
localStorage.setItem('theme', 'light');
expect(run('dark')).toBe('light');
localStorage.setItem('theme', 'dark');
expect(run('light')).toBe('dark');
});
it('keeps the configured mode for a visitor who never chose', () => {
expect(run('light')).toBe('light');
expect(run('dark')).toBe('dark');
});
it('follows the OS under mode: auto', () => {
osLight = true;
expect(run('auto')).toBe('light');
osLight = false;
expect(run('auto')).toBe('dark');
});
it('ignores a stored value that is not a mode', () => {
localStorage.setItem('theme', 'purple');
osLight = true;
expect(run('auto')).toBe('light');
});
it('falls back to the configured mode when storage throws', () => {
vi.stubGlobal('localStorage', {
getItem: () => {
throw new Error('SecurityError');
},
});
expect(run('light')).toBe('light');
});
/** It is inlined as a string: a `</script>` inside would end it early. */
it('is safe to inline', () => {
expect(THEME_INIT_SCRIPT).not.toMatch(/<\/script/i);
});
});

View File

@@ -18,6 +18,7 @@ import {
assetExifSummary,
assetAspectRatio,
assetCaption,
editMarker,
} from '@/lib/urls';
describe('imageUrl', () => {
@@ -32,6 +33,50 @@ describe('imageUrl', () => {
});
});
describe('imageUrl for photos edited in Immich (#831)', () => {
const UPDATED = '2026-09-04T19:22:09.220Z';
it('adds the edit marker only for an edited photo', () => {
const marker = Math.floor(Date.parse(UPDATED) / 1000).toString(36);
expect(imageUrl({ id: 'id', isEdited: true, updatedAt: UPDATED })).toBe(
`/api/image/ENCODED_id?size=preview&e=${marker}`,
);
expect(imageUrl({ id: 'id', isEdited: true, updatedAt: UPDATED }, 'thumbnail')).toBe(
`/api/image/ENCODED_id?size=thumbnail&e=${marker}`,
);
});
it('leaves every other URL exactly as before', () => {
expect(imageUrl({ id: 'id', isEdited: false, updatedAt: UPDATED })).toBe(
'/api/image/ENCODED_id?size=preview',
);
expect(imageUrl({ id: 'id', updatedAt: UPDATED })).toBe('/api/image/ENCODED_id?size=preview');
expect(imageUrl({ id: 'id' })).toBe(imageUrl('id'));
});
it('changes the marker when the photo is edited again', () => {
const first = editMarker({ isEdited: true, updatedAt: UPDATED });
const again = editMarker({ isEdited: true, updatedAt: '2026-10-01T08:00:00.000Z' });
expect(first).toMatch(/^[0-9a-z]{1,16}$/);
expect(again).toMatch(/^[0-9a-z]{1,16}$/);
expect(again).not.toBe(first);
});
it('reveals the update time to the second only (review of #832)', () => {
const marker = editMarker({ isEdited: true, updatedAt: UPDATED });
expect(marker).toBe((Date.parse('2026-09-04T19:22:09Z') / 1000).toString(36));
expect(editMarker({ isEdited: true, updatedAt: '2026-09-04T19:22:09.999Z' })).toBe(marker);
expect(editMarker({ isEdited: true, updatedAt: '2026-09-04T19:22:10.000Z' })).not.toBe(marker);
});
it('still marks an edited photo whose update time is unknown', () => {
expect(editMarker({ isEdited: true })).toBe('1');
expect(editMarker({ isEdited: true, updatedAt: 'not a date' })).toBe('1');
expect(editMarker({ isEdited: false, updatedAt: UPDATED })).toBeNull();
expect(editMarker({})).toBeNull();
});
});
describe('exifUrl', () => {
it('generates an EXIF API URL with encoded token', () => {
expect(exifUrl('my-asset')).toBe('/api/exif/ENCODED_my-asset');
@@ -144,6 +189,68 @@ describe('assetAspectRatio', () => {
expect(assetAspectRatio({ exifInfo: undefined })).toBeUndefined();
});
/**
* #831, measured on Immich 3.2: EXIF keeps describing the stored file after
* an edit in Immich, while `width`/`height` follow the edit.
*/
describe('a photo edited in Immich', () => {
const exif = (w: number, h: number, orientation: string | null = null) => ({
make: null,
model: null,
lensModel: null,
focalLength: null,
fNumber: null,
exposureTime: null,
iso: null,
exifImageWidth: w,
exifImageHeight: h,
orientation,
latitude: null,
longitude: null,
city: null,
state: null,
country: null,
dateTimeOriginal: null,
description: null,
});
it('takes a quarter turn from the edited size', () => {
// A scan stored 1076×723, rotated 270° in Immich: shown 723×1076.
expect(
assetAspectRatio({ isEdited: true, width: 723, height: 1076, exifInfo: exif(1076, 723) }),
).toBeCloseTo(723 / 1076);
});
it('takes a crop from the edited size', () => {
// Stored 960×1280, cropped to 872×1132.
expect(
assetAspectRatio({ isEdited: true, width: 872, height: 1132, exifInfo: exif(960, 1280) }),
).toBeCloseTo(872 / 1132);
// Stored 8064×6048 with orientation 6 (portrait), cropped to landscape.
expect(
assetAspectRatio({
isEdited: true,
width: 6048,
height: 4838,
exifInfo: exif(8064, 6048, '6'),
}),
).toBeCloseTo(6048 / 4838);
});
it('keeps EXIF for a photo that is not edited', () => {
expect(
assetAspectRatio({ isEdited: false, width: 723, height: 1076, exifInfo: exif(3000, 2000) }),
).toBe(1.5);
});
it('does not fall back to EXIF, which describes the unedited file (review of #832)', () => {
expect(assetAspectRatio({ isEdited: true, exifInfo: exif(3000, 2000) })).toBeUndefined();
expect(
assetAspectRatio({ isEdited: true, width: null, height: 0, exifInfo: exif(3000, 2000) }),
).toBeUndefined();
});
});
it('returns undefined when height is 0', () => {
expect(
assetAspectRatio({

View File

@@ -11,7 +11,9 @@ vi.mock('fs/promises', () => ({
unlink: vi.fn(async () => undefined),
writeFile: vi.fn(async () => undefined),
rename: vi.fn(async () => undefined),
readFile: vi.fn(),
readFile: vi.fn(async () => {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
}),
},
}));
@@ -95,7 +97,7 @@ describe('writeYamlFile backup failure', () => {
});
it('aborts the save when the file exists but the backup copy fails', async () => {
vi.mocked(fs.access).mockResolvedValueOnce(undefined);
vi.mocked(fs.readFile).mockResolvedValueOnce(Buffer.from('albums: []\n'));
vi.mocked(fs.copyFile).mockRejectedValueOnce(
Object.assign(new Error('EACCES'), { code: 'EACCES' }),
);
@@ -105,7 +107,7 @@ describe('writeYamlFile backup failure', () => {
});
it('aborts when checking whether the file exists fails for a reason other than ENOENT', async () => {
vi.mocked(fs.access).mockRejectedValueOnce(
vi.mocked(fs.readFile).mockRejectedValueOnce(
Object.assign(new Error('EACCES'), { code: 'EACCES' }),
);
@@ -113,3 +115,24 @@ describe('writeYamlFile backup failure', () => {
expect(fs.writeFile).not.toHaveBeenCalled();
});
});
/**
* A save that changes nothing used to snapshot anyway; with ten backups kept,
* a few of those pushed the real ones out (QA A-19).
*/
describe('writeYamlFile no-op save', () => {
beforeEach(() => vi.clearAllMocks());
it('takes no backup when the file already holds exactly these bytes', async () => {
await writeGalleryYaml(gallery);
const written = vi.mocked(fs.writeFile).mock.calls[0][1] as string;
vi.mocked(fs.readFile).mockResolvedValueOnce(Buffer.from(written));
await writeGalleryYaml(gallery);
expect(fs.copyFile).not.toHaveBeenCalled();
vi.mocked(fs.readFile).mockResolvedValueOnce(Buffer.from(written + '# edited\n'));
await writeGalleryYaml(gallery);
expect(fs.copyFile).toHaveBeenCalledTimes(1);
});
});

View File

@@ -17,6 +17,7 @@ import {
ARCHIVE_STALL_TIMEOUT_MS,
MAX_CONCURRENT_ARCHIVES,
__archivesInFlight,
entryDate,
withArchiveSlot,
} from '@/lib/zipArchive';
import { immich, type ImmichAsset } from '@/lib/immich';
@@ -211,3 +212,55 @@ describe('archive stall timeout', () => {
await vi.waitFor(() => expect(__archivesInFlight(ip)).toBe(0));
});
});
/**
* F-16: every entry carried the time the ZIP was built. Each now carries the
* capture time, as the wall clock of the camera.
*/
describe('archive entry timestamps', () => {
/** The DOS date/time of every local file header, as `YYYY-MM-DD HH:MM:SS`. */
function entryTimes(bytes: Uint8Array): string[] {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const out: string[] = [];
for (let i = 0; i + 30 <= bytes.length; i++) {
if (view.getUint32(i, true) !== 0x04034b50) continue;
const time = view.getUint16(i + 10, true);
const date = view.getUint16(i + 12, true);
const pad = (n: number) => String(n).padStart(2, '0');
out.push(
`${(date >> 9) + 1980}-${pad((date >> 5) & 15)}-${pad(date & 31)} ` +
`${pad(time >> 11)}:${pad((time >> 5) & 63)}:${pad((time & 31) * 2)}`,
);
}
return out;
}
it('dates each entry by when the photo was taken, on the camera clock', async () => {
const assets = [
{
id: 'a1',
originalFileName: 'a1.jpg',
// 14:30 in Berlin; the instant is 12:30 UTC.
localDateTime: '2024-05-01T14:30:10.000Z',
exifInfo: { dateTimeOriginal: '2024-05-01T12:30:10.000Z' },
fileCreatedAt: '2024-05-01T12:30:10.000Z',
},
{
id: 'a2',
originalFileName: 'a2.jpg',
exifInfo: { dateTimeOriginal: '2019-12-24T18:00:00.000Z' },
fileCreatedAt: '2020-01-01T00:00:00.000Z',
},
{ id: 'a3', originalFileName: 'a3.jpg', fileCreatedAt: '2021-07-04T09:15:00.000Z' },
] as unknown as ImmichAsset[];
const res = await start(freshIp(), assets);
const times = entryTimes(new Uint8Array(await res.arrayBuffer()));
expect(times).toEqual(['2024-05-01 14:30:10', '2019-12-24 18:00:00', '2021-07-04 09:15:00']);
});
it('falls back to the default for an asset with no usable date', () => {
expect(entryDate({ fileCreatedAt: 'not a date' } as unknown as ImmichAsset)).toBeUndefined();
});
});

View File

@@ -0,0 +1,164 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('@/lib/env', () => ({
env: {
IMMICH_API_URL: 'http://localhost:2283',
IMMICH_API_KEY: 'test-key',
SITE_TITLE: 'Test',
SITE_SUBTITLE: '',
CACHE_TTL: 300,
RATE_LIMIT_RPM: 120,
},
normalizeApiUrl: (raw: string) => raw.replace(/\/+$/, ''),
}));
vi.mock('@/lib/secret', () => ({
resolveAuthSecret: () => 'test-auth-secret-32-chars-long-min',
}));
vi.mock('@/lib/config/parser', () => ({
loadYaml: vi.fn(),
clearYamlCache: vi.fn(),
validateUuid: (id: string) => id,
}));
import { deriveGallery, getConfig, invalidateConfigCache, resolveZoom } from '@/lib/config';
import { loadYaml } from '@/lib/config/parser';
import type { GalleryYaml } from '@/lib/config/schema';
const A = '11111111-1111-1111-1111-111111111111';
const B = '22222222-2222-2222-2222-222222222222';
/**
* Lightbox zoom (#467): a site-wide switch in settings.yaml, off by default,
* overridden by a subpage and then by an album, each in either direction.
*/
describe('resolveZoom', () => {
const config = (zoom: boolean, standaloneAlbumZoom: Record<string, boolean> = {}) => ({
zoom,
standaloneAlbumZoom,
});
it('follows the site setting when nothing more specific is set', () => {
expect(resolveZoom(config(false), A)).toBe(false);
expect(resolveZoom(config(true), A)).toBe(true);
expect(resolveZoom(config(true), A, {})).toBe(true);
});
it('lets a subpage override the site, both ways', () => {
expect(resolveZoom(config(false), A, { zoom: true })).toBe(true);
expect(resolveZoom(config(true), A, { zoom: false })).toBe(false);
});
it('lets an album entry override its subpage and the site, both ways', () => {
expect(resolveZoom(config(false), A, { zoom: false, albumZoom: { [A]: true } })).toBe(true);
expect(resolveZoom(config(true), A, { zoom: true, albumZoom: { [A]: false } })).toBe(false);
// Only that album.
expect(resolveZoom(config(true), B, { zoom: true, albumZoom: { [A]: false } })).toBe(true);
});
it('reads the standalone override only for the standalone route', () => {
const c = config(true, { [A]: false });
expect(resolveZoom(c, A)).toBe(false);
// On a subpage the standalone entry has no say.
expect(resolveZoom(c, A, {})).toBe(true);
});
});
describe('deriveGallery keeps album zoom per route (review of #830)', () => {
/*
* The reported leak: one album on an open page with zoom off and on a
* password page with zoom on. A single map let the last entry decide for
* both, so the open page served zoom its owner had switched off.
*/
const pages = (first: boolean) => {
const open = { name: 'Public', albums: [{ [A]: { zoom: false } }] };
const locked = { name: 'Client', password: 'pw', albums: [{ [A]: { zoom: true } }] };
return deriveGallery({ subpages: first ? [open, locked] : [locked, open] } as GalleryYaml);
};
it.each([true, false])('keeps each page’s own value (open page first: %s)', (openFirst) => {
const derived = pages(openFirst);
const open = derived.subpages.find((sp) => sp.slug === 'public')!;
const locked = derived.subpages.find((sp) => sp.slug === 'client')!;
const cfg = { zoom: false, standaloneAlbumZoom: derived.standaloneAlbumZoom };
expect(resolveZoom(cfg, A, open)).toBe(false);
expect(resolveZoom(cfg, A, locked)).toBe(true);
});
it('keeps a standalone entry apart from a subpage entry for the same album', () => {
const derived = deriveGallery({
albums: [{ [A]: { zoom: true } }],
subpages: [{ name: 'S', albums: [{ [A]: { zoom: false } }] }],
} as GalleryYaml);
expect(derived.standaloneAlbumZoom).toEqual({ [A]: true });
expect(derived.subpages[0].albumZoom).toEqual({ [A]: false });
});
it('collects section entries on their subpage, and false wins within one page', () => {
const derived = deriveGallery({
subpages: [
{
name: 'S',
albums: [{ [B]: { zoom: true } }],
sections: [
{ title: 'One', albums: [{ [A]: { zoom: true } }] },
{ title: 'Two', albums: [{ [A]: { zoom: false } }] },
],
},
{ name: 'T', albums: [A] },
],
} as GalleryYaml);
expect(derived.subpages[0].albumZoom).toEqual({ [A]: false, [B]: true });
expect(derived.subpages[1].albumZoom).toBeUndefined();
});
it('ignores anything but a boolean', () => {
const odd = deriveGallery({ albums: [{ [A]: { zoom: 'yes' } }] } as unknown as GalleryYaml);
expect(odd.standaloneAlbumZoom).toEqual({});
});
it('keeps a subpage’s zoom in both gallery.yaml shapes', () => {
const list = deriveGallery({
subpages: [
{ name: 'On', albums: [A], zoom: true },
{ name: 'Off', albums: [B], zoom: false },
{ name: 'Unset', albums: [A] },
],
} as GalleryYaml);
expect(list.subpages.map((sp) => sp.zoom)).toEqual([true, false, undefined]);
const record = deriveGallery({
subpages: { Travel: { albums: [{ [A]: { zoom: false } }], zoom: true } },
} as unknown as GalleryYaml);
expect(record.subpages[0].zoom).toBe(true);
expect(record.subpages[0].albumZoom).toEqual({ [A]: false });
});
});
describe('the site setting', () => {
beforeEach(() => invalidateConfigCache());
const withSettings = (settings: Record<string, unknown>) =>
vi.mocked(loadYaml).mockImplementation((filename: string) => {
if (filename === 'gallery.yaml') return { albums: [{ [A]: { zoom: false } }] };
if (filename === 'settings.yaml') return settings;
return null;
});
it('is off unless settings.yaml says true', () => {
withSettings({});
expect(getConfig().zoom).toBe(false);
invalidateConfigCache();
withSettings({ zoom: 'true' });
expect(getConfig().zoom).toBe(false);
invalidateConfigCache();
withSettings({ zoom: true });
expect(getConfig().zoom).toBe(true);
});
it('carries the standalone album overrides into the config', () => {
withSettings({ zoom: true });
expect(getConfig().standaloneAlbumZoom).toEqual({ [A]: false });
});
});

203
lib/__tests__/zoom.test.ts Normal file
View File

@@ -0,0 +1,203 @@
import { describe, it, expect } from 'vitest';
import {
FIT_VIEW,
canZoom,
clampView,
isZoomed,
maxZoomScale,
oneToOneScale,
panBy,
pinchView,
stepView,
toggleView,
TOUCH_ZOOM_MAX_PIXELS,
zoomFitsDevice,
zoomAround,
type FitBox,
type ZoomView,
} from '../zoom';
/**
* The lightbox zoom geometry (#467). A 1000×600 fit box centred in a
* 1200×800 viewport, showing a 6000×3600 photo: 1:1 is six times the fit size
* on a 1× screen, three times on a 2× one.
*/
const viewport = { width: 1200, height: 800 };
const fit: FitBox = { cx: 600, cy: 400, width: 1000, height: 600 };
const NATURAL = 6000;
/** Where a photo point (offset from the box centre at fit) lands on screen. */
const screenOf = (view: ZoomView, u: number, v: number) => ({
x: fit.cx + view.x + u * view.scale,
y: fit.cy + view.y + v * view.scale,
});
describe('1:1 scale', () => {
it('is one image pixel per device pixel', () => {
expect(oneToOneScale(NATURAL, 1000, 1)).toBe(6);
expect(oneToOneScale(NATURAL, 1000, 2)).toBe(3);
expect(oneToOneScale(NATURAL, 1000, 3)).toBe(2);
});
it('accounts for a fractional ratio', () => {
expect(oneToOneScale(NATURAL, 1000, 1.5)).toBeCloseTo(4);
});
it('treats a missing or nonsense ratio as 1', () => {
expect(oneToOneScale(NATURAL, 1000, 0)).toBe(6);
expect(oneToOneScale(NATURAL, 1000, NaN)).toBe(6);
});
it('never goes below fit', () => {
// A 1500px photo on a 3× screen is already shown beyond 1:1 at fit.
expect(oneToOneScale(1500, 1000, 3)).toBe(0.5);
expect(maxZoomScale(1500, 1000, 3)).toBe(1);
expect(canZoom(maxZoomScale(1500, 1000, 3))).toBe(false);
expect(canZoom(maxZoomScale(NATURAL, 1000, 2))).toBe(true);
});
it('is 1 when a size is unknown', () => {
expect(oneToOneScale(0, 1000, 1)).toBe(1);
expect(oneToOneScale(NATURAL, 0, 1)).toBe(1);
});
});
describe('clamping', () => {
const max = 6;
it('keeps the scale between fit and 1:1', () => {
expect(clampView({ scale: 10, x: 0, y: 0 }, fit, viewport, max).scale).toBe(6);
expect(clampView({ scale: 0.5, x: 30, y: 30 }, fit, viewport, max)).toEqual(FIT_VIEW);
});
it('never shows an empty band beside a photo larger than the viewport', () => {
// At 6× the box is 6000×3600: its left edge may go no further right than
// the viewport's left edge, and so on.
const v = clampView({ scale: 6, x: 99999, y: -99999 }, fit, viewport, max);
expect(fit.cx + v.x - (fit.width * 6) / 2).toBeCloseTo(0);
expect(fit.cy + v.y + (fit.height * 6) / 2).toBeCloseTo(viewport.height);
});
it('keeps a photo smaller than the viewport on screen, where it was', () => {
// 1.1× is 1100 wide in a 1200 viewport: it may move 50px either way.
const centred = clampView({ scale: 1.1, x: 0, y: 0 }, fit, viewport, max);
expect(centred.x).toBe(0);
const pushed = clampView({ scale: 1.1, x: 500, y: 0 }, fit, viewport, max);
expect(pushed.x).toBeCloseTo(50);
});
it('pans by a delta, within the same bounds', () => {
const start = { scale: 3, x: 0, y: 0 };
expect(panBy(start, 100, -50, fit, viewport, max)).toEqual({ scale: 3, x: 100, y: -50 });
// 3000 wide in 1200: at most 900 either side of centre.
expect(panBy(start, 5000, 0, fit, viewport, max).x).toBeCloseTo(900);
});
});
describe('zooming around a point', () => {
const max = 6;
it('keeps the photo point under the cursor where it was', () => {
const point = { x: 700, y: 450 };
// Photo point under the cursor at fit:
const u = point.x - fit.cx;
const v = point.y - fit.cy;
const next = zoomAround(FIT_VIEW, 3, point, fit, viewport, max);
expect(next.scale).toBe(3);
const after = screenOf(next, u, v);
expect(after.x).toBeCloseTo(point.x);
expect(after.y).toBeCloseTo(point.y);
});
it('keeps it fixed from an already zoomed, panned view too', () => {
const start = { scale: 2, x: -150, y: 80 };
const point = { x: 400, y: 300 };
const u = (point.x - fit.cx - start.x) / start.scale;
const v = (point.y - fit.cy - start.y) / start.scale;
const next = zoomAround(start, 4, point, fit, viewport, max);
const after = screenOf(next, u, v);
expect(after.x).toBeCloseTo(point.x);
expect(after.y).toBeCloseTo(point.y);
});
it('gives way to the clamp at the photo edge', () => {
// A point at the very corner cannot stay put without exposing the
// background, so the photo edge wins.
const next = zoomAround(FIT_VIEW, 6, { x: 100, y: 100 }, fit, viewport, max);
expect(fit.cx + next.x - (fit.width * 6) / 2).toBeCloseTo(0);
});
it('double-click goes to 1:1 at the point, and back to fit', () => {
const point = { x: 650, y: 420 };
const zoomed = toggleView(FIT_VIEW, point, fit, viewport, max);
expect(zoomed.scale).toBe(max);
expect(isZoomed(zoomed)).toBe(true);
const after = screenOf(zoomed, point.x - fit.cx, point.y - fit.cy);
expect(after.x).toBeCloseTo(point.x);
expect(toggleView(zoomed, point, fit, viewport, max)).toEqual(FIT_VIEW);
});
it('steps by a factor around the viewport centre, and snaps back to fit', () => {
const once = stepView(FIT_VIEW, 2, fit, viewport, max);
expect(once.scale).toBe(2);
expect(stepView(stepView(once, 2, fit, viewport, max), 2, fit, viewport, max).scale).toBe(6);
expect(stepView(once, 0.5, fit, viewport, max)).toEqual(FIT_VIEW);
expect(stepView(once, 0.51, fit, viewport, max)).toEqual(FIT_VIEW);
});
});
describe('pinch', () => {
const max = 6;
it('scales by how far the fingers spread, around their midpoint', () => {
const mid = { x: 640, y: 380 };
const next = pinchView(FIT_VIEW, mid, 100, mid, 250, fit, viewport, max);
expect(next.scale).toBeCloseTo(2.5);
const after = screenOf(next, mid.x - fit.cx, mid.y - fit.cy);
expect(after.x).toBeCloseTo(mid.x);
expect(after.y).toBeCloseTo(mid.y);
});
it('follows the midpoint as the fingers move', () => {
const startMid = { x: 600, y: 400 };
const mid = { x: 660, y: 430 };
const next = pinchView({ scale: 3, x: 0, y: 0 }, startMid, 100, mid, 100, fit, viewport, max);
expect(next).toEqual({ scale: 3, x: 60, y: 30 });
});
it('pinching back past fit lands on fit', () => {
const mid = { x: 600, y: 400 };
expect(pinchView({ scale: 2, x: 40, y: 0 }, mid, 200, mid, 50, fit, viewport, max)).toEqual(
FIT_VIEW,
);
});
it('cannot pinch beyond 1:1', () => {
const mid = { x: 600, y: 400 };
expect(pinchView(FIT_VIEW, mid, 10, mid, 1000, fit, viewport, max).scale).toBe(max);
});
});
describe('zoomFitsDevice (review of #830)', () => {
it('limits touch devices to 50 MP', () => {
expect(TOUCH_ZOOM_MAX_PIXELS).toBe(50_000_000);
// Leica Q3, 60 MP: not on a phone, fine with a mouse.
expect(zoomFitsDevice(9520, 6336, true)).toBe(false);
expect(zoomFitsDevice(9520, 6336, false)).toBe(true);
// iPhone, 12 MP; and exactly at the limit.
expect(zoomFitsDevice(4032, 3024, true)).toBe(true);
expect(zoomFitsDevice(10000, 5000, true)).toBe(true);
});
it('needs a size', () => {
expect(zoomFitsDevice(undefined, 100, false)).toBe(false);
});
});
describe('isZoomed', () => {
it('counts a hair above fit as fit', () => {
expect(isZoomed(FIT_VIEW)).toBe(false);
expect(isZoomed({ scale: 1.01, x: 0, y: 0 })).toBe(false);
expect(isZoomed({ scale: 1.5, x: 0, y: 0 })).toBe(true);
});
});

View File

@@ -0,0 +1,143 @@
import { describe, it, expect } from 'vitest';
import { zoomDimensions, zoomSourceFor } from '../zoomSource';
/**
* Which file the zoom may show (#467). The rule that matters most is the
* negative one: a format whose metadata the scrubber does not edit must never
* be served, whatever a browser could display.
*/
describe('zoomSourceFor', () => {
const image = (originalMimeType: string | null, originalFileName = 'x') => ({
type: 'IMAGE',
originalMimeType,
originalFileName,
});
it('serves JPEG and AVIF originals, which the scrubber cleans', () => {
expect(zoomSourceFor(image('image/jpeg'))).toBe('original');
expect(zoomSourceFor(image('image/jpg'))).toBe('original');
expect(zoomSourceFor(image('image/avif'))).toBe('original');
expect(zoomSourceFor(image('IMAGE/JPEG; charset=binary'))).toBe('original');
});
it('uses Immich’s rendition for what a browser cannot show', () => {
for (const mime of [
'image/heic',
'image/heif',
'image/x-adobe-dng',
'image/x-sony-arw',
'image/tiff',
'image/jxl',
'image/bmp',
]) {
expect(zoomSourceFor(image(mime)), mime).toBe('fullsize');
}
});
it('never zooms PNG, WebP or GIF: their metadata would go out unscrubbed', () => {
for (const mime of ['image/png', 'image/webp', 'image/gif', 'image/svg+xml']) {
expect(zoomSourceFor(image(mime)), mime).toBeNull();
}
});
it('never zooms a video', () => {
expect(
zoomSourceFor({
type: 'VIDEO',
originalMimeType: 'video/quicktime',
originalFileName: 'a.mov',
}),
).toBeNull();
// Even one mislabelled as an image type.
expect(zoomSourceFor({ type: 'VIDEO', originalMimeType: 'image/jpeg' })).toBeNull();
});
it('falls back to the file extension without a MIME type', () => {
expect(zoomSourceFor(image(null, 'L1001783.JPG'))).toBe('original');
expect(zoomSourceFor(image(null, 'export.avif'))).toBe('original');
expect(zoomSourceFor(image(null, 'IMG_6109.HEIC'))).toBe('fullsize');
expect(zoomSourceFor(image(null, 'scan.png'))).toBeNull();
expect(zoomSourceFor(image(null, 'no-extension'))).toBeNull();
});
it('zooms a photo edited in Immich into its edited rendition (#831)', () => {
// Immich serves the unedited file as the original: a crop would come
// undone. The edited full-size rendition matches the edited preview.
expect(zoomSourceFor({ ...image('image/jpeg'), isEdited: true })).toBe('edited');
expect(zoomSourceFor({ ...image('image/heic'), isEdited: true })).toBe('edited');
expect(zoomSourceFor({ ...image('image/jpeg'), isEdited: false })).toBe('original');
expect(zoomSourceFor({ ...image('image/heic'), isEdited: false })).toBe('fullsize');
// Edits never make a video zoomable.
expect(
zoomSourceFor({ type: 'VIDEO', originalMimeType: 'video/mp4', isEdited: true }),
).toBeNull();
});
it('refuses a non-image MIME type on an image asset', () => {
expect(zoomSourceFor(image('application/octet-stream'))).toBeNull();
});
});
describe('zoomDimensions', () => {
it('gives an edited photo without its edited size no size at all (review of #832)', () => {
// EXIF describes the unedited file; a rotated photo would get the box of
// the unrotated one. No size means no zoom.
expect(
zoomDimensions({
isEdited: true,
exifInfo: { exifImageWidth: 1076, exifImageHeight: 723, orientation: null },
}),
).toBeUndefined();
});
it('gives an edited photo its edited size, which is what its rendition has (#831)', () => {
// Measured on Immich 3.2: an iPhone HEIC stored 5712×4284 with
// orientation 6, turned back to landscape in Immich's editor. The edited
// full-size rendition is 5712×4284; EXIF alone would say 4284×5712.
expect(
zoomDimensions({
width: 5712,
height: 4284,
exifInfo: { exifImageWidth: 5712, exifImageHeight: 4284, orientation: '6' },
}),
).toEqual({ width: 5712, height: 4284 });
// A crop: stored 8064×6048 (orientation 6), cropped to 6048×4838.
expect(
zoomDimensions({
width: 6048,
height: 4838,
exifInfo: { exifImageWidth: 8064, exifImageHeight: 6048, orientation: '6' },
}),
).toEqual({ width: 6048, height: 4838 });
});
it('prefers Immich’s upright width and height', () => {
expect(
zoomDimensions({
width: 3024,
height: 4032,
exifInfo: { exifImageWidth: 4032, exifImageHeight: 3024, orientation: '6' },
}),
).toEqual({ width: 3024, height: 4032 });
});
it('turns EXIF dimensions for a quarter-turn orientation', () => {
expect(
zoomDimensions({
exifInfo: { exifImageWidth: 4032, exifImageHeight: 3024, orientation: '6' },
}),
).toEqual({ width: 3024, height: 4032 });
expect(
zoomDimensions({
exifInfo: { exifImageWidth: 7242, exifImageHeight: 4294, orientation: null },
}),
).toEqual({ width: 7242, height: 4294 });
});
it('is undefined when nothing is known', () => {
expect(zoomDimensions({})).toBeUndefined();
expect(
zoomDimensions({ exifInfo: { exifImageWidth: 0, exifImageHeight: 10 } }),
).toBeUndefined();
});
});

View File

@@ -60,6 +60,19 @@ export async function fileVersion(filePath: string): Promise<string> {
}
}
/**
* Whether a save of `content` has to back up the file at `filePath` first:
* not when there is no file yet, and not when the file already holds exactly
* these bytes. A save that changes nothing used to snapshot anyway, and with
* ten backups kept per file, a few no-op saves pushed out the real ones
* (QA A-19). Only ENOENT counts as "no file"; any other failure to read it
* throws, so the save stops before it overwrites the live file (#630).
*/
export async function needsBackup(filePath: string, content: string): Promise<boolean> {
const onDisk = await fileVersion(filePath);
return onDisk !== ABSENT_VERSION && onDisk !== versionOf(content);
}
/** The file changed on disk since the editor loaded it. */
export class VersionConflictError extends Error {
constructor(readonly currentVersion: string) {

View File

@@ -314,6 +314,124 @@ export function checkAlbumsShared(configured: string[], known: AlbumRef[]): Doct
};
}
/** What the doctor needs to know about one album that offers downloads. */
export interface DownloadAlbumRef {
id: string;
albumName: string;
/** Assets in a format whose metadata the download cannot clean (RAW, PNG, video, …). */
uncleanable: number;
/** Of those, how many Immich has coordinates for. */
uncleanableWithLocation: number;
}
/**
* Downloads remove GPS from JPEG, HEIC/HEIF and AVIF originals
* (lib/locationScrub.ts). Every other format is served exactly as Immich
* stores it, so an album that offers downloads and holds such files hands out
* whatever location they carry. The route decides which assets are cleanable;
* this module stays import-free for `npm run doctor`. Returns null while no
* album offers downloads.
*/
export function checkDownloadMetadata(albums: DownloadAlbumRef[]): DoctorFinding | null {
if (!albums.length) return null;
const affected = albums.filter((a) => a.uncleanable > 0);
if (!affected.length) {
return {
id: 'download-metadata',
level: 'ok',
title: 'Downloads go out without GPS',
detail: `Every original in the ${albums.length === 1 ? 'download album' : `${albums.length} download albums`} is JPEG, HEIC or AVIF, and is served with its location removed.`,
};
}
const files = affected.reduce((n, a) => n + a.uncleanable, 0);
const located = affected.reduce((n, a) => n + a.uncleanableWithLocation, 0);
return {
id: 'download-metadata',
level: 'warn',
title: `${files} downloadable ${files === 1 ? 'original keeps' : 'originals keep'} all metadata`,
detail:
'RAW, DNG, PNG, TIFF, WebP and video files are served exactly as stored, with any GPS ' +
`coordinates${located ? ` (Immich has a location for ${located} of them)` : ''}. ` +
'Turn off downloads or remove these files if their location should stay private: ' +
affected.map((a) => `${a.albumName} (${a.uncleanable})`).join(', '),
albumIds: affected.map((a) => a.id),
};
}
/** What the doctor needs to know about one album with lightbox zoom on (#467). */
export interface ZoomAlbumRef {
id: string;
albumName: string;
/** Photos zoomed through Immich's full-size rendition (HEIC, RAW, TIFF, …). */
needRendition: number;
}
/**
* What the sampled full-size rendition turned out to be: `ok` (JPEG, which the
* zoom route serves), `missing` (Immich has none), or the content type of one
* the route refuses — a WebP rendition, when Immich's full-size format is set
* to WebP, since the location scrubber cannot clean WebP.
*/
export type ZoomRenditionSample = 'ok' | 'missing' | { contentType: string };
/**
* Zoom shows JPEG and AVIF originals directly, but everything a browser cannot
* display needs Immich's full-size rendition, which Immich only generates while
* "Full-size image" is on in its image settings, and which the zoom route only
* serves as JPEG. Immich does not say whether a rendition exists until one is
* asked for, so the route samples one photo and passes what it found (null
* when it could not ask).
*
* Returns null when no zoom album holds such photos, or nothing is known.
*/
export function checkZoomRenditions(
albums: ZoomAlbumRef[],
sample: ZoomRenditionSample | null,
): DoctorFinding | null {
const affected = albums.filter((a) => a.needRendition > 0);
if (!affected.length || sample === null) return null;
const photos = affected.reduce((n, a) => n + a.needRendition, 0);
const noun = photos === 1 ? 'photo' : 'photos';
const albumList = affected.map((a) => `${a.albumName} (${a.needRendition})`).join(', ');
if (sample === 'ok') {
return {
id: 'zoom-renditions',
level: 'ok',
title: 'Immich has full-size previews for zoom',
detail: `${photos} HEIC, RAW or similar ${noun} in zoom albums are zoomed through Immich's full-size rendition, with the location removed.`,
};
}
if (sample === 'missing') {
return {
id: 'zoom-renditions',
level: 'warn',
title: `${photos} ${noun} cannot be zoomed: Immich has no full-size preview`,
detail:
'HEIC, RAW and other formats a browser cannot show are zoomed through Immich’s full-size ' +
'rendition, and Immich has none (one photo sampled). Visitors see the zoom button and then ' +
'"not available". In Immich, turn on Administration › Settings › Image Settings › Full-size ' +
'image (JPEG), then run the Generate Thumbnails job for missing assets. Albums: ' +
albumList,
albumIds: affected.map((a) => a.id),
};
}
return {
id: 'zoom-renditions',
level: 'warn',
title: `${photos} ${noun} cannot be zoomed: Immich's full-size format is not JPEG`,
detail:
`Immich renders its full-size previews as ${sample.contentType || 'an unknown type'}, and ` +
'zoom only serves JPEG renditions — their location metadata is removed on the way out, which ' +
'Folio cannot do for WebP. Set Administration › Settings › Image Settings › Full-size image ' +
'› Format to JPEG, then run the Generate Thumbnails job. Albums: ' +
albumList,
albumIds: affected.map((a) => a.id),
};
}
/** One set of albums that will all be reachable under the same URL prefix. */
export interface AlbumSlugGroup {
/** e.g. "gallery.yaml albums" or `subpage "Trips"` */
@@ -567,18 +685,29 @@ export function checkContact(
const SETTING_SECTIONS: Record<string, string> = {
url: 'seo',
'theme.accent': 'theme',
'theme.radius': 'theme',
'grid.columns': 'grid',
'grid.gap': 'grid',
'contact.retentionDays': 'legal',
navLinks: 'footer',
};
/** Human names for the same fields, for the finding's text. */
const SETTING_LABELS: Record<string, string> = {
url: 'Site URL',
'theme.accent': 'accent colour',
'theme.radius': 'corner radius',
'grid.columns': 'grid columns',
'grid.gap': 'grid gap',
'contact.retentionDays': 'message retention',
navLinks: 'header links',
};
/** `navLinks.2.url` → `navLinks`: every header-link error is one setting. */
function settingKey(field: string): string {
return field.startsWith('navLinks.') ? 'navLinks' : field;
}
/**
* 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.
@@ -592,21 +721,23 @@ export function checkSettingValues(errors: Array<{ field: string }>): DoctorFind
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.',
detail:
'Site URL, theme, grid, message retention and header links in settings.yaml are all valid.',
};
}
const names = errors.map((e) => SETTING_LABELS[e.field] ?? e.field);
const keys = [...new Set(errors.map((e) => settingKey(e.field)))];
const names = keys.map((key) => SETTING_LABELS[key] ?? key);
return {
id: 'settings-values',
level: 'warn',
title:
errors.length === 1
names.length === 1
? `The ${names[0]} setting is ignored`
: `${errors.length} settings are ignored`,
: `${names.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],
'The site uses a default or the nearest allowed value instead. Open the setting to see what it expects.',
settingsSection: SETTING_SECTIONS[keys[0]],
};
}

View File

@@ -7,7 +7,13 @@ 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 {
assertVersion,
needsBackup,
readVersioned,
serializeContentWrite,
versionOf,
} from './contentVersion';
import { ParsedFileCache } from './parsedFileCache';
import {
parseJournalMarkdown,
@@ -296,16 +302,9 @@ export async function writeJournalEntry(slug: string, rawMarkdown: string): Prom
// catch, so a `.backups/` a save couldn't write to looked exactly like a
// brand-new entry: the save went ahead with no snapshot taken (#630). Only
// ENOENT means there is nothing to back up; anything else aborts the save
// before it overwrites the live file.
let fileExists = true;
try {
await fs.access(filePath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
fileExists = false;
}
if (fileExists) {
// before it overwrites the live file. A save that changes nothing takes no
// backup either (needsBackup).
if (await needsBackup(filePath, rawMarkdown)) {
await snapshotEntry(filePath, filename);
await pruneEntryBackups(filename);
}
@@ -378,12 +377,56 @@ export async function deleteJournalEntry(slug: string): Promise<boolean> {
return deletedAny;
}
/**
* Move an entry to a new slug with new content: snapshot the old file under
* the new name, write the new file, then remove the old one from both
* content/journal/ and the legacy content/essays/. The route has already
* refused a rename onto an existing entry.
*/
async function renameJournalEntry(
fromSlug: string,
slug: string,
rawMarkdown: string,
): Promise<void> {
if (!isValidSlug(fromSlug)) throw new Error(`Invalid journal slug: "${fromSlug}"`);
const fromFilename = `${fromSlug}.md`;
const fromPaths = [
containedPath(JOURNAL_DIR, fromFilename),
containedPath(LEGACY_ESSAYS_DIR, fromFilename),
].filter((p): p is string => p !== null);
const existing: string[] = [];
for (const p of fromPaths) {
try {
await fs.access(p);
existing.push(p);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
}
// The copy resolveJournalFilePath reads — content/journal/ over essays/.
if (existing.length > 0) {
await snapshotEntry(existing[0], `${slug}.md`);
await pruneEntryBackups(`${slug}.md`);
}
await writeJournalEntry(slug, rawMarkdown);
for (const p of existing) {
await fs.unlink(p).catch((err: NodeJS.ErrnoException) => {
if (err.code !== 'ENOENT') throw err;
});
}
if (existing.length > 0) console.log(`[Journal] ✏️ Renamed ${fromFilename} to ${slug}.md`);
}
/**
* 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.
*
* A rename takes the entry's history along: the version before it is kept as
* a save backup of the new slug, not as a `.deleted` snapshot of the old one,
* which the Backup Manager listed as a deleted entry (QA A-19).
*/
export function saveJournalEntry(
slug: string,
@@ -397,8 +440,11 @@ export function saveJournalEntry(
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);
if (fromSlug === slug) {
await writeJournalEntry(slug, rawMarkdown);
} else {
await renameJournalEntry(fromSlug, slug, rawMarkdown);
}
return versionOf(rawMarkdown);
});
}

View File

@@ -11,7 +11,13 @@ 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 {
assertVersion,
needsBackup,
readVersioned,
serializeContentWrite,
versionOf,
} from './contentVersion';
import { isValidSlug, parseJournalMarkdown, type ParsedJournal } from '../journal';
import type { PageSummary } from '../pages';
import { ParsedFileCache } from './parsedFileCache';
@@ -199,15 +205,9 @@ export async function writePage(slug: string, rawMarkdown: string): Promise<void
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) {
// before it overwrites the live file (the journal's #630). A save that
// changes nothing takes no backup either (needsBackup).
if (await needsBackup(filePath, rawMarkdown)) {
await snapshotPage(filePath, filename);
await prunePageBackups(filename);
}
@@ -234,6 +234,33 @@ export async function deletePage(slug: string): Promise<boolean> {
return true;
}
/**
* Move a page to a new slug with new content. The old file is snapshotted
* under the new name first, then the new file is written, then the old one
* removed — a failure part way leaves the old page in place. The route has
* already refused a rename onto an existing page.
*/
async function renamePage(fromSlug: string, slug: string, rawMarkdown: string): Promise<void> {
const fromPath = resolvePageFilePath(fromSlug);
if (!fromPath) throw new Error(`Invalid page slug: "${fromSlug}"`);
let fromExists = true;
try {
await fs.access(fromPath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
fromExists = false;
}
if (fromExists) {
await snapshotPage(fromPath, `${slug}.md`);
await prunePageBackups(`${slug}.md`);
}
await writePage(slug, rawMarkdown);
if (fromExists) {
await fs.unlink(fromPath);
console.log(`[Pages] ✏️ Renamed ${fromSlug}.md to ${slug}.md`);
}
}
/** 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);
@@ -265,6 +292,11 @@ export async function restorePageBackup(backupFilename: string): Promise<string>
* 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.
*
* A rename takes the page's history along: the version before it is kept as
* a save backup of the new slug, not as a `.deleted` snapshot of the old one.
* The Backup Manager used to list every rename as a deleted page, whose
* restore brought the old slug back next to the renamed page (QA A-19).
*/
export function savePage(
slug: string,
@@ -278,8 +310,11 @@ export function savePage(
if (!loaded) throw new Error(`Invalid page slug: "${fromSlug}"`);
await assertVersion(loaded, options.baseVersion);
}
await writePage(slug, rawMarkdown);
if (fromSlug !== slug) await deletePage(fromSlug);
if (fromSlug === slug) {
await writePage(slug, rawMarkdown);
} else {
await renamePage(fromSlug, slug, rawMarkdown);
}
return versionOf(rawMarkdown);
});
}

View File

@@ -86,7 +86,7 @@ export function picksFor(session: ProofSession, album: ImmichAlbum | null): Admi
assetId: asset.id,
position: index + 1,
fileName: asset.originalFileName,
thumbUrl: imageUrl(asset.id, 'thumbnail'),
thumbUrl: imageUrl(asset, 'thumbnail'),
...(asset.localDateTime || asset.fileCreatedAt
? { takenAt: asset.localDateTime ?? asset.fileCreatedAt }
: {}),

View File

@@ -8,7 +8,13 @@ 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';
import {
assertVersion,
needsBackup,
readVersioned,
serializeContentWrite,
versionOf,
} from './contentVersion';
const CONTENT_DIR = path.join(process.cwd(), 'content');
const MAX_BACKUPS = 10; // Keep last 10 backups per file
@@ -73,32 +79,6 @@ async function writeYamlFileNow(
// Ensure content directory exists
await fs.mkdir(CONTENT_DIR, { recursive: true });
// "No file yet" and "the backup could not be written" used to share one
// catch, so a `.backups/` a save couldn't write to (owned by root after a
// first start as root, say) looked exactly like a brand-new file: the save
// went ahead with no snapshot taken (#630). Only ENOENT means there is
// nothing to back up; anything else aborts the save before it overwrites
// the live file.
let fileExists = true;
try {
await fs.access(filePath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
fileExists = false;
}
if (fileExists) {
const backupDir = path.join(CONTENT_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 old backups
await pruneBackups(backupDir, filename);
}
// Generate YAML content with header comment
const header =
filename === 'gallery.yaml'
@@ -114,6 +94,25 @@ async function writeYamlFileNow(
sortKeys: false,
});
// "No file yet" and "the backup could not be written" used to share one
// catch, so a `.backups/` a save couldn't write to (owned by root after a
// first start as root, say) looked exactly like a brand-new file: the save
// went ahead with no snapshot taken (#630). Only ENOENT means there is
// nothing to back up; anything else aborts the save before it overwrites
// the live file.
// A save that changes nothing takes no backup either (needsBackup).
if (await needsBackup(filePath, content)) {
const backupDir = path.join(CONTENT_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 old backups
await pruneBackups(backupDir, filename);
}
await atomicWrite(filePath, content);
console.log(`[Admin] ✅ Saved ${filename}`);

View File

@@ -11,7 +11,7 @@ import crypto from 'crypto';
import { getConfig, SubpageConfig } from './config';
// From the pure schema module, not the config barrel: tests that stub out
// '@/lib/config' wholesale would otherwise lose this helper.
import { normalizeSlug } from './config/schema';
import { normalizeSlug, resolveZoom } from './config/schema';
import { verifyScrypt, generateScryptHash, isScryptHash, burnScrypt } from './password';
const TOKEN_EXPIRY_HOURS = 24;
@@ -140,6 +140,41 @@ export function isAlbumReachable(
return routes.some((sp) => isAuthenticated(sp.slug, getCookie, 'subpage'));
}
/**
* Whether this visitor may zoom into the album's photos (#467): there is a
* route to the album they could have taken — every gate on it satisfied, as in
* isAlbumReachable() — on which zoom resolves on.
*
* Per route, not per album: zoom is resolved against the subpage an album is
* shown on, and an album listed on two subpages can have it on one and off on
* the other. Asking "is zoom on anywhere" and "can the visitor get in
* anywhere" separately would let a visitor through an open subpage that has
* zoom off, on the strength of a locked one that has it on. The album entry's
* own override is per route too (resolveZoom).
*
* Essay subpages are not excluded. The essay itself offers no zoom, but each
* of its albums is still reachable as an album page at /<subpage>/<album>,
* which offers zoom by exactly this rule. The route answers what that page
* offers; refusing it here would break zoom there.
*/
export function isAlbumZoomReachable(
albumId: string,
getCookie: (name: string) => string | undefined,
): boolean {
if (!isAuthenticated(albumId, getCookie, 'album')) return false;
const config = getConfig();
if (config.standaloneAlbums?.includes(albumId) && resolveZoom(config, albumId)) return true;
return config.subpages.some(
(sp) =>
sp.enabled !== false &&
sp.albumIds.includes(albumId) &&
resolveZoom(config, albumId, sp) &&
isAuthenticated(sp.slug, getCookie, 'subpage'),
);
}
/**
* The albums this visitor may see the photographs of: every album without a
* password of its own, plus the ones already unlocked.

View File

@@ -277,6 +277,8 @@ export interface GalleryDerivation {
albumGrids: Record<string, Partial<GridConfig>>;
albumCoverPositions: Record<string, string>;
albumDownloads: Record<string, boolean>;
/** Album zoom overrides on the standalone entries; subpages carry their own. */
standaloneAlbumZoom: Record<string, boolean>;
albumLocationPrecision: Record<string, LocationPrecision>;
}
@@ -302,6 +304,13 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
const albumGrids: Record<string, Partial<GridConfig>> = {};
const albumCoverPositions: Record<string, string> = {};
const albumDownloads: Record<string, boolean> = {};
/**
* Per-album zoom overrides (#467), one map per route: the standalone entries
* here, each subpage's (sections included) on the subpage. An album listed on
* two pages can be zoomable on one and not the other; a single map would let
* whichever entry came last decide for both.
*/
const standaloneAlbumZoom: Record<string, boolean> = {};
const albumLocationPrecision: Record<string, LocationPrecision> = {};
/** Returns null for an entry whose album ID is not a UUID; callers drop it. */
@@ -309,6 +318,7 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
entry: string | Record<string, string | AlbumEntryObject>,
context: string,
inheritedLocation?: LocationPrecision,
zoomSink?: Record<string, boolean>,
): string | null {
if (typeof entry === 'string') {
if (inheritedLocation) albumLocationPrecision[entry] = inheritedLocation;
@@ -396,6 +406,13 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
// handing out full-resolution originals because one client gallery
// needed to (#475).
if (value.download === true) albumDownloads[validatedUuid] = true;
// Both values mean something: `false` switches zoom off here even where
// the subpage or the site has it on (#467). Anything else is ignored.
// Written to the route this entry is on. Listed twice on one route (two
// sections of a page), the cautious answer wins: `false` stays `false`.
if (zoomSink && typeof value.zoom === 'boolean') {
zoomSink[validatedUuid] = zoomSink[validatedUuid] !== false && value.zoom;
}
const location = parseLocation(value.location, `${context}: album ${validatedUuid} location`);
if (location) albumLocationPrecision[validatedUuid] = location;
}
@@ -407,9 +424,10 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
entries: Array<string | Record<string, string | AlbumEntryObject>>,
context: string,
inheritedLocation?: LocationPrecision,
zoomSink?: Record<string, boolean>,
): string[] {
return entries
.map((entry) => processAlbumEntry(entry, context, inheritedLocation))
.map((entry) => processAlbumEntry(entry, context, inheritedLocation, zoomSink))
.filter((id): id is string => id !== null);
}
@@ -429,7 +447,12 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
return raw;
}
const standaloneAlbumIds = processAlbumEntries(gallery.albums ?? [], 'gallery.yaml albums');
const standaloneAlbumIds = processAlbumEntries(
gallery.albums ?? [],
'gallery.yaml albums',
undefined,
standaloneAlbumZoom,
);
let subpages: SubpageConfig[] = [];
/** Menu order, subpages and `- page:` references interleaved (#722). */
@@ -465,6 +488,9 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
throw new Error(`Subpage "(unnamed)" must have a name`);
}
// This page's own album zoom overrides, sections included (#467).
const albumZoom: Record<string, boolean> = {};
// Parse sections if present
let sections: SubpageSectionConfig[] | undefined;
let albumIds: string[];
@@ -478,6 +504,7 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
sec.albums,
`subpage "${sp.name}" section "${sec.title}"`,
parseLocation(sp.location, `subpage "${sp.name}" location`),
albumZoom,
),
}));
// flat union of all section albums
@@ -487,6 +514,7 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
sp.albums ?? [],
`subpage "${sp.name}"`,
parseLocation(sp.location, `subpage "${sp.name}" location`),
albumZoom,
);
albumIds = [...topLevel, ...albumIds];
} else {
@@ -498,6 +526,7 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
albums,
`subpage "${sp.name}"`,
parseLocation(sp.location, `subpage "${sp.name}" location`),
albumZoom,
);
}
@@ -510,6 +539,8 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
sections,
password: sp.password,
proofing: sp.proofing,
zoom: typeof sp.zoom === 'boolean' ? sp.zoom : undefined,
...(Object.keys(albumZoom).length ? { albumZoom } : {}),
essayFile: sp.essayFile,
essayText: sp.essayText,
enabled: sp.enabled !== false,
@@ -533,6 +564,7 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
}
const sp = value as SubpageObjectValue;
const albumEntries = sp.albums || [];
const albumZoom: Record<string, boolean> = {};
return {
name,
slug: slugify(name),
@@ -542,9 +574,12 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
albumEntries,
`subpage "${name}"`,
parseLocation(sp.location, `subpage "${name}" location`),
albumZoom,
),
password: sp.password,
proofing: sp.proofing,
zoom: typeof sp.zoom === 'boolean' ? sp.zoom : undefined,
...(Object.keys(albumZoom).length ? { albumZoom } : {}),
essayFile: sp.essayFile,
essayText: sp.essayText,
enabled: sp.enabled !== false,
@@ -618,6 +653,7 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
albumGrids,
albumCoverPositions,
albumDownloads,
standaloneAlbumZoom,
albumLocationPrecision,
};
}
@@ -740,6 +776,7 @@ function deriveConfig(): AppConfig {
analytics: true,
proofing: { enabled: false, allowMailto: false },
aboutEnabled: true,
zoom: false,
albumOverrides: {},
albumDescriptions: {},
albumPasswords: {},
@@ -749,6 +786,7 @@ function deriveConfig(): AppConfig {
albumGrids: {},
albumCoverPositions: {},
albumDownloads: {},
standaloneAlbumZoom: {},
albumLocationPrecision: {},
navLinks: [],
cacheTtl: env.CACHE_TTL * 1000,
@@ -778,6 +816,7 @@ function deriveConfig(): AppConfig {
albumGrids,
albumCoverPositions,
albumDownloads,
standaloneAlbumZoom,
albumLocationPrecision,
} = deriveGallery(gallery);
@@ -864,6 +903,9 @@ function deriveConfig(): AppConfig {
// writes it, so a fresh install put a nav link in front of an empty page
// (#518). `enabled: false` still hides it even when the file is there.
aboutEnabled: settings.about?.enabled !== false && aboutContentExists(),
// Opt-in: zoom serves originals, which a portfolio must not start doing
// because an upgrade added the feature (#467).
zoom: settings.zoom === true,
albumOverrides,
albumDescriptions,
albumPasswords,
@@ -873,6 +915,7 @@ function deriveConfig(): AppConfig {
albumGrids,
albumCoverPositions,
albumDownloads,
standaloneAlbumZoom,
albumLocationPrecision,
navLinks: sanitizeNavLinks(settings.navLinks),
cacheTtl: env.CACHE_TTL * 1000,

264
lib/config/presets.ts Normal file
View File

@@ -0,0 +1,264 @@
import type { ThemeConfig } from './schema';
/**
* The built-in theme presets, declared once.
*
* Everything that lists presets reads this array: `THEME_PRESETS` and
* `resolveTheme()` (lib/config/theme.ts), the admin theme cards, the install
* wizard and its allowlist, and the dev toolbar. The parts that cannot import
* it — the palette blocks in app/tokens.css, app/themes/<id>.css and its
* `@import`, the docs table, scripts/contrast-audit.mjs — are checked against
* it by lib/__tests__/preset-registry.test.ts.
*
* Client-safe: no `fs`, so client components may import it.
*/
export interface PresetDefinition {
id: string;
label: string;
/** One line under the preset in the install wizard. */
tagline: string;
/** The admin theme card's description. */
description: string;
/** What the preset applies when settings.yaml overrides nothing. */
theme: Omit<ThemeConfig, 'preset'>;
/**
* The admin card's mini mockup. Font, radius and frame come from `theme`;
* `gap` is the visual density of the preset's gallery grid.
*/
card: { bg: string; tile: string; accent: string; type: 'serif' | 'sans' | 'mono'; gap: number };
/**
* Whether the preset ships its own palette in app/tokens.css and its own
* app/themes/<id>.css. Only `studio` does not: it is the look the global
* defaults describe.
*/
ownStyles: boolean;
}
export const PRESET_REGISTRY: readonly PresetDefinition[] = [
{
id: 'studio-modern',
label: 'Studio Modern',
tagline: 'Precision grotesque, hairline rules',
description: 'Leica precision: Archivo grotesque, mono EXIF, red signal accents.',
theme: {
accent: '#e60012',
fonts: { heading: 'Archivo', body: 'Archivo', caption: 'IBM Plex Mono' },
radius: 0,
photoFrame: 'none',
grain: false,
headerDot: true,
heroStyle: 'split',
},
card: { bg: '#121212', tile: '#191919', accent: '#e60012', type: 'sans', gap: 3 },
ownStyles: true,
},
{
id: 'studio',
label: 'Studio',
tagline: 'Classic portfolio, red accent',
description: 'Playfair Display serif headings over DM Sans, matted photos on black.',
theme: {
accent: '#e60012',
fonts: { heading: 'Playfair Display', body: 'DM Sans', caption: 'EB Garamond' },
radius: 0,
photoFrame: 'passepartout',
grain: true,
headerDot: true,
heroStyle: 'split',
},
card: { bg: '#141414', tile: '#242424', accent: '#e60012', type: 'serif', gap: 4 },
ownStyles: false,
},
{
id: 'minimal',
label: 'Minimal',
tagline: 'Bare-bones, monochrome',
description: 'Pure raw layouts with tiny gaps and high whitespace.',
theme: {
accent: '#000000',
accentDark: '#ffffff',
fonts: { heading: 'Geist', body: 'Geist', caption: 'IBM Plex Mono' },
radius: 0,
photoFrame: 'none',
grain: false,
headerDot: false,
heroStyle: 'fullbleed',
},
card: { bg: '#ffffff', tile: '#f3f3f3', accent: '#111111', type: 'sans', gap: 2 },
ownStyles: true,
},
{
id: 'editorial',
label: 'Editorial',
tagline: 'Magazine-style serif',
description: 'Warm backgrounds, elegant serifs and large headers.',
theme: {
accent: '#8B2500',
accentDark: '#d9602a',
fonts: { heading: 'Bodoni Moda', body: 'Newsreader', caption: 'Spectral' },
radius: 0,
photoFrame: 'shadow',
grain: false,
headerDot: false,
heroStyle: 'split',
},
card: { bg: '#fbf9f4', tile: '#e5dfd4', accent: '#b89053', type: 'serif', gap: 8 },
ownStyles: true,
},
{
id: 'classic',
label: 'Classic',
tagline: 'Warm gold, traditional',
description: 'Soft traditional portfolio: Cinzel capitals, rounded matted photos.',
theme: {
accent: '#c49a3c',
accentLight: '#8d6f2b',
fonts: { heading: 'Cinzel', body: 'Crimson Pro', caption: 'Crimson Pro' },
radius: 12,
photoFrame: 'passepartout',
grain: false,
headerDot: true,
heroStyle: 'minimal',
},
card: { bg: '#f7f7f7', tile: '#ffffff', accent: '#444444', type: 'serif', gap: 7 },
ownStyles: true,
},
{
id: 'noir',
label: 'Noir',
tagline: 'Dark, high-contrast',
description: 'High drama absolute pitch black, stark high-fashion look.',
theme: {
accent: '#ff6b35',
accentLight: '#c2410c',
fonts: { heading: 'Libre Baskerville', body: 'Source Sans 3', caption: 'Space Mono' },
radius: 0,
photoFrame: 'passepartout',
grain: true,
headerDot: false,
heroStyle: 'fullbleed',
},
card: { bg: '#000000', tile: '#151515', accent: '#ffffff', type: 'serif', gap: 6 },
ownStyles: true,
},
{
id: 'monograph',
label: 'Monograph',
tagline: 'Typographic, text-first',
description: 'Instrument Serif headlines, Inter text, mono captions: a quiet document feel.',
theme: {
accent: '#333333',
accentDark: '#c8c8c8',
fonts: { heading: 'Instrument Serif', body: 'Inter', caption: 'IBM Plex Mono' },
radius: 0,
photoFrame: 'none',
grain: false,
headerDot: false,
heroStyle: 'typographic',
},
card: { bg: '#f4f4f6', tile: '#ffffff', accent: '#555555', type: 'serif', gap: 5 },
ownStyles: true,
},
{
id: 'kunsthalle',
label: 'Kunsthalle',
tagline: 'Grey gallery wall, wall labels',
description:
'Photos hung on a neutral grey wall with museum wall labels. Libre Franklin, petrol links.',
theme: {
accent: '#1d545c',
accentDark: '#8cc3ca',
fonts: { heading: 'Libre Franklin', body: 'Libre Franklin', caption: 'Libre Franklin' },
radius: 0,
photoFrame: 'shadow',
grain: false,
headerDot: false,
heroStyle: 'mosaic',
},
card: { bg: '#2b2b29', tile: '#393937', accent: '#8cc3ca', type: 'sans', gap: 8 },
ownStyles: true,
},
{
id: 'ma',
label: 'Ma',
tagline: 'Japanese restraint, washi and sumi',
description:
'Negative space, washi-grey paper, sumi ink and one vermilion seal. Cormorant Garamond and Hanken Grotesk.',
theme: {
accent: '#b0381f',
accentDark: '#e8674c',
fonts: { heading: 'Cormorant Garamond', body: 'Hanken Grotesk', caption: 'Hanken Grotesk' },
radius: 0,
photoFrame: 'none',
grain: false,
headerDot: true,
heroStyle: 'split',
},
card: { bg: '#f0ede6', tile: '#e7e3da', accent: '#b0381f', type: 'serif', gap: 9 },
ownStyles: true,
},
{
id: 'cyanotype',
label: 'Cyanotype',
tagline: 'Prussian blue, drawing sheet',
description:
'A Prussian-blue print set like an architectural drawing: Jost, DM Mono, registration marks.',
theme: {
accent: '#1a4d88',
accentDark: '#8ccbeb',
fonts: { heading: 'Jost', body: 'Jost', caption: 'DM Mono' },
radius: 0,
photoFrame: 'none',
grain: false,
headerDot: false,
heroStyle: 'split',
},
card: { bg: '#0e1621', tile: '#192839', accent: '#8ccbeb', type: 'sans', gap: 4 },
ownStyles: true,
},
{
id: 'salon',
label: 'Salon',
tagline: 'Oxblood salon, fine fillet',
description:
'A 19th-century oxblood picture gallery with modern restraint: Gloock, Work Sans, a fillet round every photo.',
theme: {
accent: '#7d1f2c',
accentDark: '#e3a39a',
fonts: { heading: 'Gloock', body: 'Work Sans', caption: 'Work Sans' },
radius: 0,
photoFrame: 'none',
grain: false,
headerDot: false,
heroStyle: 'fullbleed',
},
card: { bg: '#1c1315', tile: '#2c2023', accent: '#e3a39a', type: 'serif', gap: 5 },
ownStyles: true,
},
{
id: 'birch',
label: 'Birch',
tagline: 'Nordic daylight, soft corners',
description:
'Scandinavian daylight: birch-white and pine-night, soft corners, sentence case, lichen green.',
theme: {
accent: '#2c5d44',
accentDark: '#9fc9a9',
fonts: { heading: 'Albert Sans', body: 'Albert Sans', caption: 'Albert Sans' },
radius: 6,
photoFrame: 'none',
grain: false,
headerDot: false,
heroStyle: 'stacked',
},
card: { bg: '#f3f5f1', tile: '#e9ece6', accent: '#2c5d44', type: 'sans', gap: 3 },
ownStyles: true,
},
];
/** Preset ids in registry order: the order every picker shows them in. */
export const PRESET_IDS: readonly string[] = PRESET_REGISTRY.map((p) => p.id);
export function getPresetDefinition(id: string): PresetDefinition | undefined {
return PRESET_REGISTRY.find((p) => p.id === id);
}

View File

@@ -25,6 +25,13 @@ export interface SubpageConfig {
*/
coverGrid?: Partial<GridConfig>;
proofing?: boolean;
/** Lightbox zoom for the albums here, over the site setting (#467). */
zoom?: boolean;
/**
* Album zoom overrides written on this page's album entries (sections
* included). They apply on this page only; see resolveZoom().
*/
albumZoom?: Record<string, boolean>;
essayFile?: string;
essayText?: string;
enabled?: boolean;
@@ -40,6 +47,8 @@ export interface SubpageObjectValue {
/** Album-cover tiles only; falls back to `grid` when unset (#523). */
coverGrid?: Partial<GridConfig>;
proofing?: boolean;
/** Lightbox zoom for the albums here, over the site setting (#467). */
zoom?: boolean;
essayFile?: string;
essayText?: string;
enabled?: boolean;
@@ -279,6 +288,11 @@ export interface AppConfig {
email?: string;
};
aboutEnabled: boolean;
/**
* Lightbox zoom to full resolution, site-wide (`zoom` in settings.yaml,
* off by default). Subpages and albums override it; see resolveZoom() (#467).
*/
zoom: boolean;
albumOverrides: Record<string, string>;
albumDescriptions: Record<string, string>;
albumPasswords: Record<string, string>;
@@ -293,6 +307,12 @@ export interface AppConfig {
albumCoverPositions: Record<string, string>;
/** Albums whose originals may be downloaded. Absent means no (#475). */
albumDownloads: Record<string, boolean>;
/**
* Lightbox zoom overrides on the standalone album entries, either way.
* Absent means inherit (#467). Entries inside a subpage are kept on that
* subpage (`SubpageConfig.albumZoom`), so each route answers for itself.
*/
standaloneAlbumZoom: Record<string, boolean>;
/** Per-album map precision. Absent means `exact` (#469). */
albumLocationPrecision: Record<string, LocationPrecision>;
/** EXPERIMENTAL: external links appended to the header navigation. */
@@ -338,6 +358,33 @@ export interface AppConfig {
};
}
/**
* Whether the lightbox offers zoom to full resolution for an album on one
* route (#467): the album standalone (no subpage), or on a given subpage.
*
* Most specific wins, in either direction: the album entry's own `zoom:` on
* that route, then the subpage, then `zoom` in settings.yaml (off by default).
* The same `??` chain as resolveProofing(), one level deeper — `zoom: false` on
* an album has to beat a subpage's `true`.
*
* The album override is per route on purpose. An album listed on an open page
* with `zoom: false` and on a password page with `zoom: true` must not become
* zoomable on the open one because the other entry happened to be read last.
*
* The page, the zoom route (isAlbumZoomReachable in lib/auth.ts) and the
* admin drawer (which edits the entry in its place) all read the same value.
* Lives here rather than in lib/config/index.ts so client-safe and test code
* that stubs the config barrel can still reach it.
*/
export function resolveZoom(
config: Pick<AppConfig, 'zoom' | 'standaloneAlbumZoom'>,
albumId: string,
subpage?: { zoom?: boolean; albumZoom?: Record<string, boolean> },
): boolean {
if (subpage) return subpage.albumZoom?.[albumId] ?? subpage.zoom ?? config.zoom;
return config.standaloneAlbumZoom?.[albumId] ?? config.zoom;
}
export interface AlbumEntryObject {
/** Optional: an entry may carry only a sort mode and no title override. */
title?: string;
@@ -357,6 +404,11 @@ export interface AlbumEntryObject {
coverPosition?: string;
/** Offer the original file for download from the lightbox (#475). Off by default. */
download?: boolean;
/**
* Lightbox zoom to full resolution for this album, over the subpage and
* site setting in either direction (#467). Absent inherits.
*/
zoom?: boolean;
/**
* How precisely this album's photographs may be placed on the map:
* `exact` (default), `city`, `country` or `hidden`. Narrowed to
@@ -403,6 +455,8 @@ export interface GallerySubpageYaml {
}>;
password?: string;
proofing?: boolean;
/** Lightbox zoom for every album here that does not set its own (#467). */
zoom?: boolean;
essayFile?: string;
essayText?: string;
enabled?: boolean;
@@ -447,6 +501,8 @@ export interface SettingsYaml {
transitions?: boolean;
scrollToTop?: boolean;
analytics?: boolean;
/** Lightbox zoom to full resolution; off unless set (#467). */
zoom?: boolean;
proofing?: {
enabled?: boolean;
allowMailto?: boolean;

View File

@@ -7,22 +7,37 @@
* an input for, that meant a save of `accent: rot` or nine columns in a 1–6
* field was reported as "Saved" while the site showed something else.
*
* This checks exactly those fields — the site URL, the accent, the grid
* columns and gap — with the resolvers' own rules and bounds, so it can never
* be stricter than what renders. Kept apart from settingsSchema.ts so the
* This checks the values a resolver bends — the site URL, the accent, the
* corner radius, the grid columns and gap, the message retention and the
* header links — with the resolvers' own rules and bounds, so it can never be
* stricter than what renders. Kept apart from settingsSchema.ts so the
* settings form can run it as you type without bundling zod; the save route
* runs it before writing, and the doctor over settings.yaml as it is.
* runs it before writing, and both doctors over settings.yaml as it is.
*
* `npm run doctor` imports this file under Node's own type stripping
* (scripts/doctor.mts), which resolves no extensionless paths: every relative
* import here carries its `.ts`, and the modules it pulls in may only
* `import type` from elsewhere. scripts/__tests__/doctor-cli.test.ts loads the
* graph in a real Node process and fails when that breaks.
*/
import { normaliseSiteUrl } from '../siteUrl';
import { isAccentHex } from './theme';
import { PHOTO_GRID_COLUMNS_MAX, PHOTO_GRID_COLUMNS_MIN, PHOTO_GRID_GAP_MAX } from './schema';
import { normaliseSiteUrl } from '../siteUrl.ts';
import { isAccentHex, THEME_RADIUS_MAX } from './theme.ts';
import {
CONTACT_RETENTION_MAX,
PHOTO_GRID_COLUMNS_MAX,
PHOTO_GRID_COLUMNS_MIN,
PHOTO_GRID_GAP_MAX,
} from './schema.ts';
import type { SettingsFieldError } from './settingsSchema';
function inRange(value: unknown, min: number, max: number): boolean {
return typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max;
}
/** What sanitizeNavLinks (lib/config/index.ts) keeps as a link target. */
const NAV_LINK_URL = /^https?:\/\//i;
/** One error per offending field; an empty string counts as unset, as in the resolvers. */
export function validateSettingValues(input: unknown): SettingsFieldError[] {
if (!input || typeof input !== 'object') return [];
@@ -30,6 +45,8 @@ export function validateSettingValues(input: unknown): SettingsFieldError[] {
url?: unknown;
theme?: unknown;
grid?: { columns?: unknown; gap?: unknown } | null;
contact?: { retentionDays?: unknown } | null;
navLinks?: unknown;
};
const errors: SettingsFieldError[] = [];
@@ -42,13 +59,33 @@ export function validateSettingValues(input: unknown): SettingsFieldError[] {
});
}
const theme =
s.theme && typeof s.theme === 'object'
? (s.theme as { accent?: unknown; radius?: unknown })
: null;
// resolveTheme: anything but hex falls back to the preset's accent.
const accent =
s.theme && typeof s.theme === 'object' ? (s.theme as { accent?: unknown }).accent : undefined;
const accent = theme?.accent;
if (typeof accent === 'string' && accent.trim() !== '' && !isAccentHex(accent)) {
errors.push({ field: 'theme.accent', message: 'Must be a hex colour such as #e60012.' });
}
// resolveRadius clamps into 0–THEME_RADIUS_MAX. The panel has no input for
// it, so the message says where the value lives.
const radius = theme?.radius;
if (
radius != null &&
(typeof radius !== 'number' ||
!Number.isFinite(radius) ||
radius < 0 ||
radius > THEME_RADIUS_MAX)
) {
errors.push({
field: 'theme.radius',
message: `Must be a number from 0 to ${THEME_RADIUS_MAX} (theme.radius in settings.yaml).`,
});
}
// getConfig clamps both into these bounds (lib/config/index.ts).
const grid = s.grid && typeof s.grid === 'object' ? s.grid : null;
if (
@@ -67,5 +104,36 @@ export function validateSettingValues(input: unknown): SettingsFieldError[] {
});
}
// resolveContact rounds and clamps into 1–CONTACT_RETENTION_MAX.
const contact = s.contact && typeof s.contact === 'object' ? s.contact : null;
if (contact?.retentionDays != null && !inRange(contact.retentionDays, 1, CONTACT_RETENTION_MAX)) {
errors.push({
field: 'contact.retentionDays',
message: `Must be a whole number of days from 1 to ${CONTACT_RETENTION_MAX}.`,
});
}
// sanitizeNavLinks drops an entry without a label or without an http(s)
// URL, whole — so each half is reported against its own input.
if (Array.isArray(s.navLinks)) {
s.navLinks.forEach((entry: unknown, i) => {
const link = entry && typeof entry === 'object' ? (entry as Record<string, unknown>) : {};
const label = typeof link.label === 'string' ? link.label.trim() : '';
const url = typeof link.url === 'string' ? link.url.trim() : '';
if (!label) {
errors.push({
field: `navLinks.${i}.label`,
message: 'Needs a label, or the link is left out.',
});
}
if (!NAV_LINK_URL.test(url)) {
errors.push({
field: `navLinks.${i}.url`,
message: 'Must be a full http(s) address, or the link is left out.',
});
}
});
}
return errors;
}

View File

@@ -29,9 +29,10 @@
* to the preset's own value for anything that is not the right type, which
* covers hand-edited YAML as well as a save from this panel.
*
* **Values the panel offers an input for** are the exception, checked with the
* resolvers' own rules in `settingValues.ts`: the site URL, the accent and the
* grid columns and gap, which a save used to accept and the site then ignored.
* **Values a resolver would bend** are the exception, checked with the
* resolvers' own rules in `settingValues.ts`: the site URL, the accent, the
* corner radius, the grid columns and gap, the message retention and the
* header links, which a save used to accept and the site then ignored.
*
* **Unknown keys pass through.** There is no `schemaVersion` and no migration
* code, so configurations older and newer than this build are both in the wild.
@@ -87,6 +88,7 @@ export const settingsSchema = z.looseObject({
transitions: bool,
scrollToTop: bool,
analytics: bool,
zoom: bool,
proofing: z.looseObject({ enabled: bool, allowMailto: bool, email: str }).optional(),

View File

@@ -1,85 +1,13 @@
import type { ThemeConfig, SettingsYaml } from './schema';
import { PRESET_REGISTRY } from './presets.ts';
/** The preset used when none is configured. */
export const DEFAULT_PRESET = 'studio-modern';
export const THEME_PRESETS: Record<string, ThemeConfig> = {
'studio-modern': {
preset: 'studio-modern',
accent: '#e60012',
fonts: { heading: 'Archivo', body: 'Archivo', caption: 'IBM Plex Mono' },
radius: 0,
photoFrame: 'none',
grain: false,
headerDot: true,
heroStyle: 'split',
},
studio: {
preset: 'studio',
accent: '#e60012',
fonts: { heading: 'Playfair Display', body: 'DM Sans', caption: 'EB Garamond' },
radius: 0,
photoFrame: 'passepartout',
grain: true,
headerDot: true,
heroStyle: 'split',
},
minimal: {
preset: 'minimal',
accent: '#000000',
accentDark: '#ffffff',
fonts: { heading: 'Geist', body: 'Geist', caption: 'IBM Plex Mono' },
radius: 0,
photoFrame: 'none',
grain: false,
headerDot: false,
heroStyle: 'fullbleed',
},
editorial: {
preset: 'editorial',
accent: '#8B2500',
accentDark: '#d9602a',
fonts: { heading: 'Bodoni Moda', body: 'Newsreader', caption: 'Spectral' },
radius: 0,
photoFrame: 'shadow',
grain: false,
headerDot: false,
heroStyle: 'split',
},
classic: {
preset: 'classic',
accent: '#c49a3c',
accentLight: '#8d6f2b',
fonts: { heading: 'Cinzel', body: 'Crimson Pro', caption: 'Crimson Pro' },
radius: 12,
photoFrame: 'passepartout',
grain: false,
headerDot: true,
heroStyle: 'minimal',
},
noir: {
preset: 'noir',
accent: '#ff6b35',
accentLight: '#c2410c',
fonts: { heading: 'Libre Baskerville', body: 'Source Sans 3', caption: 'Space Mono' },
radius: 0,
photoFrame: 'passepartout',
grain: true,
headerDot: false,
heroStyle: 'fullbleed',
},
monograph: {
preset: 'monograph',
accent: '#333333',
accentDark: '#c8c8c8',
fonts: { heading: 'Instrument Serif', body: 'Inter', caption: 'IBM Plex Mono' },
radius: 0,
photoFrame: 'none',
grain: false,
headerDot: false,
heroStyle: 'typographic',
},
};
/** Every built-in preset by id, derived from PRESET_REGISTRY (lib/config/presets.ts). */
export const THEME_PRESETS: Record<string, ThemeConfig> = Object.fromEntries(
PRESET_REGISTRY.map((p) => [p.id, { preset: p.id, ...p.theme }]),
);
export const VALID_PHOTO_FRAMES = ['none', 'passepartout', 'shadow'];
export const VALID_HERO_STYLES = [
@@ -109,7 +37,7 @@ export const VALID_LAYOUTS = [
* side of this field, are already checked against an allowlist; this is the
* same treatment for the one numeric scalar in ThemeConfig.
*/
const THEME_RADIUS_MAX = 64; // Presets top out at 16; generous but not unbounded.
export const THEME_RADIUS_MAX = 64; // Presets top out at 16; generous but not unbounded.
function resolveRadius(raw: unknown, fallback: number): number {
if (typeof raw !== 'number' || !Number.isFinite(raw)) return fallback;
return Math.min(THEME_RADIUS_MAX, Math.max(0, raw));
@@ -187,6 +115,50 @@ export function onAccent(accent: string): string {
return toBlack > toWhite ? '#000000' : '#ffffff';
}
/**
* The surfaces accent-coloured text is checked against, one per mode: the
* lightest dark surface (`--bg-card-hover` of the brightest dark preset,
* rounded up) and the darkest light one (`--bg-secondary` of the studio
* light palette, rounded down). Clearing these clears every other surface of
* the same mode.
*/
const ACCENT_TEXT_SURFACE = { dark: '#262626', light: '#e8e8e3' } as const;
const AA_TEXT = 4.5;
function mixHex(a: string, b: string, weightB: number): string {
const channel = (hex: string, i: number) => parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16);
return (
'#' +
[0, 1, 2]
.map((i) => Math.round(channel(a, i) * (1 - weightB) + channel(b, i) * weightB))
.map((c) => c.toString(16).padStart(2, '0'))
.join('')
);
}
/**
* Text in the accent colour (`--accent-text`), lifted to AA. A fill can be any
* accent: `--on-accent` picks the text on top of it. Text *in* the accent
* cannot choose its background, and the preset reds measured 3.5–3.7:1 on the
* dark pages (#e60012 on #121212). The accent is mixed toward white on dark
* and toward black on light, in 5% steps (the same mix `color-mix(in srgb)`
* makes), until it reaches 4.5:1 on that mode's worst surface — so an accent
* that already passes is returned unchanged, and every accent terminates at
* white or black at the latest. A non-hex value is returned as it is.
*/
export function accentText(accent: string, mode: 'dark' | 'light'): string {
const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(accent.trim());
if (!m) return accent;
const hex = '#' + (m[1].length === 3 ? [...m[1]].map((c) => c + c).join('') : m[1]).toLowerCase();
const surface = ACCENT_TEXT_SURFACE[mode];
const target = mode === 'dark' ? '#ffffff' : '#000000';
for (let step = 0; step <= 20; step++) {
const candidate = mixHex(hex, target, step / 20);
if ((contrastRatio(candidate, surface) ?? 0) >= AA_TEXT) return candidate;
}
return target;
}
/** `String(theme.grain)`/`String(theme.headerDot)` become a `data-*` attribute
* either way, but a non-boolean is a sign the value was never meant for this
* field at all — a typo'd key one level up, say — so it falls back rather

View File

@@ -67,3 +67,34 @@ export function contentDisposition(name: string | undefined): string {
.join('') || 'photo';
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(safe)}`;
}
/** The extensions a rendition's content type is saved under; the first is used. */
const EXTENSIONS: Record<string, readonly string[]> = {
'image/jpeg': ['jpg', 'jpeg', 'jpe', 'jfif'],
'image/webp': ['webp'],
'image/avif': ['avif'],
'image/png': ['png'],
};
/**
* The filename for a photo downloaded as edited in Immich (#831).
*
* Immich hands out an edited photo as a new rendition — a JPEG, measured
* against Immich 3.2 — not as the stored file, so an iPhone's `IMG_0001.HEIC`
* arrives as JPEG bytes. Keeping the name would leave a file the system opens
* with the wrong program, or not at all; the extension is changed to match
* what was sent. A name that already fits, or a type not listed here, is
* left alone.
*/
export function editedDownloadName(
name: string | undefined,
contentType: string | null | undefined,
): string | undefined {
const type = (contentType ?? '').toLowerCase().split(';')[0].trim();
const extensions = EXTENSIONS[type];
if (!name || !extensions) return name;
const dot = name.lastIndexOf('.');
const stem = dot > 0 ? name.slice(0, dot) : name;
const current = dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
return extensions.includes(current) ? name : `${stem}.${extensions[0]}`;
}

View File

@@ -31,3 +31,31 @@ export function formatCamera(make?: string | null, model?: string | null): strin
return namesBrand ? cleanModel : `${cleanMake} ${cleanModel}`;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* The lens, without the camera it is built into.
*
* Phones report the whole device as the lens: an iPhone gives
* `model: "iPhone 14 Pro"` and `lensModel: "iPhone 14 Pro back triple camera
* 6.86mm f/1.78"`, so the tile and the lightbox strip read the model twice
* ("IPHONE 14 PRO · IPHONE 14 PRO BACK TRIPLE CAMERA …"). When the camera is
* shown next to it, the model is removed from the lens as a whole phrase
* (case-insensitive, never inside another word). A lens that is nothing but
* the model says nothing the camera does not, and becomes empty.
*/
export function formatLens(lens?: string | null, model?: string | null): string {
const cleanLens = lens?.trim() ?? '';
const cleanModel = model?.trim() ?? '';
if (!cleanLens || !cleanModel) return cleanLens;
const phrase = new RegExp(`(^|\\s)${escapeRegExp(cleanModel)}(?=\\s|$)`, 'i');
if (!phrase.test(cleanLens)) return cleanLens;
return cleanLens.replace(phrase, ' ').replace(/\s+/g, ' ').trim();
}
/** The aperture sign. `text-transform: uppercase` turns it into "Ƒ". */
export const APERTURE_SIGN = 'ƒ';

View File

@@ -98,6 +98,7 @@ export const de: Dictionary = {
seconds
? `Zu viele Versuche – bitte in ${seconds} ${seconds === 1 ? 'Sekunde' : 'Sekunden'} erneut versuchen.`
: 'Zu viele Versuche – bitte später erneut versuchen.',
legalNavAria: 'Rechtliche Hinweise',
},
about: {
@@ -132,7 +133,6 @@ export const de: Dictionary = {
title: 'Journal',
kicker: 'Geschichten & Essays',
subtitle: 'Fotoessays, visuelle Geschichten und Notizen von unterwegs.',
description: 'Fotoessays, Reisegeschichten und Journale hinter den Kulissen.',
entryDescription: 'Journal-Eintrag',
empty: 'Noch keine Journal-Einträge veröffentlicht.',
readStory: 'Geschichte lesen →',
@@ -186,6 +186,14 @@ export const de: Dictionary = {
shortcutCopyLink: 'Link zum Foto kopieren',
shortcutList: 'Diese Liste',
shortcutClose: 'Ansicht schließen',
zoomIn: 'Auf volle Auflösung zoomen',
zoomInTitle: 'Auf volle Auflösung zoomen (+ oder Doppelklick)',
zoomOut: 'Foto an den Bildschirm anpassen',
zoomOutTitle: 'Einpassen (0 oder Esc)',
zoomLoading: 'Volle Auflösung wird geladen …',
zoomUnavailable: 'Für dieses Foto ist keine volle Auflösung verfügbar.',
shortcutZoom: 'Hinein- / herauszoomen',
shortcutZoomReset: 'Einpassen',
},
proofing: {

View File

@@ -99,6 +99,7 @@ export const en = {
seconds
? `Too many attempts. Please try again in ${seconds} ${seconds === 1 ? 'second' : 'seconds'}.`
: 'Too many attempts. Please try again later.',
legalNavAria: 'Legal information',
},
about: {
@@ -134,7 +135,6 @@ export const en = {
title: 'Journal',
kicker: 'Stories & Essays',
subtitle: 'Photo essays, visual stories, and field notes.',
description: 'Photo essays, travel stories, and behind-the-scenes journals.',
entryDescription: 'Journal entry',
empty: 'No journal entries published yet.',
readStory: 'Read Story →',
@@ -189,6 +189,16 @@ export const en = {
shortcutCopyLink: 'Copy link to this photo',
shortcutList: 'This list',
shortcutClose: 'Close the viewer',
/** The zoom button (#467): to 1:1 from fit, and back. */
zoomIn: 'Zoom to full resolution',
zoomInTitle: 'Zoom to full resolution (+ or double-click)',
zoomOut: 'Fit the photo to the screen',
zoomOutTitle: 'Fit to screen (0 or Esc)',
zoomLoading: 'Loading full resolution…',
/** Immich has no full-resolution file the viewer may show for this photo. */
zoomUnavailable: 'Full resolution is not available for this photo.',
shortcutZoom: 'Zoom in / out',
shortcutZoomReset: 'Fit to screen',
},
proofing: {

View File

@@ -98,6 +98,7 @@ export const es: Dictionary = {
seconds
? `Demasiados intentos. Inténtalo de nuevo en ${seconds} ${seconds === 1 ? 'segundo' : 'segundos'}.`
: 'Demasiados intentos. Inténtalo más tarde.',
legalNavAria: 'Información legal',
},
about: {
@@ -132,7 +133,6 @@ export const es: Dictionary = {
title: 'Diario',
kicker: 'Historias y ensayos',
subtitle: 'Ensayos fotográficos, historias visuales y notas de campo.',
description: 'Ensayos fotográficos, relatos de viaje y diarios entre bastidores.',
entryDescription: 'Entrada del diario',
empty: 'Todavía no hay entradas publicadas.',
readStory: 'Leer historia →',
@@ -186,6 +186,14 @@ export const es: Dictionary = {
shortcutCopyLink: 'Copiar enlace a esta foto',
shortcutList: 'Esta lista',
shortcutClose: 'Cerrar el visor',
zoomIn: 'Ampliar a resolución completa',
zoomInTitle: 'Ampliar a resolución completa (+ o doble clic)',
zoomOut: 'Ajustar la foto a la pantalla',
zoomOutTitle: 'Ajustar a la pantalla (0 o Esc)',
zoomLoading: 'Cargando la resolución completa…',
zoomUnavailable: 'La resolución completa no está disponible para esta foto.',
shortcutZoom: 'Acercar / alejar',
shortcutZoomReset: 'Ajustar a la pantalla',
},
proofing: {

View File

@@ -100,6 +100,7 @@ export const fr: Dictionary = {
seconds
? `Trop de tentatives. Veuillez réessayer dans ${seconds} ${seconds === 1 ? 'seconde' : 'secondes'}.`
: 'Trop de tentatives. Veuillez réessayer plus tard.',
legalNavAria: 'Informations légales',
},
about: {
@@ -134,7 +135,6 @@ export const fr: Dictionary = {
title: 'Journal',
kicker: 'Récits & essais',
subtitle: 'Essais photographiques, récits visuels et notes de terrain.',
description: 'Essais photographiques, récits de voyage et coulisses.',
entryDescription: 'Article du journal',
empty: 'Aucun article publié pour le moment.',
readStory: 'Lire le récit →',
@@ -189,6 +189,14 @@ export const fr: Dictionary = {
shortcutCopyLink: 'Copier le lien de cette photo',
shortcutList: 'Cette liste',
shortcutClose: 'Fermer la visionneuse',
zoomIn: 'Zoomer en pleine résolution',
zoomInTitle: 'Zoomer en pleine résolution (+ ou double-clic)',
zoomOut: 'Ajuster la photo à l’écran',
zoomOutTitle: 'Ajuster à l’écran (0 ou Échap)',
zoomLoading: 'Chargement de la pleine résolution…',
zoomUnavailable: 'La pleine résolution n’est pas disponible pour cette photo.',
shortcutZoom: 'Zoom avant / arrière',
shortcutZoomReset: 'Ajuster à l’écran',
},
proofing: {

View File

@@ -98,6 +98,7 @@ export const it: Dictionary = {
seconds
? `Troppi tentativi. Riprova tra ${seconds} ${seconds === 1 ? 'secondo' : 'secondi'}.`
: 'Troppi tentativi. Riprova più tardi.',
legalNavAria: 'Note legali',
},
about: {
@@ -132,7 +133,6 @@ export const it: Dictionary = {
title: 'Diario',
kicker: 'Storie e saggi',
subtitle: 'Saggi fotografici, racconti visivi e appunti sul campo.',
description: 'Saggi fotografici, racconti di viaggio e diari dietro le quinte.',
entryDescription: 'Articolo del diario',
empty: 'Nessun articolo pubblicato per ora.',
readStory: 'Leggi la storia →',
@@ -186,6 +186,14 @@ export const it: Dictionary = {
shortcutCopyLink: 'Copia il link a questa foto',
shortcutList: 'Questo elenco',
shortcutClose: 'Chiudi il visualizzatore',
zoomIn: 'Ingrandisci alla risoluzione piena',
zoomInTitle: 'Ingrandisci alla risoluzione piena (+ o doppio clic)',
zoomOut: 'Adatta la foto allo schermo',
zoomOutTitle: 'Adatta allo schermo (0 o Esc)',
zoomLoading: 'Caricamento della risoluzione piena…',
zoomUnavailable: 'La risoluzione piena non è disponibile per questa foto.',
shortcutZoom: 'Ingrandisci / riduci',
shortcutZoomReset: 'Adatta allo schermo',
},
proofing: {

View File

@@ -98,6 +98,7 @@ export const nl: Dictionary = {
seconds
? `Te veel pogingen. Probeer het over ${seconds} ${seconds === 1 ? 'seconde' : 'seconden'} opnieuw.`
: 'Te veel pogingen. Probeer het later opnieuw.',
legalNavAria: 'Juridische informatie',
},
about: {
@@ -132,7 +133,6 @@ export const nl: Dictionary = {
title: 'Journal',
kicker: 'Verhalen & essays',
subtitle: 'Fotoessays, beeldverhalen en veldnotities.',
description: 'Fotoessays, reisverhalen en een kijkje achter de schermen.',
entryDescription: 'Journalverhaal',
empty: 'Er zijn nog geen verhalen gepubliceerd.',
readStory: 'Lees het verhaal →',
@@ -187,6 +187,14 @@ export const nl: Dictionary = {
shortcutCopyLink: 'Link naar deze foto kopiëren',
shortcutList: 'Deze lijst',
shortcutClose: 'De viewer sluiten',
zoomIn: 'Inzoomen op volledige resolutie',
zoomInTitle: 'Inzoomen op volledige resolutie (+ of dubbelklik)',
zoomOut: 'Foto aan het scherm aanpassen',
zoomOutTitle: 'Aan scherm aanpassen (0 of Esc)',
zoomLoading: 'Volledige resolutie laden…',
zoomUnavailable: 'Voor deze foto is geen volledige resolutie beschikbaar.',
shortcutZoom: 'In- / uitzoomen',
shortcutZoomReset: 'Aan scherm aanpassen',
},
proofing: {

View File

@@ -48,11 +48,30 @@ export interface ImmichAsset {
originalFileName: string;
originalMimeType: string;
thumbhash: string | null;
/**
* Pixel size as displayed, EXIF orientation already applied. Optional:
* older Immich responses omit it, and EXIF dimensions are the fallback.
*/
width?: number | null;
height?: number | null;
fileCreatedAt: string;
/** Capture time in the photographer's local zone — Immich's timeline sort key. */
localDateTime?: string;
exifInfo?: ImmichExifInfo;
isTrashed: boolean;
/**
* Edited in Immich's editor (crop, rotate, …). Immich still answers
* `/original` and the default thumbnail sizes with the *unedited* file; only
* `?edited=true` applies the edit — see streamAsset() and editMarker() in
* ./urls (#831). `width`/`height` above are the edited size, EXIF the
* stored one. Optional: older Immich has no editor.
*/
isEdited?: boolean;
/**
* Last change to the asset in Immich. Read for edited photos only, as the
* cache marker of their image URLs (editMarker() in ./urls).
*/
updatedAt?: string;
/** Only surfaced in the admin pickers. Optional: older Immich responses omit it. */
isFavorite?: boolean;
}
@@ -108,6 +127,21 @@ function allowlistKey(config: AppConfig): string {
return [...config.albums].sort().join(',');
}
/**
* The Immich path for an asset's bytes. `edited=true` is only added when the
* edit is wanted, so a photo that is not edited is requested exactly as it
* always was (#831).
*/
export function assetBinaryPath(
assetId: string,
size: ImageSize | 'fullsize',
edited = false,
): string {
const id = encodeURIComponent(assetId);
if (size === 'original') return `/assets/${id}/original${edited ? '?edited=true' : ''}`;
return `/assets/${id}/thumbnail?size=${size}${edited ? '&edited=true' : ''}`;
}
// ── API Client ─────────────────────────────────────────────────
class ImmichClient {
@@ -270,19 +304,54 @@ class ImmichClient {
/**
* Stream a binary response from Immich (for image proxying).
*
* `edited` asks for the photo as edited in Immich's editor (#831). Measured
* against Immich 3.2: without it, the thumbnail, the preview and the
* original are the unedited file. With it, an edited photo's thumbnail and
* preview carry the crop or rotation, and its original is Immich's
* full-resolution edited rendition — a JPEG without EXIF, not the camera
* file. For a photo that is not edited, `edited=true` returns the same
* bytes as without it.
*/
async streamAsset(
assetId: string,
size: ImageSize = 'preview',
edited = false,
): Promise<{ stream: ReadableStream; contentType: string; contentLength: string | null } | null> {
return this.streamBinary(assetId, assetBinaryPath(assetId, size, edited), 'follow');
}
/**
* Immich's full-size rendition of an asset, or null when there is none (#467).
*
* `GET /assets/:id/thumbnail?size=fullsize` never 404s for a missing
* rendition: measured against Immich 3.2, it answers 302 to the original for
* a web-compatible format, and 302 to the 1440px preview when full-size
* previews are switched off or the asset has not been processed since they
* were switched on. Following either redirect would pass a preview off as
* full resolution, or hand out an original the caller did not choose — so
* redirects are not followed, and any 3xx means "no rendition".
*
* `edited` asks for the edited rendition of a photo edited in Immich
* (#831). Measured against Immich 3.2, an edited photo has one at full
* resolution — a JPEG — whether or not full-size previews are switched on,
* and it is the same file `original?edited=true` returns.
*/
async streamFullsize(
assetId: string,
edited = false,
): Promise<{ stream: ReadableStream; contentType: string; contentLength: string | null } | null> {
return this.streamBinary(assetId, assetBinaryPath(assetId, 'fullsize', edited), 'manual');
}
private async streamBinary(
assetId: string,
endpoint: string,
redirect: 'follow' | 'manual',
): Promise<{ stream: ReadableStream; contentType: string; contentLength: string | null } | null> {
const config = this.config;
if (!this.hasCredentials(config)) return null;
const endpoint =
size === 'original'
? `/assets/${encodeURIComponent(assetId)}/original`
: `/assets/${encodeURIComponent(assetId)}/thumbnail?size=${size}`;
const url = `${config.immich.apiUrl}${endpoint}`;
// Headers-only timeout, same reasoning as streamVideo: an original-size
@@ -296,8 +365,15 @@ class ImmichClient {
'x-api-key': config.immich.apiKey,
},
signal: controller.signal,
redirect,
});
// Only reachable with `redirect: 'manual'`: Immich has no such file.
if (res.status >= 300 && res.status < 400) {
await res.body?.cancel();
return null;
}
if (!res.ok) {
console.error(`[Immich] Failed to stream ${assetId}: ${res.status}`);
// Neither branch below reads the body — an unconsumed one keeps its
@@ -734,7 +810,26 @@ class ImmichClient {
}
/**
* Find an album by its URL slug.
* Find an album by its URL slug, with its assets.
*
* The search set follows the route, never the whole allowlist: see
* findAlbumBySlug().
*/
async getAlbumBySlug(
slug: string,
subpageSlug?: string,
forceFresh = false,
): Promise<ImmichAlbum | null> {
const match = await this.findAlbumBySlug(slug, subpageSlug, forceFresh);
if (!match) return null;
return this.getAlbum(match.id, forceFresh);
}
/**
* The published album a URL slug names, from the album list alone — no asset
* fetch. The catch-all page asks this before it streams, to know whether a
* path exists while it can still answer 404; the list is the one the header
* nav already waits for, so the question costs no extra Immich request.
*
* The search set follows the route, never the whole allowlist: with a
* subpageSlug it is that subpage's albums, without one it is the standalone
@@ -742,7 +837,7 @@ class ImmichClient {
* top-level slug would answer for an album whose only route is a subpage —
* past that subpage's password, and past `enabled: false`.
*/
async getAlbumBySlug(
async findAlbumBySlug(
slug: string,
subpageSlug?: string,
forceFresh = false,
@@ -764,9 +859,7 @@ class ImmichClient {
const searchSet = albums.filter((a) => routeIds.has(a.id));
const wanted = normalizeSlug(slug);
const match = searchSet.find((a) => a.slug === wanted);
if (!match) return null;
return this.getAlbum(match.id, forceFresh);
return searchSet.find((a) => a.slug === wanted) ?? null;
}
/**

View File

@@ -453,6 +453,32 @@ function parsePhotoLine(chunk: string): { target: string; caption: string } | nu
return { target, caption: rest.trim() };
}
/**
* A text line that begins like a photo reference. The serializer escapes it
* so a paragraph reading `![draft]` is not reloaded as a photo block — the
* parser splits a chunk at every line that starts with `![` and turns it into
* one (splitAtPhotoLines, parsePhotoLine). The escape is one backslash in
* front of whatever backslashes are already there, so a line that really
* starts with `\![` round-trips too. Only a line start needs it: elsewhere
* in a paragraph `![` means nothing to the parser.
*/
const PHOTO_LIKE_LINE = /^([ \t]*)(\\*)!\[/;
const ESCAPED_PHOTO_LIKE_LINE = /^([ \t]*)\\(\\*)!\[/;
function escapePhotoLikeLines(markdown: string): string {
return markdown
.split('\n')
.map((line) => line.replace(PHOTO_LIKE_LINE, '$1\\$2!['))
.join('\n');
}
function unescapePhotoLikeLines(markdown: string): string {
return markdown
.split(/\r?\n/)
.map((line) => line.replace(ESCAPED_PHOTO_LIKE_LINE, '$1$2!['))
.join('\n');
}
/** Parse Journal Markdown content into structured blocks */
export function parseJournalMarkdown(rawContent: string): ParsedJournal {
const { frontmatter, body } = parseFrontmatter(rawContent);
@@ -653,7 +679,7 @@ export function parseJournalMarkdown(rawContent: string): ParsedJournal {
// 4. Standard Text Paragraph
blocks.push({
type: 'paragraph',
html: renderInlineMarkdown(chunk.replace(/\r?\n/g, ' ')),
html: renderInlineMarkdown(unescapePhotoLikeLines(chunk).replace(/\r?\n/g, ' ')),
});
}
@@ -703,7 +729,7 @@ export function serializeJournalMarkdown(journal: ParsedJournal): string {
break;
}
case 'paragraph': {
lines.push(inlineHtmlToMarkdown(block.html));
lines.push(escapePhotoLikeLines(inlineHtmlToMarkdown(block.html)));
break;
}
case 'quote': {

View File

@@ -47,3 +47,26 @@ export function formatJournalDate(raw: string | undefined, locale: string): stri
return value;
}
/**
* The `datetime` attribute for a `<time>` around that display form: the
* calendar date (`2026-03-15`) or month (`2026-03`) it names, or null when
* the value is free text — `Spring 2026` has no machine-readable form, and a
* `<time>` carrying one would claim its text is a valid date string.
*/
export function journalDateTime(raw: string | undefined): string | null {
const value = raw?.trim();
if (!value) return null;
const day = DAY.exec(value);
if (day) {
// An impossible date (2026-02-30) is displayed as typed; the attribute
// must not claim it either.
return formatJournalDate(value, 'en') === value ? null : `${day[1]}-${day[2]}-${day[3]}`;
}
const month = MONTH.exec(value);
if (month) {
const m = Number(month[2]);
return m >= 1 && m <= 12 ? `${month[1]}-${month[2]}` : null;
}
return null;
}

36
lib/justifiedRow.ts Normal file
View File

@@ -0,0 +1,36 @@
/**
* Flex sizing for one tile of the justified photo layout.
*
* Every tile in a row has the same height (--grid-row-height). Its basis is
* the width the photo needs at that height, and the leftover row width is
* shared out by `flex-grow`, which is also the aspect ratio, so every photo in
* the row widens by the same factor.
*
* A frame that puts a mat around the photo (passepartout) makes the tile
* larger than the photo: --photo-mat-x across, --photo-mat-y down, both set
* by the frame in globals.css and 0 without one. The basis therefore sizes
* the photo inside the mat and adds the mat back; basing the whole tile on
* the row height would give each photo the aspect ratio of its tile instead of
* its own, cropping portraits hardest.
*
* Client-safe: no imports, used by PhotoGrid.
*/
/** Assumed for assets without dimensions (videos), ~3:2. */
export const JUSTIFIED_FALLBACK_RATIO = 1.5;
export interface JustifiedTileStyle {
flexGrow: number;
flexBasis: string;
}
export function justifiedTileStyle(aspectRatio?: number): JustifiedTileStyle {
const ar =
aspectRatio && Number.isFinite(aspectRatio) && aspectRatio > 0
? aspectRatio
: JUSTIFIED_FALLBACK_RATIO;
return {
flexGrow: ar,
flexBasis: `calc((var(--grid-row-height, 300px) - var(--photo-mat-y, 0px)) * ${ar} + var(--photo-mat-x, 0px))`,
};
}

View File

@@ -27,7 +27,9 @@ export type ShortcutAvailability =
/** Absent where the browser has no element fullscreen to give (iPhone). */
| 'fullscreen'
/** Only where the album offers its originals. */
| 'download';
| 'download'
/** Only where the photo on screen can be zoomed (#467). */
| 'zoom';
/**
* What a key does. Closed on purpose: the viewer switches over this union
@@ -42,6 +44,9 @@ export type LightboxAction =
| 'slideshow'
| 'copyLink'
| 'download'
| 'zoomIn'
| 'zoomOut'
| 'zoomReset'
| 'shortcutList';
export interface ShortcutBinding {
@@ -70,6 +75,8 @@ export interface LightboxShortcut {
| 'shortcutSlideshow'
| 'shortcutCopyLink'
| 'shortcutDownload'
| 'shortcutZoom'
| 'shortcutZoomReset'
| 'shortcutList'
| 'shortcutClose';
availability: ShortcutAvailability;
@@ -119,6 +126,24 @@ export const LIGHTBOX_SHORTCUTS: readonly LightboxShortcut[] = [
availability: 'download',
note: 'Only for albums with download: true in gallery.yaml.',
},
{
// `=` is `+` without Shift on US and many other layouts; the numpad sends
// `+` and `-` directly. Ctrl/Cmd with these stays the browser's own zoom:
// the viewer only listens to bare keys.
bindings: [
{ display: '+', eventKeys: ['+', '='], action: 'zoomIn' },
{ display: '-', eventKeys: ['-'], action: 'zoomOut' },
],
labelKey: 'shortcutZoom',
availability: 'zoom',
note: 'Only where zoom is on (settings, page or album) and the photo has a full-resolution source.',
},
{
bindings: [{ display: '0', eventKeys: ['0'], action: 'zoomReset' }],
labelKey: 'shortcutZoomReset',
availability: 'zoom',
note: 'Back to the whole photo. Esc does the same before it closes the viewer.',
},
{
// `h` is the escape hatch for layouts where `?` is awkward — and the one
// key someone guesses without having been told.

1250
lib/locationScrub.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,52 @@ export interface MapAlbumEntry {
latSum: number;
lngSum: number;
coverAssetId: string;
/** Set when the cover was edited in Immich, for its image URL's edit marker (#831). */
coverEdit?: { isEdited: true; updatedAt?: string };
}
interface MapConfig {
standaloneAlbums?: readonly string[];
subpages: ReadonlyArray<{
slug: string;
albumIds: readonly string[];
enabled?: boolean;
hidden?: boolean;
}>;
}
/**
* The subpage a marker links an album to, and whose password gates it on the
* map: the first listed one that carries it. Never an offline subpage (its URL
* is a 404 and its password guards nothing), and never a hidden one (the
* marker would publish its address).
*/
function mapSubpageFor(config: MapConfig, albumId: string) {
return config.subpages.find((s) => isListedSubpage(s) && s.albumIds.includes(albumId));
}
/**
* The counts the /map header prints, for this viewer.
*
* `/api/map` drops every album whose subpage or own password the viewer has
* not unlocked; the header counted them anyway, so a visitor read "3
* collections · 12 albums" above a map that showed two. Same rule here, the
* same subpage per album (`mapSubpageFor`), so the two cannot disagree.
* `isAllowed` is `isAuthenticated` bound to the request's cookies.
*/
export function visibleMapCounts(
config: MapConfig,
isAllowed: (key: string, type: 'subpage' | 'album') => boolean,
): { collections: number; albums: number } {
const collections = config.subpages.filter(
(sp) => isListedSubpage(sp) && isAllowed(sp.slug, 'subpage'),
).length;
let albums = 0;
for (const id of listedAlbumIds(config)) {
const sp = mapSubpageFor(config, id);
if ((!sp || isAllowed(sp.slug, 'subpage')) && isAllowed(id, 'album')) albums++;
}
return { collections, albums };
}
/** A clustered map marker — one per unique city/country. */
@@ -56,10 +102,7 @@ export async function getMapData(): Promise<MapLocation[]> {
// Build a lookup: album ID → { name, slug, subpageSlug? }
const albumMeta = new Map<string, { name: string; slug: string; subpageSlug?: string }>();
for (const a of albums) {
// The subpage the marker links to, and whose password gates it. Never
// an offline one (its URL is a 404 and its password guards nothing),
// and never a hidden one: the marker would publish its address.
const sp = config.subpages.find((s) => isListedSubpage(s) && s.albumIds.includes(a.id));
const sp = mapSubpageFor(config, a.id);
albumMeta.set(a.id, { name: a.albumName, slug: a.slug, subpageSlug: sp?.slug });
}
@@ -107,6 +150,9 @@ export async function getMapData(): Promise<MapLocation[]> {
latSum: 0,
lngSum: 0,
coverAssetId: asset.id,
...(asset.isEdited === true
? { coverEdit: { isEdited: true as const, updatedAt: asset.updatedAt } }
: {}),
};
byAlbum.set(album.id, entry);
}

View File

@@ -57,7 +57,18 @@ export interface ProcessingFact {
*/
export function processingFacts(
config: AppConfig,
env: { CDN_URL?: string; hasPasswords: boolean },
env: {
CDN_URL?: string;
hasPasswords: boolean;
/**
* At least one proofing link exists in content/proofing.json. Links are
* created in the admin panel whatever `proofing.enabled` says, so the
* setting cannot stand in for this.
*/
hasProofingLinks: boolean;
/** PROOFING_WEBHOOK_URL, already parsed (lib/proofWebhook.ts). */
proofingWebhookUrl?: string | null;
},
): ProcessingFact[] {
const facts: ProcessingFact[] = [
{
@@ -115,13 +126,27 @@ export function processingFacts(
thirdParty: false,
});
}
// A subpage's own `proofing: true` turns the hearts on even with the global
// switch off (resolveProofing), so the global flag alone would under-report.
const favourites = config.proofing.enabled || config.subpages.some((sp) => sp.proofing);
facts.push({
topic: 'Browser storage',
detail: `The colour-mode choice${
config.proofing.enabled ? ' and photo selections (proofing)' : ''
} are kept in the visitor’s own browser (localStorage) and never sent to the server.`,
detail: favourites
? 'The colour-mode choice and the photos a visitor marks as favourites on an album are kept in the visitor’s own browser (localStorage). Folio does not store them on the server.'
: 'The colour-mode choice is kept in the visitor’s own browser (localStorage). Folio does not store it on the server.',
thirdParty: false,
});
if (env.hasProofingLinks) {
facts.push({
topic: 'Proofing links',
detail: `Each client proofing link (/proof/…) is stored on this server in content/proofing.json: the client name you entered, the photos the client selects, when the selection was last changed and submitted, and how many ZIP downloads were used. The data stays until you delete the link in the admin panel; an expired link stops working but keeps its data.${
env.proofingWebhookUrl
? ` When the client submits, a notification with the client name, the album name and the number of selected photos goes to ${hostOf(env.proofingWebhookUrl)}.`
: ''
}`,
thirdParty: !!env.proofingWebhookUrl,
});
}
if (env.CDN_URL) {
facts.push({
topic: 'CDN',
@@ -152,6 +177,7 @@ export function starterHeadings(lang: string, facts: ProcessingFact[]): string {
topics.has('Map') ? h('Map', 'Kartendienst') : '',
topics.has('Visitor statistics') ? h('Visitor statistics', 'Besucherstatistik') : '',
topics.has('Contact form') ? h('Contact form', 'Kontaktformular') : '',
topics.has('Proofing links') ? h('Client proofing', 'Bildauswahl durch Kunden') : '',
h('Cookies and browser storage', 'Cookies und lokaler Speicher'),
topics.has('CDN') ? h('Content delivery network', 'Content Delivery Network') : '',
h('Your rights', 'Ihre Rechte'),

20
lib/themeScript.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* The colour mode, applied before the first paint.
*
* The server renders the configured mode onto <html> (`data-theme`, or nothing
* for `mode: auto`), but it cannot know a visitor's stored choice or their OS
* setting. ThemeToggle corrects both — once its bundle has loaded and run,
* which is after the page has already painted in the server's guess. A visitor
* who picked light saw a dark flash on every navigation.
*
* This script runs synchronously in <head>, so the attribute is right before
* the body is parsed. It must stay self-contained (it is inlined as a string)
* and carries the CSP nonce, since `proxy.ts` allows no other inline script.
* It is also the only thing that applies the mode on the site gate, which
* renders without the header and therefore without ThemeToggle.
*
* The rules match ThemeToggle: a stored choice wins, then the configured mode
* (`data-default-theme`), and `auto` — or no attribute at all — follows the OS.
* Storage can throw (blocked site data); the server's attribute then stands.
*/
export const THEME_INIT_SCRIPT = `(function(){try{var d=document.documentElement,t=null;try{t=localStorage.getItem('theme')}catch(e){}if(t!=='light'&&t!=='dark'){t=d.getAttribute('data-default-theme');if(t!=='light'&&t!=='dark'){t=window.matchMedia&&window.matchMedia('(prefers-color-scheme: light)').matches?'light':'dark'}}d.setAttribute('data-theme',t)}catch(e){}})();`;

View File

@@ -8,6 +8,7 @@ import { env } from './env';
import { cdnBase } from './cdn';
import { thumbHashToBlurDataUrl, thumbHashToDominantHex } from './thumbhash';
import type { ImmichAsset } from './immich';
import { formatLens } from './exif';
/**
* Optional cache-buster, appended to every image and video URL.
@@ -22,15 +23,56 @@ import type { ImmichAsset } from './immich';
*/
const cacheBuster = env.IMAGE_CACHE_VERSION ? `&v=${env.IMAGE_CACHE_VERSION}` : '';
/** What an image URL needs to know about an asset beyond its ID (#831). */
export interface ImageRef {
id: string;
isEdited?: boolean | null;
updatedAt?: string | null;
}
/**
* The `e` parameter an edited photo's image URL carries, or null for a photo
* that is not edited (#831).
*
* Immich applies an edit made in its editor (crop, rotate) only when asked
* with `edited=true`. The image route always asks — the server decides that
* an edit is shown, not the URL. The marker is purely a cache key: the asset
* ID does not change when a photo is edited, so without it a browser or CDN
* that cached the unedited preview under the same URL would keep showing it
* for a year (`immutable`). Only edited photos get a new URL; every other URL
* stays as it was, which is why this is not a bump of IMAGE_CACHE_VERSION.
*
* The value is the asset's `updatedAt` in whole seconds, base 36 — precise
* enough to tell two edits apart, without publishing the millisecond. Immich regenerates an
* asset's thumbnails and ThumbHash (and, for a crop or a quarter turn, its
* dimensions) when an edit is applied or changed, which writes the asset and
* moves `updatedAt` — so a re-edit produces a new URL once the album cache
* (CACHE_TTL) has picked it up. Other updates to an edited photo move it too;
* that costs one refetch of that photo, nothing more.
*/
export function editMarker(asset: Omit<ImageRef, 'id'>): string | null {
if (asset.isEdited !== true) return null;
const time = asset.updatedAt ? Date.parse(asset.updatedAt) : NaN;
return Number.isFinite(time) && time >= 1000 ? Math.floor(time / 1000).toString(36) : '1';
}
/**
* Generate a public image proxy URL for an asset. Absolute, on the CDN, when
* CDN mode is on (lib/cdn.ts); relative otherwise.
*
* Pass the asset rather than its ID wherever it is at hand: only then can an
* edited photo get its edit marker (see editMarker()). A bare ID still shows
* the edit — the route always asks for it — but under the same URL as before
* the edit, so a copy cached earlier can outlive it.
*/
export function imageUrl(
assetId: string,
asset: string | ImageRef,
size: 'thumbnail' | 'preview' | 'original' = 'preview',
): string {
return `${cdnBase()}/api/image/${encodeAssetId(assetId)}?size=${size}${cacheBuster}`;
const ref = typeof asset === 'string' ? { id: asset } : asset;
const marker = editMarker(ref);
const edit = marker ? `&e=${marker}` : '';
return `${cdnBase()}/api/image/${encodeAssetId(ref.id)}?size=${size}${edit}${cacheBuster}`;
}
/**
@@ -92,7 +134,8 @@ export function assetExifSummary(asset: Pick<ImmichAsset, 'exifInfo'>): ExifSumm
if (!exif) return undefined;
const camera = exif.model || undefined;
const lens = exif.lensModel || undefined;
// A phone's lens names the phone again; the camera is already on the tile.
const lens = formatLens(exif.lensModel, exif.model) || undefined;
const focalLength = exif.focalLength ? `${exif.focalLength}mm` : undefined;
if (!camera && !lens && !focalLength) return undefined;
@@ -110,6 +153,19 @@ export function downloadUrl(albumId: string, assetId: string): string {
return `/api/download/${encodeAssetId(albumId)}/${encodeAssetId(assetId)}`;
}
/**
* The full-resolution image the lightbox zooms into (#467).
*
* Shaped like downloadUrl() for the same reason: the route authorises the
* zoom against the album that offered it — allowlist, every password gate,
* zoom resolved on — and checks the asset belongs to it. Relative even in CDN
* mode: the answer depends on the visitor's cookies and is `private`, so it
* must not be cached at the edge.
*/
export function zoomUrl(albumId: string, assetId: string): string {
return `/api/zoom/${encodeAssetId(albumId)}/${encodeAssetId(assetId)}`;
}
/**
* The endpoint that streams an album (or a selection of its assets) as a ZIP.
*
@@ -160,8 +216,22 @@ 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.
*
* A photo edited in Immich (#831) is the exception: its EXIF still describes
* the file as stored, before a crop or a quarter turn, while the edited
* rendition Folio shows has other proportions. Immich's top-level
* `width`/`height` are the edited, upright size, so those are used for it.
*/
export function assetAspectRatio(asset: Pick<ImmichAsset, 'exifInfo'>): number | undefined {
export function assetAspectRatio(
asset: Pick<ImmichAsset, 'exifInfo' | 'isEdited' | 'width' | 'height'>,
): number | undefined {
if (asset.isEdited === true) {
// Without the edited size there is nothing right to say: EXIF would give
// the unedited proportions. Undefined leaves the layout's default ratio.
return asset.width && asset.height && asset.width > 0 && asset.height > 0
? asset.width / asset.height
: undefined;
}
const w = asset.exifInfo?.exifImageWidth;
const h = asset.exifInfo?.exifImageHeight;
if (!w || !h || h <= 0) return undefined;

View File

@@ -10,13 +10,14 @@ import { NextRequest, NextResponse } from 'next/server';
import { Readable } from 'node:stream';
import archiver from 'archiver';
import { immich, type ImmichAsset } from '@/lib/immich';
import { contentDisposition, safeDownloadName } from '@/lib/downloadName';
import { contentDisposition, editedDownloadName, safeDownloadName } from '@/lib/downloadName';
import { getClientIp } from '@/lib/rate-limit';
import { getDictionary } from '@/lib/i18n';
import { getLocale, getServerDictionary } from '@/lib/i18n/server';
import { getConfigOrNull } from '@/lib/config';
import { accentForMode, resolveTheme } from '@/lib/config/theme';
import { env } from '@/lib/env';
import { scrubLocationStream } from '@/lib/locationScrub';
/** Escape a value interpolated into the refusal page. */
function escapeHtml(value: string): string {
@@ -116,6 +117,26 @@ export const REFUSAL_PALETTES: Record<string, { dark: Palette; light: Palette }>
dark: { bg: '#151515', text: '#e8e8e8', muted: '#a5a5a5' },
light: { bg: '#fafafa', text: '#111111', muted: '#475569' },
},
kunsthalle: {
dark: { bg: '#2b2b29', text: '#f0efeb', muted: '#c6c4bd' },
light: { bg: '#dddcd8', text: '#1b1b19', muted: '#3f3e3a' },
},
ma: {
dark: { bg: '#1a1917', text: '#ece7dd', muted: '#b5ad9f' },
light: { bg: '#f0ede6', text: '#1f1d1a', muted: '#4d4943' },
},
cyanotype: {
dark: { bg: '#0e1621', text: '#e3eaf0', muted: '#a8b7c6' },
light: { bg: '#eef2f4', text: '#10243a', muted: '#34495f' },
},
salon: {
dark: { bg: '#1c1315', text: '#f3eae7', muted: '#c5b1ad' },
light: { bg: '#f5efec', text: '#271618', muted: '#573f42' },
},
birch: {
dark: { bg: '#121613', text: '#e9eee9', muted: '#aab5ac' },
light: { bg: '#f3f5f1', text: '#19201b', muted: '#434e46' },
},
};
const HEX_COLOUR = /^#[0-9a-f]{3,8}$/i;
@@ -216,6 +237,28 @@ function uniqueEntryName(raw: string | undefined, used: Set<string>): string {
return name;
}
/**
* The timestamp an entry carries: when the photo was taken, not when the ZIP
* was built. Without it every file unpacked with the download time, so a
* folder sorted by date lost the album's order.
*
* A ZIP stores a wall-clock time with no zone, and archiver writes the UTC
* fields of the Date it is given. Immich's `localDateTime` is exactly that
* shape — the capture time in the photographer's zone, written as if it were
* UTC — so it lands in the archive as the time on the camera, whatever zone
* the server runs in. `dateTimeOriginal` and `fileCreatedAt` are real instants
* and only the fallbacks. All three come with the album response: no extra
* Immich request. Undefined leaves archiver's default (now).
*/
export function entryDate(asset: ImmichAsset): Date | undefined {
for (const raw of [asset.localDateTime, asset.exifInfo?.dateTimeOriginal, asset.fileCreatedAt]) {
if (!raw) continue;
const date = new Date(raw);
if (!Number.isNaN(date.getTime())) return date;
}
return undefined;
}
/**
* Append one entry, resolving once it has been written (or the archive has been
* torn down because the visitor left).
@@ -232,7 +275,12 @@ function uniqueEntryName(raw: string | undefined, used: Set<string>): string {
* that for a queued or half-read entry, and the upstream body would otherwise
* hold its socket out of undici's pool until GC finalises it (#635).
*/
function appendEntry(archive: archiver.Archiver, source: Readable, name: string): Promise<void> {
function appendEntry(
archive: archiver.Archiver,
source: Readable,
name: string,
date?: Date,
): Promise<void> {
return new Promise<void>((resolve, reject) => {
const cleanup = () => {
archive.off('entry', onEntry);
@@ -259,7 +307,7 @@ function appendEntry(archive: archiver.Archiver, source: Readable, name: string)
archive.once('entry', onEntry);
archive.once('error', onError);
archive.once('close', onClose);
archive.append(source, { name });
archive.append(source, date ? { name, date } : { name });
});
}
@@ -352,7 +400,8 @@ export async function withArchiveSlot(
}
/**
* Stream `assets` as a ZIP of originals.
* Stream `assets` as a ZIP of originals, without their location metadata
* (lib/locationScrub.ts).
*
* archiver writes data descriptors, so entry sizes are never known up front and
* memory stays flat no matter how large the album is. The loop pulls one
@@ -439,20 +488,65 @@ export function streamArchive(
void (async () => {
try {
const used = new Set<string>();
// Originals refused by the location scrubber. The ZIP carries on without
// them, so the count is the only trace a visitor's archive came up short.
let dropped = 0;
for (const asset of assets) {
// The visitor left (or the archive failed): stop pulling originals.
if (archive.destroyed) break;
const result = await immich.streamAsset(asset.id, 'original');
// Edited in Immich: the edit, not the unedited camera file (#831).
const edited = asset.isEdited === true;
const result = await immich.streamAsset(asset.id, 'original', edited);
if (!result) continue;
if (archive.destroyed) {
// Left while the headers were on their way: release the body unread.
await result.stream.cancel();
break;
}
// Location out, as for the single download. Only the head of each
// original is held while its metadata is found, so memory stays flat.
const scrubbed = await scrubLocationStream(result.stream as ReadableStream<Uint8Array>);
if (!scrubbed.ok) {
dropped++;
console.warn(
`[Download] Left asset ${asset.id} out of the archive "${albumName}": its location metadata could not be removed (${scrubbed.reason}).`,
);
continue;
}
// An edited photo's rendition is expected as JPEG (#831); one the
// scrubber passes through untouched stays out, as for the single
// download.
if (edited && scrubbed.format === 'passthrough') {
dropped++;
await scrubbed.stream.cancel().catch(() => {});
console.warn(
`[Download] Left edited asset ${asset.id} out of the archive "${albumName}": Immich sent ${result.contentType || 'no type'}, which is not a JPEG or HEIF-family file.`,
);
continue;
}
if (archive.destroyed) {
await scrubbed.stream.cancel();
break;
}
const nodeStream = Readable.fromWeb(
result.stream as unknown as import('node:stream/web').ReadableStream,
scrubbed.stream as unknown as import('node:stream/web').ReadableStream,
);
await appendEntry(
archive,
nodeStream,
uniqueEntryName(
edited
? editedDownloadName(asset.originalFileName, result.contentType)
: asset.originalFileName,
used,
),
entryDate(asset),
);
}
if (dropped) {
console.warn(
`[Download] Archive "${albumName}" is missing ${dropped} of ${assets.length} originals; see the lines above for the asset IDs.`,
);
await appendEntry(archive, nodeStream, uniqueEntryName(asset.originalFileName, used));
}
if (!archive.destroyed) await archive.finalize();
} catch (err) {

234
lib/zoom.ts Normal file
View File

@@ -0,0 +1,234 @@
/**
* Zoom geometry for the lightbox (#467). Pure, so it can be tested without a
* DOM; the gesture wiring lives in hooks/useZoom.ts.
*
* The model: the photo is laid out at its "fit" size, as the lightbox always
* shows it, and zooming applies `translate(x, y) scale(scale)` to that box
* with the transform origin at its centre. `scale: 1` is fit. Every
* coordinate is in CSS pixels relative to the viewport.
*
* The top of the range is 1:1 — one image pixel per *device* pixel, so the
* CSS size at 1:1 is the image width divided by devicePixelRatio. That is the
* view a sharpness check needs. Going further only interpolates pixels: it
* shows nothing the file does not hold and makes good focus look soft, so the
* range is clamped to [fit, 1:1] rather than [fit, 2 × 1:1].
*/
/** The current transform of the fit box. */
export interface ZoomView {
scale: number;
/** Translation of the box centre, CSS px. */
x: number;
y: number;
}
/** Where the photo sits at fit: the centre of its box and its size, CSS px. */
export interface FitBox {
cx: number;
cy: number;
width: number;
height: number;
}
export interface Viewport {
width: number;
height: number;
}
export interface Point {
x: number;
y: number;
}
/**
* The largest image offered for zoom on a touch device (coarse pointer), in
* pixels. Showing a photo at 1:1 means decoding all of it: 50 MP is 200 MB of
* RGBA, about what phones and tablets cope with alongside the page. Larger
* files (a 60 MP Leica Q3 frame, 240 MB decoded) get no zoom control there,
* rather than one that crashes the tab. Mouse and trackpad devices are not
* limited.
*/
export const TOUCH_ZOOM_MAX_PIXELS = 50_000_000;
/** Whether a photo of this pixel size may be zoomed on this kind of device. */
export function zoomFitsDevice(
width: number | undefined,
height: number | undefined,
coarsePointer: boolean,
): boolean {
if (!width || !height) return false;
return !coarsePointer || width * height <= TOUCH_ZOOM_MAX_PIXELS;
}
/** The untouched view. */
export const FIT_VIEW: ZoomView = Object.freeze({ scale: 1, x: 0, y: 0 });
/**
* Below this the view counts as fit. Pinching back almost to fit should land
* on fit, not leave the photo a pixel off and swipe navigation switched off.
*/
const FIT_EPSILON = 0.02;
export function isZoomed(view: ZoomView): boolean {
return view.scale > 1 + FIT_EPSILON;
}
/**
* The scale (relative to fit) at which one image pixel covers one device
* pixel. Below 1 when the photo is already shown at more than 1:1 — a small
* file on a large screen — which leaves nothing to zoom into.
*/
export function oneToOneScale(naturalWidth: number, fitWidth: number, dpr: number): number {
if (!(naturalWidth > 0) || !(fitWidth > 0)) return 1;
const ratio = dpr > 0 && Number.isFinite(dpr) ? dpr : 1;
return naturalWidth / ratio / fitWidth;
}
/** The top of the zoom range: 1:1, and never below fit. */
export function maxZoomScale(naturalWidth: number, fitWidth: number, dpr: number): number {
return Math.max(1, oneToOneScale(naturalWidth, fitWidth, dpr));
}
/** Whether zooming would show anything the fit view does not. */
export function canZoom(maxScale: number): boolean {
return maxScale > 1 + FIT_EPSILON;
}
export function clampScale(scale: number, maxScale: number): number {
if (!Number.isFinite(scale)) return 1;
return Math.min(Math.max(scale, 1), Math.max(1, maxScale));
}
/**
* The allowed range for one axis of the translation.
*
* A box larger than the viewport may move until its edge meets the viewport
* edge, never further — no empty band appears beside the photo. A box smaller
* than the viewport may move anywhere that keeps it fully on screen; that
* range always contains 0, so a photo that is barely zoomed stays where it was
* instead of jumping to the centre.
*/
function axisRange(centre: number, half: number, extent: number): [number, number] {
const a = half - centre;
const b = extent - centre - half;
return [Math.min(a, b), Math.max(a, b)];
}
/** Keep the photo covering the viewport (or fully on it), at a clamped scale. */
export function clampView(
view: ZoomView,
fit: FitBox,
viewport: Viewport,
maxScale: number,
): ZoomView {
const scale = clampScale(view.scale, maxScale);
if (scale <= 1) return { ...FIT_VIEW };
const [minX, maxX] = axisRange(fit.cx, (fit.width * scale) / 2, viewport.width);
const [minY, maxY] = axisRange(fit.cy, (fit.height * scale) / 2, viewport.height);
return {
scale,
x: Math.min(Math.max(view.x, minX), maxX),
y: Math.min(Math.max(view.y, minY), maxY),
};
}
/**
* Change the scale while the photo point under `point` stays under it — the
* cursor for a wheel or a double-click, the midpoint of two fingers for a
* pinch. Clamping can still move it at the edges, which is the price of never
* showing an empty band.
*/
export function zoomAround(
view: ZoomView,
nextScale: number,
point: Point,
fit: FitBox,
viewport: Viewport,
maxScale: number,
): ZoomView {
const scale = clampScale(nextScale, maxScale);
// The photo point under `point`, as an offset from the box centre at scale 1.
const u = (point.x - fit.cx - view.x) / view.scale;
const v = (point.y - fit.cy - view.y) / view.scale;
return clampView(
{ scale, x: point.x - fit.cx - u * scale, y: point.y - fit.cy - v * scale },
fit,
viewport,
maxScale,
);
}
/**
* The pinch step: the photo point that was under the fingers' midpoint when
* the pinch began follows the midpoint, at the start scale times how far the
* fingers spread. Computed from the start of the gesture rather than
* incrementally, so rounding cannot drift the photo away under the fingers.
*/
export function pinchView(
start: ZoomView,
startMid: Point,
startDistance: number,
mid: Point,
distance: number,
fit: FitBox,
viewport: Viewport,
maxScale: number,
): ZoomView {
const factor = startDistance > 0 ? distance / startDistance : 1;
const scale = clampScale(start.scale * factor, maxScale);
const u = (startMid.x - fit.cx - start.x) / start.scale;
const v = (startMid.y - fit.cy - start.y) / start.scale;
return clampView(
{ scale, x: mid.x - fit.cx - u * scale, y: mid.y - fit.cy - v * scale },
fit,
viewport,
maxScale,
);
}
/** Move the photo by a pointer delta, clamped. */
export function panBy(
view: ZoomView,
dx: number,
dy: number,
fit: FitBox,
viewport: Viewport,
maxScale: number,
): ZoomView {
return clampView({ ...view, x: view.x + dx, y: view.y + dy }, fit, viewport, maxScale);
}
/** Double-click / double-tap: fit ↔ 1:1, around the point. */
export function toggleView(
view: ZoomView,
point: Point,
fit: FitBox,
viewport: Viewport,
maxScale: number,
): ZoomView {
if (isZoomed(view)) return { ...FIT_VIEW };
return zoomAround(view, maxScale, point, fit, viewport, maxScale);
}
/**
* The `+` / `-` keys and the wheel: multiply the scale, around the centre of
* the viewport unless a point is given. A step that lands within reach of fit
* snaps to it.
*/
export function stepView(
view: ZoomView,
factor: number,
fit: FitBox,
viewport: Viewport,
maxScale: number,
point?: Point,
): ZoomView {
const at = point ?? { x: viewport.width / 2, y: viewport.height / 2 };
const next = zoomAround(view, view.scale * factor, at, fit, viewport, maxScale);
return isZoomed(next) ? next : { ...FIT_VIEW };
}
/** The CSS transform for a view. */
export function viewTransform(view: ZoomView): string {
return `translate3d(${view.x}px, ${view.y}px, 0) scale(${view.scale})`;
}

108
lib/zoomSource.ts Normal file
View File

@@ -0,0 +1,108 @@
/**
* Which file the lightbox zoom (#467) may show for an asset, if any.
*
* Decided from what the album response already carries — the original's MIME
* type, else its file extension — so the page knows without asking Immich.
*
* - `original`: JPEG and AVIF. The browser shows them as they are, and
* lib/locationScrub.ts removes GPS and sub-city place names from both on
* the way through.
* - `fullsize`: formats a browser cannot show (HEIC/HEIF, RAW/DNG, TIFF,
* JPEG XL, …). Immich's full-size rendition is a JPEG it renders from the
* original, at the original's resolution — but only when "full-size
* preview" is enabled in Immich's image settings and the asset has been
* processed since. Whether it exists is not in the album response, so the
* zoom route asks Immich when the visitor zooms and answers 404 when there
* is none; the lightbox then says so and stays on the preview.
* - `edited`: photos edited in Immich's editor (crop, rotate), whatever
* their format (#831). Their original is the unedited file — whatever a
* crop removed is still in it — so it is never used. Measured against
* Immich 3.2, `thumbnail?size=fullsize&edited=true` is the edited photo at
* full resolution, a JPEG without EXIF, and it exists whether or not
* full-size previews are switched on. The preview Folio shows carries the
* same edit, so the two share their geometry.
* - `null`: not zoomable. Videos. And PNG, WebP and GIF: the browser could
* show those originals, but the scrubber does not edit their metadata
* (eXIf/iTXt chunks, RIFF EXIF/XMP chunks), so they would go out with any
* GPS they carry. Immich has no rendition to fall back on either — its
* full-size endpoint redirects web-compatible formats to the original.
*
* Client-safe: no `fs`, no Immich client.
*/
export type ZoomSource = 'original' | 'fullsize' | 'edited';
/** Originals served as they are (after scrubbing). */
const ORIGINAL_MIME = new Set(['image/jpeg', 'image/jpg', 'image/pjpeg', 'image/avif']);
const ORIGINAL_EXT = /\.(jpe?g|jpe|jfif|avif)$/i;
/** Browser-displayable, but their metadata is not scrubbed: never zoomed. */
const UNSCRUBBED_MIME = new Set([
'image/png',
'image/webp',
'image/gif',
'image/svg+xml',
'image/apng',
]);
const UNSCRUBBED_EXT = /\.(png|apng|webp|gif|svg)$/i;
/** What the zoom route may send back: the formats the two sources produce. */
export const ZOOM_CONTENT_TYPES: ReadonlySet<string> = new Set(['image/jpeg', 'image/avif']);
export function zoomSourceFor(asset: {
type?: string;
originalMimeType?: string | null;
originalFileName?: string | null;
isEdited?: boolean | null;
}): ZoomSource | null {
if (asset.type !== 'IMAGE') return null;
if (asset.isEdited === true) return 'edited';
const mime = asset.originalMimeType?.toLowerCase().split(';')[0].trim();
if (mime) {
if (ORIGINAL_MIME.has(mime)) return 'original';
if (UNSCRUBBED_MIME.has(mime)) return null;
return mime.startsWith('image/') ? 'fullsize' : null;
}
const name = asset.originalFileName ?? '';
if (ORIGINAL_EXT.test(name)) return 'original';
if (UNSCRUBBED_EXT.test(name)) return null;
// No type and no recognisable extension: not enough to go on.
return /\.[a-z0-9]+$/i.test(name) ? 'fullsize' : null;
}
const ROTATED_ORIENTATIONS = new Set([5, 6, 7, 8]);
/**
* The zoom image's pixel size as the browser will report it — upright, after
* the EXIF orientation is applied. The lightbox needs it before the file has
* loaded, so the first gesture can go straight to 1:1.
*
* Immich's top-level `width`/`height` are already upright, and for a photo
* edited in Immich they are the edited size — the size of the `edited`
* source; EXIF dimensions are as stored and are turned here. Undefined when neither is known — the asset
* is then not offered for zoom.
*/
export function zoomDimensions(asset: {
isEdited?: boolean | null;
width?: number | null;
height?: number | null;
exifInfo?: {
exifImageWidth?: number | null;
exifImageHeight?: number | null;
orientation?: string | null;
} | null;
}): { width: number; height: number } | undefined {
if (asset.width && asset.height && asset.width > 0 && asset.height > 0) {
return { width: asset.width, height: asset.height };
}
// An edited photo (#831) has no fallback: EXIF describes the unedited file,
// and a wrong size would put the zoom image off the preview's box. No size,
// no zoom.
if (asset.isEdited === true) return undefined;
const w = asset.exifInfo?.exifImageWidth;
const h = asset.exifInfo?.exifImageHeight;
if (!w || !h || w <= 0 || h <= 0) return undefined;
return ROTATED_ORIENTATIONS.has(Number(asset.exifInfo?.orientation))
? { width: h, height: w }
: { width: w, height: h };
}