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();
});
});