release: v0.13.0 (#535)

Twenty-nine commits since v0.12.0. See CHANGELOG.md for the full list
and the upgrade notes.

Ten additions — the SEO package (site URL, robots.txt, sitemap.xml,
JSON-LD), slideshow mode, opt-in original downloads, copy-link,
previous/next album navigation, alt text from the Immich description,
per-album location precision, the update notice, a Help tab, and a
subpage cover grid separate from its photo grid. Two fixes: CJK album
slugs, and the lightbox control bar.

Photo of the Day (#476) was built on `dev` and reverted as not planned;
it never appears in a release.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01DmsHVRL9yQvckUB1ZpVWre

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Ralksta
2026-08-25 10:08:12 +02:00
committed by GitHub
parent 10ec68f493
commit a813433891
73 changed files with 4697 additions and 453 deletions

View File

@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import { albumNeighbours } from '@/lib/albumNav';
const album = (id: string, slug = id, albumName = id.toUpperCase()) => ({ id, slug, albumName });
const three = [album('a'), album('b'), album('c')];
describe('albumNeighbours', () => {
it('offers both neighbours in the middle of a list', () => {
expect(albumNeighbours(three, 'b')).toEqual({
prev: { href: '/a', name: 'A' },
next: { href: '/c', name: 'C' },
});
});
it('omits the previous link on the first album', () => {
expect(albumNeighbours(three, 'a')).toEqual({ next: { href: '/b', name: 'B' } });
});
it('omits the next link on the last album — no wrap-around', () => {
expect(albumNeighbours(three, 'c')).toEqual({ prev: { href: '/b', name: 'B' } });
});
it('prefixes hrefs with the subpage path when there is one', () => {
expect(albumNeighbours(three, 'b', '/travel')).toEqual({
prev: { href: '/travel/a', name: 'A' },
next: { href: '/travel/c', name: 'C' },
});
});
it('returns nothing for a single-album list', () => {
expect(albumNeighbours([album('a')], 'a')).toEqual({});
});
it('returns nothing for an empty list', () => {
expect(albumNeighbours([], 'a')).toEqual({});
});
it('returns nothing when the album is not in the list', () => {
expect(albumNeighbours(three, 'zzz')).toEqual({});
});
it('follows list order, not slug or name order', () => {
const shuffled = [album('c'), album('a'), album('b')];
expect(albumNeighbours(shuffled, 'a')).toEqual({
prev: { href: '/c', name: 'C' },
next: { href: '/b', name: 'B' },
});
});
it('uses the slug for the href and the display name for the label', () => {
const list = [album('id-1', 'harbour-at-dawn', 'Harbour at Dawn'), album('id-2')];
expect(albumNeighbours(list, 'id-2').prev).toEqual({
href: '/harbour-at-dawn',
name: 'Harbour at Dawn',
});
});
});

View File

@@ -0,0 +1,89 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { LocationPrecision } from '@/lib/mapPrecision';
/**
* The per-album `location:` setting governed the map and nothing else, so an
* album asking to be absent from it still had its city named in the lightbox
* info panel. These pin the join: an asset inherits the setting of the albums
* that carry it.
*/
const albumLocationPrecision: Record<string, LocationPrecision> = {};
vi.mock('@/lib/config', async () => {
const actual = await vi.importActual<typeof import('@/lib/config')>('@/lib/config');
return {
...actual,
getConfig: () => ({ albumLocationPrecision }),
};
});
const getAlbum = vi.fn();
vi.mock('@/lib/immich', () => ({
immich: { getAlbum: (id: string) => getAlbum(id) },
}));
import { assetLocationPrecision } from '@/lib/assetLocation';
const ASSET = 'asset-1';
const OTHER = 'asset-2';
/** An album whose assets are just ids. */
const album = (id: string, assetIds: string[]) => ({
id,
assets: assetIds.map((a) => ({ id: a })),
});
beforeEach(() => {
for (const key of Object.keys(albumLocationPrecision)) delete albumLocationPrecision[key];
getAlbum.mockReset();
});
describe('assetLocationPrecision', () => {
it('is exact when no album restricts anything, without asking Immich at all', async () => {
await expect(assetLocationPrecision(ASSET)).resolves.toBe('exact');
// The common case must not cost a single album fetch.
expect(getAlbum).not.toHaveBeenCalled();
});
it('takes the setting of an album that carries the asset', async () => {
albumLocationPrecision['album-a'] = 'country';
getAlbum.mockResolvedValue(album('album-a', [ASSET]));
await expect(assetLocationPrecision(ASSET)).resolves.toBe('country');
});
it('ignores an album that restricts but does not carry the asset', async () => {
albumLocationPrecision['album-a'] = 'hidden';
getAlbum.mockResolvedValue(album('album-a', [OTHER]));
await expect(assetLocationPrecision(ASSET)).resolves.toBe('exact');
});
// Same rule as a merged map marker: the cautious setting governs.
it('takes the strictest when several albums carry the asset', async () => {
albumLocationPrecision['album-a'] = 'city';
albumLocationPrecision['album-b'] = 'hidden';
getAlbum.mockImplementation(async (id: string) => album(id, [ASSET]));
await expect(assetLocationPrecision(ASSET)).resolves.toBe('hidden');
});
it('only asks about albums that actually restrict something', async () => {
albumLocationPrecision['album-a'] = 'exact';
albumLocationPrecision['album-b'] = 'city';
getAlbum.mockImplementation(async (id: string) => album(id, [ASSET]));
await assetLocationPrecision(ASSET);
expect(getAlbum).toHaveBeenCalledTimes(1);
expect(getAlbum).toHaveBeenCalledWith('album-b');
});
// Failing open would publish the position the setting exists to withhold.
it('withholds the place when an album cannot be read', async () => {
albumLocationPrecision['album-a'] = 'city';
getAlbum.mockRejectedValue(new Error('Immich is down'));
await expect(assetLocationPrecision(ASSET)).resolves.toBe('hidden');
});
it('withholds it when the album is gone from the allowlist', async () => {
albumLocationPrecision['album-a'] = 'city';
getAlbum.mockResolvedValue(null);
await expect(assetLocationPrecision(ASSET)).resolves.toBe('hidden');
});
});

View File

@@ -6,11 +6,17 @@ vi.mock('@/lib/config', () => ({
getConfig: () => ({
immich: { apiKey: 'test-api-key-for-auth-tests' },
authSecret: 'test-auth-secret-32-chars-long-min',
standaloneAlbums: ['00000000-0000-0000-0000-000000000004'],
albumPasswords: { '00000000-0000-0000-0000-000000000005': 'albumpass' },
subpages: [
{
name: 'Private',
slug: 'private',
albumIds: ['00000000-0000-0000-0000-000000000001'],
albumIds: [
'00000000-0000-0000-0000-000000000001',
'00000000-0000-0000-0000-000000000004',
'00000000-0000-0000-0000-000000000005',
],
password: 'secret123',
},
{
@@ -32,7 +38,13 @@ vi.mock('@/lib/config', () => ({
}),
}));
import { isProtected, authenticate, isAuthenticated, findSubpageBySlug } from '@/lib/auth';
import {
isProtected,
authenticate,
isAuthenticated,
isAlbumReachable,
findSubpageBySlug,
} from '@/lib/auth';
describe('findSubpageBySlug', () => {
it('returns the subpage config for a known slug', () => {
@@ -197,3 +209,52 @@ describe('token expiry', () => {
expect(Math.abs(exp - (Date.now() + maxAge * 1000))).toBeLessThan(2000);
});
});
/**
* A route handler is reached without ever passing through the page that would
* have asked for the subpage password, so it has to ask the same question
* itself (#475).
*/
describe('isAlbumReachable', () => {
const IN_PRIVATE = '00000000-0000-0000-0000-000000000001';
const IN_PUBLIC = '00000000-0000-0000-0000-000000000002';
const ALSO_STANDALONE = '00000000-0000-0000-0000-000000000004';
const OWN_PASSWORD = '00000000-0000-0000-0000-000000000005';
const none = () => undefined;
async function cookieFor(key: string, password: string, type: 'subpage' | 'album' = 'subpage') {
const header = (await authenticate(key, password, type))!;
const token = header.split('=')[1].split(';')[0];
const name = header.split('=')[0];
return (asked: string) => (asked === name ? token : undefined);
}
it('allows an album in a subpage that asks for nothing', () => {
expect(isAlbumReachable(IN_PUBLIC, none)).toBe(true);
});
it('refuses an album whose only route is a protected subpage', () => {
expect(isAlbumReachable(IN_PRIVATE, none)).toBe(false);
});
it('allows it once that subpage has been unlocked', async () => {
const getCookie = await cookieFor('private', 'secret123');
expect(isAlbumReachable(IN_PRIVATE, getCookie)).toBe(true);
});
// Listing an album on the home page publishes it, whatever else it is in.
it('allows a standalone album even when a protected subpage also lists it', () => {
expect(isAlbumReachable(ALSO_STANDALONE, none)).toBe(true);
});
it('still refuses an album that carries its own password', async () => {
const subpageOnly = await cookieFor('private', 'secret123');
expect(isAlbumReachable(OWN_PASSWORD, subpageOnly)).toBe(false);
});
it('allows that one once its own password is given too', async () => {
const subpage = await cookieFor('private', 'secret123');
const album = await cookieFor(OWN_PASSWORD, 'albumpass', 'album');
expect(isAlbumReachable(OWN_PASSWORD, (n) => subpage(n) ?? album(n))).toBe(true);
});
});

View File

@@ -12,7 +12,13 @@ vi.mock('@/lib/env', () => ({
},
}));
import { slugify, buildSubpageGrid, sanitizeNavLinks } from '@/lib/config';
import {
slugify,
albumSlug,
normalizeSlug,
buildSubpageGrid,
sanitizeNavLinks,
} from '@/lib/config';
// EXPERIMENTAL: external navigation links — rendered as <a href> in the
// header, so only http(s) URLs may survive sanitisation.
@@ -81,6 +87,64 @@ describe('slugify', () => {
it('preserves numbers', () => {
expect(slugify('Album 2024')).toBe('album-2024');
});
// #522: stripping every non-ASCII character left CJK names with an empty
// slug, so their links pointed at "/" and clicking an album did nothing.
it('preserves CJK characters', () => {
expect(slugify('家族相册')).toBe('家族相册');
expect(slugify('写真')).toBe('写真');
expect(slugify('旅行 2024')).toBe('旅行-2024');
});
it('preserves Hangul in composed (NFC) form', () => {
const slug = slugify('가족 앨범');
expect(slug).toBe('가족-앨범');
expect(slug).toBe(slug.normalize('NFC'));
});
it('preserves other non-Latin scripts', () => {
expect(slugify('Москва 2024')).toBe('москва-2024');
});
it('still returns empty when nothing letter-like remains', () => {
expect(slugify('🎉🎉')).toBe('');
});
});
describe('albumSlug', () => {
it('uses the slugified name when it yields something', () => {
expect(albumSlug('Kloster Chorin', 'abc-123')).toBe('kloster-chorin');
expect(albumSlug('家族相册', 'abc-123')).toBe('家族相册');
});
// Without a fallback two emoji-only albums would both slugify to '' and
// collide on the same (unreachable) URL.
it('falls back to the album id when the name slugifies to nothing', () => {
expect(albumSlug('🎉', 'abc-123')).toBe('abc-123');
expect(albumSlug('', 'abc-123')).toBe('abc-123');
});
});
describe('normalizeSlug', () => {
// Next hands catch-all route segments over percent-encoded, so this is the
// half of #522 that fixing slugify() alone did not cure.
it('percent-decodes an encoded slug', () => {
expect(normalizeSlug('%E5%AE%B6%E6%97%8F%E7%9B%B8%E5%86%8C')).toBe('家族相册');
});
it('leaves an already decoded slug alone', () => {
expect(normalizeSlug('家族相册')).toBe('家族相册');
expect(normalizeSlug('kloster-chorin')).toBe('kloster-chorin');
});
it('composes decomposed (NFD) input', () => {
expect(normalizeSlug('가족-앨범'.normalize('NFD'))).toBe('가족-앨범');
});
it('survives a malformed percent sequence instead of throwing', () => {
expect(() => normalizeSlug('%E5%AE')).not.toThrow();
expect(normalizeSlug('100%-real')).toBe('100%-real');
});
});
describe('buildSubpageGrid', () => {

View File

@@ -0,0 +1,79 @@
import { describe, it, expect, vi } from 'vitest';
vi.mock('@/lib/env', () => ({
env: {
IMMICH_API_URL: 'http://localhost:2283',
IMMICH_API_KEY: 'test-key',
SITE_TITLE: 'Test Gallery',
SITE_SUBTITLE: '',
CACHE_TTL: 300,
IMMICH_TIMEOUT_MS: 15000,
RATE_LIMIT_RPM: 120,
TRUSTED_PROXY_HOPS: 0,
},
}));
vi.mock('@/lib/config/parser', () => ({
loadYaml: vi.fn(),
clearYamlCache: vi.fn(),
validateUuid: (id: string) => id,
}));
import { deriveGallery } from '@/lib/config';
import type { GalleryYaml } from '@/lib/config/schema';
const A = '11111111-1111-1111-1111-111111111111';
const B = '22222222-2222-2222-2222-222222222222';
describe('deriveGallery — location precision (#469)', () => {
it('leaves an album without a setting absent, meaning exact', () => {
const gallery: GalleryYaml = { albums: [A] };
expect(deriveGallery(gallery).albumLocationPrecision[A]).toBeUndefined();
});
it('reads a per-album setting', () => {
const gallery: GalleryYaml = { albums: [{ [A]: { location: 'city' } }] };
expect(deriveGallery(gallery).albumLocationPrecision[A]).toBe('city');
});
it('applies a subpage setting to every album on it', () => {
const gallery: GalleryYaml = {
subpages: [{ name: 'Gardens', location: 'country', albums: [A, B] }],
};
const derived = deriveGallery(gallery);
expect(derived.albumLocationPrecision[A]).toBe('country');
expect(derived.albumLocationPrecision[B]).toBe('country');
});
it("lets an album state its own, overriding the subpage's", () => {
const gallery: GalleryYaml = {
subpages: [
{ name: 'Gardens', location: 'city', albums: [A, { [B]: { location: 'hidden' } }] },
],
};
const derived = deriveGallery(gallery);
expect(derived.albumLocationPrecision[A]).toBe('city');
expect(derived.albumLocationPrecision[B]).toBe('hidden');
});
/**
* A typo in a privacy setting must not fall back to `exact` — that would
* publish the position it was meant to withhold.
*/
it('throws on an unknown album value rather than defaulting', () => {
const gallery: GalleryYaml = { albums: [{ [A]: { location: 'приблизительно' } }] };
expect(() => deriveGallery(gallery)).toThrow(/unknown location/i);
});
it('throws on an unknown subpage value', () => {
const gallery: GalleryYaml = {
subpages: [{ name: 'Gardens', location: 'vague', albums: [A] }],
};
expect(() => deriveGallery(gallery)).toThrow(/unknown location/i);
});
it('names the valid values in the error, so the log line is enough to fix it', () => {
const gallery: GalleryYaml = { albums: [{ [A]: { location: 'nope' } }] };
expect(() => deriveGallery(gallery)).toThrow(/exact.*city.*country.*hidden/);
});
});

View File

@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest';
import { safeDownloadName, contentDisposition } from '@/lib/downloadName';
const LF = String.fromCharCode(10);
const CRLF = String.fromCharCode(13, 10);
const NUL = String.fromCharCode(0);
const QUOTE = String.fromCharCode(34);
/** True if the string holds any control character. */
const hasControlChar = (value: string) =>
Array.from(value).some((character) => {
const code = character.charCodeAt(0);
return code < 32 || code === 127;
});
describe('safeDownloadName', () => {
it('passes an ordinary camera filename through', () => {
expect(safeDownloadName('DSCF1234.JPG')).toBe('DSCF1234.JPG');
});
it('keeps non-ASCII letters, which are a legitimate part of a name', () => {
expect(safeDownloadName('Fr\u00fchling am Meer.jpg')).toBe('Fr\u00fchling am Meer.jpg');
});
/** The header injection this exists to prevent. */
it.each([
['a newline', `a${LF}X-Evil: 1.jpg`],
['a CRLF', `a${CRLF}X-Evil: 1.jpg`],
['a null byte', `a${NUL}b.jpg`],
])('strips %s', (_label, raw) => {
expect(hasControlChar(safeDownloadName(raw))).toBe(false);
});
it('neutralises path separators and traversal', () => {
expect(safeDownloadName('../../etc/passwd')).not.toContain('/');
expect(safeDownloadName('..\\..\\secret.jpg')).not.toContain('\\');
expect(safeDownloadName('../evil.jpg').startsWith('.')).toBe(false);
});
it('removes the quotes and semicolons that delimit header parameters', () => {
expect(safeDownloadName(`a${QUOTE};b.jpg`)).toBe('ab.jpg');
});
it.each([
['undefined', undefined],
['empty', ''],
['whitespace', ' '],
['only dots', '...'],
])('falls back to a generic name for %s', (_label, raw) => {
expect(safeDownloadName(raw)).toBe('photo');
});
it('never returns an empty name', () => {
for (const raw of ['///', QUOTE.repeat(3), '..', ' . ', NUL]) {
expect(safeDownloadName(raw).length).toBeGreaterThan(0);
}
});
it('bounds the length', () => {
expect(safeDownloadName('x'.repeat(5000)).length).toBeLessThanOrEqual(200);
});
});
describe('contentDisposition', () => {
it('marks the response as an attachment', () => {
expect(contentDisposition('DSCF1234.JPG')).toMatch(/^attachment; /);
});
it('carries both a plain and a percent-encoded filename', () => {
const header = contentDisposition('Fr\u00fchling.jpg');
expect(header).toContain('filename="Fr_hling.jpg"');
expect(header).toContain("filename*=UTF-8''Fr%C3%BChling.jpg");
});
it('never emits a control character, whatever it is given', () => {
expect(hasControlChar(contentDisposition(`a${CRLF}X-Evil: 1.jpg`))).toBe(false);
});
it('never emits an unbalanced quote', () => {
const header = contentDisposition(`a${QUOTE}b.jpg`);
expect((header.match(/"/g) ?? []).length % 2).toBe(0);
});
it('survives a missing name', () => {
expect(contentDisposition(undefined)).toContain('filename="photo"');
});
});

View File

@@ -82,9 +82,16 @@ describe('dictionary parity', () => {
});
it('leaves no English string untranslated', () => {
// Proper nouns and units read the same in both languages; `coverAria` is
// pure interpolation with no words of its own.
const shared = new Set(['nav.journal', 'journal.title', 'lightbox.iso', 'subpage.coverAria']);
// Proper nouns, loanwords and units read the same in both languages;
// `coverAria` is pure interpolation with no words of its own.
const shared = new Set([
'nav.journal',
'journal.title',
'lightbox.iso',
'lightbox.copyLinkShort',
'lightbox.downloadShort',
'subpage.coverAria',
]);
const identical = enLeaves
.filter(([path]) => !shared.has(path))

View File

@@ -381,6 +381,63 @@ describe('ImmichClient', () => {
});
});
// #522: a CJK album name slugified to '', so its link was "/" and the album
// was unreachable — no URL segment can ever be the empty string.
describe('getAlbumBySlug() with non-Latin names', () => {
function mockList(albumName: string) {
const json = (body: unknown) => ({
ok: true,
headers: { get: () => 'application/json' },
json: async () => body,
});
mockFetch.mockImplementation(async (url: string) => {
if (url.includes('/search/metadata')) {
return json({ assets: { items: [], nextPage: null, total: 0 } });
}
if (/\/albums\/album-2/.test(url)) {
return json({ id: 'album-2', albumName, assetCount: 0, assets: [], order: 'desc' });
}
return json([{ id: 'album-2', albumName, assetCount: 0 }]);
});
}
it('gives a CJK album a non-empty slug and resolves it', async () => {
mockList('家族相册');
const albums = await immich.getAlbums();
expect(albums[0].slug).toBe('家族相册');
const album = await immich.getAlbumBySlug('家族相册');
expect(album?.id).toBe('album-2');
});
it('resolves a Hangul slug that arrives decomposed (NFD) from the URL', async () => {
mockList('가족 앨범');
await immich.getAlbums();
const album = await immich.getAlbumBySlug('가족-앨범'.normalize('NFD'));
expect(album?.id).toBe('album-2');
});
it('resolves a slug that arrives percent-encoded from the route', async () => {
mockList('家族相册');
await immich.getAlbums();
const encoded = encodeURIComponent('家族相册');
expect(encoded).not.toBe('家族相册');
const album = await immich.getAlbumBySlug(encoded);
expect(album?.id).toBe('album-2');
});
it('falls back to the album id when the name has no letters at all', async () => {
mockList('🎉');
const albums = await immich.getAlbums();
expect(albums[0].slug).toBe('album-2');
const album = await immich.getAlbumBySlug('album-2');
expect(album?.id).toBe('album-2');
});
});
describe('getStandaloneAlbums()', () => {
it('orders albums by gallery.yaml order, not Immich API order', async () => {
// The Immich API returns albums in its own (creation/update) order;

View File

@@ -0,0 +1,135 @@
import { describe, it, expect } from 'vitest';
import {
LIGHTBOX_SHORTCUTS,
lightboxActionFor,
shortcutDisplayKeys,
shortcutKeyLabel,
} from '@/lib/lightboxShortcuts';
import { en } from '@/lib/i18n/locales/en';
import { de } from '@/lib/i18n/locales/de';
describe('LIGHTBOX_SHORTCUTS', () => {
it('is not empty', () => {
expect(LIGHTBOX_SHORTCUTS.length).toBeGreaterThan(0);
});
/**
* The whole point of the shared catalogue: the `?` overlay and the admin
* help render this list, so a label that does not resolve would leave one of
* them blank.
*/
it.each(['en', 'de'])('every label resolves in %s', (locale) => {
const dictionary = locale === 'en' ? en.lightbox : de.lightbox;
for (const shortcut of LIGHTBOX_SHORTCUTS) {
const label = dictionary[shortcut.labelKey];
expect(typeof label, shortcut.labelKey).toBe('string');
expect((label as string).length).toBeGreaterThan(0);
}
});
it('gives every shortcut at least one key', () => {
for (const shortcut of LIGHTBOX_SHORTCUTS) {
expect(shortcut.bindings.length, shortcut.labelKey).toBeGreaterThan(0);
for (const binding of shortcut.bindings) {
expect(binding.eventKeys.length, binding.display).toBeGreaterThan(0);
}
}
});
it('binds no key twice, which would make the list contradict itself', () => {
const keys = LIGHTBOX_SHORTCUTS.flatMap((shortcut) =>
shortcut.bindings.map((binding) => binding.display),
);
expect(new Set(keys).size).toBe(keys.length);
});
/**
* Two rows claiming the same physical key would make the viewer's behaviour
* depend on the order of this list, and the help would describe one of them
* wrongly.
*/
it('binds no KeyboardEvent.key to two actions', () => {
const eventKeys = LIGHTBOX_SHORTCUTS.flatMap((shortcut) =>
shortcut.bindings.flatMap((binding) => [...binding.eventKeys]),
);
expect(new Set(eventKeys).size).toBe(eventKeys.length);
});
it('lists each label once', () => {
const labels = LIGHTBOX_SHORTCUTS.map((shortcut) => shortcut.labelKey);
expect(new Set(labels).size).toBe(labels.length);
});
it('documents the conditional shortcuts, since the reader cannot see them fail', () => {
for (const shortcut of LIGHTBOX_SHORTCUTS) {
if (shortcut.availability !== 'always') {
expect(shortcut.note, shortcut.labelKey).toBeTruthy();
}
}
});
});
/**
* The catalogue is the only route from a keypress to an action (#473), so
* these cover the join the viewer relies on.
*/
describe('lightboxActionFor', () => {
it('resolves every declared key to its action', () => {
for (const shortcut of LIGHTBOX_SHORTCUTS) {
for (const binding of shortcut.bindings) {
for (const key of binding.eventKeys) {
expect(lightboxActionFor(key), key).toBe(binding.action);
}
}
}
});
it('resolves the keys a visitor actually presses', () => {
expect(lightboxActionFor('ArrowLeft')).toBe('prev');
expect(lightboxActionFor('ArrowRight')).toBe('next');
expect(lightboxActionFor('Escape')).toBe('close');
expect(lightboxActionFor('?')).toBe('shortcutList');
expect(lightboxActionFor('h')).toBe('shortcutList');
expect(lightboxActionFor('H')).toBe('shortcutList');
});
it('takes both cases of every letter key, since Shift must not matter', () => {
for (const [lower, upper] of [
['i', 'I'],
['f', 'F'],
['s', 'S'],
['c', 'C'],
['d', 'D'],
]) {
expect(lightboxActionFor(lower), lower).not.toBeNull();
expect(lightboxActionFor(upper), upper).toBe(lightboxActionFor(lower));
}
});
it('returns null for a key the viewer does not bind', () => {
for (const key of ['a', 'Enter', 'Tab', ' ', 'ArrowUp', 'F5']) {
expect(lightboxActionFor(key), key).toBeNull();
}
});
});
describe('shortcutKeyLabel', () => {
it('renders the arrow names as arrows', () => {
expect(shortcutKeyLabel('ARROW_LEFT')).toBe(String.fromCharCode(0x2190));
expect(shortcutKeyLabel('ARROW_RIGHT')).toBe(String.fromCharCode(0x2192));
});
it('passes ordinary keys through', () => {
expect(shortcutKeyLabel('S')).toBe('S');
expect(shortcutKeyLabel('Esc')).toBe('Esc');
expect(shortcutKeyLabel('?')).toBe('?');
});
it('leaves no placeholder name unrendered', () => {
for (const shortcut of LIGHTBOX_SHORTCUTS) {
for (const key of shortcutDisplayKeys(shortcut)) {
expect(key).not.toMatch(/^ARROW_/);
}
}
});
});

View File

@@ -0,0 +1,132 @@
import { describe, it, expect } from 'vitest';
import {
placeLabel,
isLocationPrecision,
strictestPrecision,
quantiseCoordinate,
applyPrecision,
LOCATION_PRECISIONS,
type LocationPrecision,
} from '@/lib/mapPrecision';
describe('isLocationPrecision', () => {
it.each(LOCATION_PRECISIONS)('accepts %s', (level) => {
expect(isLocationPrecision(level)).toBe(true);
});
it.each(['', 'EXACT', 'town', 'precise', 'none'])('rejects %s', (value) => {
expect(isLocationPrecision(value)).toBe(false);
});
});
describe('strictestPrecision', () => {
it.each([
[['exact', 'city'], 'city'],
[['city', 'country'], 'country'],
[['exact', 'hidden'], 'hidden'],
[['country', 'city', 'exact'], 'country'],
[['exact'], 'exact'],
[['exact', 'exact'], 'exact'],
] as [LocationPrecision[], LocationPrecision][])('%s → %s', (levels, expected) => {
expect(strictestPrecision(levels)).toBe(expected);
});
it('is order-independent', () => {
expect(strictestPrecision(['country', 'exact'])).toBe(strictestPrecision(['exact', 'country']));
});
/** Nothing contributed, so there is nothing to place. */
it('treats an empty list as hidden', () => {
expect(strictestPrecision([])).toBe('hidden');
});
});
describe('quantiseCoordinate', () => {
it('leaves an exact coordinate untouched', () => {
expect(quantiseCoordinate(52.516272, 'exact')).toBe(52.516272);
});
it('snaps to 0.05° for city', () => {
expect(quantiseCoordinate(52.516272, 'city')).toBe(52.5);
expect(quantiseCoordinate(13.377704, 'city')).toBe(13.4);
});
it('snaps to 1° for country', () => {
expect(quantiseCoordinate(52.516272, 'country')).toBe(53);
expect(quantiseCoordinate(13.377704, 'country')).toBe(13);
});
it('leaves no floating-point tail', () => {
for (const value of [52.9123, 13.0001, -3.3333, 0.049]) {
const quantised = quantiseCoordinate(value, 'city');
expect(String(quantised)).toMatch(/^-?\d+(\.\d{1,2})?$/);
}
});
it('works south and west of zero', () => {
expect(quantiseCoordinate(-33.918861, 'city')).toBe(-33.9);
expect(quantiseCoordinate(-118.24368, 'country')).toBe(-118);
});
/**
* The property that matters: the output is a fixed grid, so asking twice
* cannot average back to the true position.
*/
it('is stable across repeated calls', () => {
const value = 47.376887;
const answers = new Set(Array.from({ length: 50 }, () => quantiseCoordinate(value, 'city')));
expect(answers.size).toBe(1);
});
it('places nearby points on the same grid node, so one album does not stand out', () => {
const garden = [52.5161, 52.5169, 52.5152].map((v) => quantiseCoordinate(v, 'city'));
expect(new Set(garden).size).toBe(1);
});
it('moves a coordinate far enough to stop naming a property', () => {
const exact = 52.516272;
expect(Math.abs(quantiseCoordinate(exact, 'city') - exact)).toBeGreaterThan(0.01);
});
});
describe('applyPrecision', () => {
const berlin = { lat: 52.516272, lng: 13.377704 };
it('quantises both axes', () => {
expect(applyPrecision(berlin, 'city')).toEqual({ lat: 52.5, lng: 13.4 });
});
it('passes an exact position through', () => {
expect(applyPrecision(berlin, 'exact')).toEqual(berlin);
});
/** Not {0,0} — the caller must drop the marker, not place it in the Atlantic. */
it('returns null for hidden', () => {
expect(applyPrecision(berlin, 'hidden')).toBeNull();
});
});
/**
* Quantising the coordinates while still naming the city left the label more
* precise than the position it was meant to blur: a 1° grid cell is ~100 km,
* and "Kloster Chorin" is a village.
*/
describe('placeLabel', () => {
const place = { city: 'Chorin', country: 'Germany' };
it('keeps the city at exact', () => {
expect(placeLabel('exact', place)).toEqual({ city: 'Chorin', country: 'Germany' });
});
it('keeps the city at city precision, which is roughly what a city name says', () => {
expect(placeLabel('city', place)).toEqual({ city: 'Chorin', country: 'Germany' });
});
it('drops the city at country precision', () => {
expect(placeLabel('country', place)).toEqual({ city: '', country: 'Germany' });
});
it('drops both when the marker is hidden, so a caller cannot leak one by accident', () => {
expect(placeLabel('hidden', place)).toEqual({ city: '', country: '' });
});
});

View File

@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import { parsePhotoHash, buildPhotoHash, buildPhotoPermalink } from '@/lib/photoHash';
describe('buildPhotoHash', () => {
it('presents the index 1-indexed', () => {
expect(buildPhotoHash(0)).toBe('#photo-1');
expect(buildPhotoHash(41)).toBe('#photo-42');
});
});
describe('parsePhotoHash', () => {
it('round-trips with buildPhotoHash', () => {
for (const index of [0, 1, 7, 250]) {
expect(parsePhotoHash(buildPhotoHash(index))).toBe(index);
}
});
it.each([
['no hash', ''],
['bare hash', '#'],
['another anchor', '#gallery'],
['missing number', '#photo-'],
['not a number', '#photo-abc'],
['negative', '#photo--3'],
['trailing junk', '#photo-3x'],
['leading junk', '#x-photo-3'],
])('rejects %s', (_label, hash) => {
expect(parsePhotoHash(hash)).toBeNull();
});
it('rejects #photo-0, which has no 0-based index', () => {
expect(parsePhotoHash('#photo-0')).toBeNull();
});
});
describe('buildPhotoPermalink', () => {
const location = {
origin: 'https://folio.example',
pathname: '/travel/iceland',
search: '',
};
it('builds an absolute link to the photo', () => {
expect(buildPhotoPermalink(location, 2)).toBe('https://folio.example/travel/iceland#photo-3');
});
it('preserves an existing query string', () => {
expect(buildPhotoPermalink({ ...location, search: '?preview=true' }, 0)).toBe(
'https://folio.example/travel/iceland?preview=true#photo-1',
);
});
it('works at the site root', () => {
expect(buildPhotoPermalink({ ...location, pathname: '/' }, 0)).toBe(
'https://folio.example/#photo-1',
);
});
});

View File

@@ -0,0 +1,168 @@
import { describe, it, expect } from 'vitest';
import { publicPaths, type SiteShape } from '@/lib/publicPages';
const site = (over: Partial<SiteShape> = {}): SiteShape => ({
siteLocked: false,
subpages: [],
standaloneAlbums: [],
journal: [],
isAlbumProtected: () => false,
aboutEnabled: false,
mapEnabled: false,
journalEnabled: false,
...over,
});
const openSubpage = (slug: string, albums: { id: string; slug: string }[] = []) => ({
slug,
enabled: true,
hidden: false,
isProtected: false,
albums,
});
describe('publicPaths', () => {
it('always lists the home page', () => {
expect(publicPaths(site())).toEqual(['/']);
});
it('lists the static pages that are switched on', () => {
const paths = publicPaths(site({ aboutEnabled: true, mapEnabled: true, journalEnabled: true }));
expect(paths).toEqual(['/', '/about', '/map', '/journal']);
});
it('lists a subpage and its albums', () => {
const paths = publicPaths(
site({ subpages: [openSubpage('travel', [{ id: 'a', slug: 'iceland' }])] }),
);
expect(paths).toEqual(['/', '/travel', '/travel/iceland']);
});
it('lists standalone albums at the root', () => {
expect(publicPaths(site({ standaloneAlbums: [{ id: 'a', slug: 'portraits' }] }))).toEqual([
'/',
'/portraits',
]);
});
// ── Exclusions ────────────────────────────────────────────────
it('yields nothing at all for a locked site', () => {
const paths = publicPaths(
site({
siteLocked: true,
aboutEnabled: true,
subpages: [openSubpage('travel', [{ id: 'a', slug: 'iceland' }])],
standaloneAlbums: [{ id: 'b', slug: 'portraits' }],
}),
);
expect(paths).toEqual([]);
});
it('omits a disabled subpage', () => {
const sp = { ...openSubpage('travel', [{ id: 'a', slug: 'iceland' }]), enabled: false };
expect(publicPaths(site({ subpages: [sp] }))).toEqual(['/']);
});
it('omits a hidden subpage — a listing is the opposite of a direct link', () => {
const sp = { ...openSubpage('travel', [{ id: 'a', slug: 'iceland' }]), hidden: true };
expect(publicPaths(site({ subpages: [sp] }))).toEqual(['/']);
});
it('omits a password-protected subpage', () => {
const sp = { ...openSubpage('clients', [{ id: 'a', slug: 'wedding' }]), isProtected: true };
expect(publicPaths(site({ subpages: [sp] }))).toEqual(['/']);
});
it('omits a protected album inside an open subpage', () => {
const paths = publicPaths(
site({
subpages: [
openSubpage('travel', [
{ id: 'open', slug: 'iceland' },
{ id: 'locked', slug: 'private' },
]),
],
isAlbumProtected: (id) => id === 'locked',
}),
);
expect(paths).toEqual(['/', '/travel', '/travel/iceland']);
});
it('omits a protected standalone album', () => {
const paths = publicPaths(
site({
standaloneAlbums: [
{ id: 'open', slug: 'portraits' },
{ id: 'locked', slug: 'clients' },
],
isAlbumProtected: (id) => id === 'locked',
}),
);
expect(paths).toEqual(['/', '/portraits']);
});
/**
* Inheritance — the case the issue calls out. The album itself has no
* password, but it can only be reached through a subpage that does.
*/
it('omits a public album that sits under a protected subpage', () => {
const sp = { ...openSubpage('clients', [{ id: 'open', slug: 'wedding' }]), isProtected: true };
const paths = publicPaths(site({ subpages: [sp] }));
expect(paths).not.toContain('/clients/wedding');
expect(paths).toEqual(['/']);
});
it('omits a public album under a hidden subpage for the same reason', () => {
const sp = { ...openSubpage('secret', [{ id: 'open', slug: 'wedding' }]), hidden: true };
expect(publicPaths(site({ subpages: [sp] }))).not.toContain('/secret/wedding');
});
it('omits journal drafts', () => {
const paths = publicPaths(
site({
journalEnabled: true,
journal: [
{ slug: 'published', draft: false },
{ slug: 'wip', draft: true },
],
}),
);
expect(paths).toEqual(['/', '/journal', '/journal/published']);
});
it('omits journal entries entirely when the journal is off', () => {
const paths = publicPaths(
site({ journalEnabled: false, journal: [{ slug: 'published', draft: false }] }),
);
expect(paths).toEqual(['/']);
});
// ── Shape ─────────────────────────────────────────────────────
it('emits no duplicates when a subpage and an album share a slug', () => {
const paths = publicPaths(
site({
subpages: [openSubpage('travel')],
standaloneAlbums: [{ id: 'a', slug: 'travel' }],
}),
);
expect(paths).toEqual([...new Set(paths)]);
});
it('emits only absolute-looking, single-slash paths', () => {
const paths = publicPaths(
site({
aboutEnabled: true,
journalEnabled: true,
journal: [{ slug: 'story', draft: false }],
subpages: [openSubpage('travel', [{ id: 'a', slug: 'iceland' }])],
standaloneAlbums: [{ id: 'b', slug: 'portraits' }],
}),
);
for (const p of paths) {
expect(p.startsWith('/')).toBe(true);
expect(p).not.toMatch(/\/\//);
}
});
});

View File

@@ -0,0 +1,150 @@
import { describe, it, expect } from 'vitest';
import { normaliseSiteUrl, resolveSiteUrl, absoluteUrl } from '@/lib/siteUrl';
describe('normaliseSiteUrl', () => {
it('keeps a plain https origin', () => {
expect(normaliseSiteUrl('https://folio.example')).toBe('https://folio.example');
});
it('drops the trailing slash so callers can join with /', () => {
expect(normaliseSiteUrl('https://folio.example/')).toBe('https://folio.example');
expect(normaliseSiteUrl('https://folio.example/gallery//')).toBe(
'https://folio.example/gallery',
);
});
it('keeps a sub-path, since a portfolio may live under one', () => {
expect(normaliseSiteUrl('https://example.com/folio')).toBe('https://example.com/folio');
});
it('drops query and fragment', () => {
expect(normaliseSiteUrl('https://folio.example/?utm=1#top')).toBe('https://folio.example');
});
it('trims surrounding whitespace', () => {
expect(normaliseSiteUrl(' https://folio.example ')).toBe('https://folio.example');
});
it('allows http, for a LAN instance behind no TLS', () => {
expect(normaliseSiteUrl('http://192.168.1.10:7211')).toBe('http://192.168.1.10:7211');
});
/**
* A wrong absolute URL is worse than none: it would be emitted into the
* sitemap, the feed and the JSON-LD block.
*/
it.each([
['undefined', undefined],
['null', null],
['empty', ''],
['whitespace', ' '],
['a bare host', 'folio.example'],
['a path only', '/gallery'],
['javascript:', 'javascript:alert(1)'],
['data:', 'data:text/html,x'],
['ftp', 'ftp://folio.example'],
['nonsense', 'not a url'],
])('rejects %s', (_label, raw) => {
expect(normaliseSiteUrl(raw)).toBeNull();
});
});
describe('resolveSiteUrl', () => {
/**
* settings.yaml wins, matching how `settings.title ?? env.SITE_TITLE`
* already resolves. The admin field must never save and do nothing.
*/
it('prefers settings.yaml over the environment', () => {
expect(resolveSiteUrl('https://from-yaml.example', 'https://from-env.example')).toEqual({
url: 'https://from-yaml.example',
source: 'settings',
});
});
it('falls back to the environment when the file says nothing', () => {
expect(resolveSiteUrl(undefined, 'https://from-env.example')).toEqual({
url: 'https://from-env.example',
source: 'env',
});
});
/** An unusable value in the file must not shadow a good one in the env. */
it('ignores an invalid settings value and uses the environment', () => {
expect(resolveSiteUrl('not a url', 'https://from-env.example')).toEqual({
url: 'https://from-env.example',
source: 'env',
});
});
it('reports none when neither is set', () => {
expect(resolveSiteUrl(undefined, undefined)).toEqual({ url: null, source: 'none' });
});
});
describe('absoluteUrl', () => {
it('joins a path', () => {
expect(absoluteUrl('https://folio.example', '/travel')).toBe('https://folio.example/travel');
});
it('adds the missing leading slash', () => {
expect(absoluteUrl('https://folio.example', 'travel')).toBe('https://folio.example/travel');
});
it('never emits a double slash', () => {
expect(absoluteUrl('https://folio.example', '/travel/')).toBe('https://folio.example/travel');
});
it('keeps the root as a single slash', () => {
expect(absoluteUrl('https://folio.example', '/')).toBe('https://folio.example/');
});
it('works under a sub-path', () => {
expect(absoluteUrl('https://example.com/folio', '/travel')).toBe(
'https://example.com/folio/travel',
);
});
/** A relative sitemap entry is invalid and a relative feed link points at the reader. */
it('returns null without a site URL', () => {
expect(absoluteUrl(null, '/travel')).toBeNull();
});
});
/**
* A sitemap, a canonical link and a JSON-LD block all want a URI, not an IRI.
* Non-Latin album slugs only became possible with #522, so this surfaced with
* them.
*/
describe('absoluteUrl encoding', () => {
it('percent-encodes a non-ASCII path', () => {
expect(absoluteUrl('https://example.com', '/家族相册')).toBe(
'https://example.com/%E5%AE%B6%E6%97%8F%E7%9B%B8%E5%86%8C',
);
});
it('leaves an ASCII path exactly as it was', () => {
expect(absoluteUrl('https://example.com', '/kloster-chorin')).toBe(
'https://example.com/kloster-chorin',
);
});
it('keeps a query string intact', () => {
expect(absoluteUrl('https://example.com', '/api/image/abc?size=preview')).toBe(
'https://example.com/api/image/abc?size=preview',
);
});
// A portfolio may live under a sub-path, which normaliseSiteUrl keeps.
it('preserves a sub-path in the site URL', () => {
expect(absoluteUrl('https://example.com/folio', '/家族相册')).toBe(
'https://example.com/folio/%E5%AE%B6%E6%97%8F%E7%9B%B8%E5%86%8C',
);
});
// Encoding blindly would turn a '%' into '%25' and corrupt the URL.
it('does not encode an already-encoded path a second time', () => {
expect(absoluteUrl('https://example.com', '/%E5%AE%B6%E6%97%8F%E7%9B%B8%E5%86%8C')).toBe(
'https://example.com/%E5%AE%B6%E6%97%8F%E7%9B%B8%E5%86%8C',
);
});
});

View File

@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import { nextSlideshowSpeed, SLIDESHOW_SPEEDS, type SlideshowSpeed } from '@/lib/slideshow';
describe('nextSlideshowSpeed', () => {
it('cycles off → 3s → 5s → 10s → off', () => {
expect(nextSlideshowSpeed(null)).toBe(3);
expect(nextSlideshowSpeed(3)).toBe(5);
expect(nextSlideshowSpeed(5)).toBe(10);
expect(nextSlideshowSpeed(10)).toBeNull();
});
it('returns to off after one full cycle, so the key always lets go', () => {
let speed: SlideshowSpeed = null;
for (let i = 0; i < SLIDESHOW_SPEEDS.length; i++) speed = nextSlideshowSpeed(speed);
expect(speed).toBeNull();
});
it('never yields a speed outside the advertised set', () => {
let speed: SlideshowSpeed = null;
for (let i = 0; i < 20; i++) {
speed = nextSlideshowSpeed(speed);
expect(SLIDESHOW_SPEEDS).toContain(speed);
}
});
it('re-enters the cycle from an unrecognised value rather than sticking', () => {
expect(nextSlideshowSpeed(7 as SlideshowSpeed)).toBe(3);
});
});

View File

@@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest';
import { albumStructuredData } from '@/lib/structuredData';
const base = {
siteUrl: 'https://folio.example',
pageUrl: 'https://folio.example/travel/iceland',
albumName: 'Iceland',
};
describe('albumStructuredData', () => {
it('describes the album as an ImageGallery', () => {
const data = albumStructuredData(base)!;
expect(data['@context']).toBe('https://schema.org');
expect(data['@type']).toBe('ImageGallery');
expect(data.name).toBe('Iceland');
expect(data.url).toBe('https://folio.example/travel/iceland');
});
/**
* Structured data is a set of claims about absolute URLs. Without one there
* is nothing truthful to claim, so nothing is emitted.
*/
it.each([
['no site URL', { ...base, siteUrl: null }],
['no page URL', { ...base, pageUrl: null }],
['a blank album name', { ...base, albumName: ' ' }],
])('emits nothing for %s', (_label, input) => {
expect(albumStructuredData(input)).toBeNull();
});
it('omits optional fields rather than emitting empty ones', () => {
const data = albumStructuredData(base)!;
for (const key of ['description', 'creator', 'license', 'image', 'numberOfItems']) {
expect(data).not.toHaveProperty(key);
}
});
it('carries creator and license when configured', () => {
const data = albumStructuredData({
...base,
creator: 'Jane Doe',
license: 'https://creativecommons.org/licenses/by-nc/4.0/',
})!;
expect(data.creator).toEqual({ '@type': 'Person', name: 'Jane Doe' });
expect(data.license).toBe('https://creativecommons.org/licenses/by-nc/4.0/');
});
it('repeats creator and license on the cover image', () => {
const data = albumStructuredData({
...base,
coverUrl: 'https://folio.example/api/image/tok',
creator: 'Jane Doe',
license: 'CC BY-NC 4.0',
})!;
expect(data.image).toEqual({
'@type': 'ImageObject',
contentUrl: 'https://folio.example/api/image/tok',
creator: { '@type': 'Person', name: 'Jane Doe' },
license: 'CC BY-NC 4.0',
});
});
it('treats blank creator and license as absent', () => {
const data = albumStructuredData({ ...base, creator: ' ', license: ' ' })!;
expect(data).not.toHaveProperty('creator');
expect(data).not.toHaveProperty('license');
});
it('trims the values it does emit', () => {
const data = albumStructuredData({
...base,
albumName: ' Iceland ',
description: ' Winter light. ',
creator: ' Jane Doe ',
})!;
expect(data.name).toBe('Iceland');
expect(data.description).toBe('Winter light.');
expect(data.creator).toEqual({ '@type': 'Person', name: 'Jane Doe' });
});
it('reports the photo count when given', () => {
expect(albumStructuredData({ ...base, photoCount: 42 })!.numberOfItems).toBe(42);
expect(albumStructuredData({ ...base, photoCount: 0 })!.numberOfItems).toBe(0);
});
it('survives JSON serialisation, which is how it is emitted', () => {
const data = albumStructuredData({ ...base, creator: 'Jane Doe', photoCount: 3 })!;
expect(() => JSON.parse(JSON.stringify(data))).not.toThrow();
});
});

View File

@@ -0,0 +1,73 @@
import { describe, it, expect, vi } from 'vitest';
vi.mock('@/lib/env', () => ({
env: {
IMMICH_API_URL: 'http://localhost:2283',
IMMICH_API_KEY: 'test-key',
SITE_TITLE: 'Test Gallery',
SITE_SUBTITLE: '',
CACHE_TTL: 300,
IMMICH_TIMEOUT_MS: 15000,
RATE_LIMIT_RPM: 120,
TRUSTED_PROXY_HOPS: 0,
},
}));
vi.mock('@/lib/config/parser', () => ({
loadYaml: vi.fn(),
clearYamlCache: vi.fn(),
validateUuid: (id: string) => id,
}));
import { deriveGallery } from '@/lib/config';
import type { GalleryYaml } from '@/lib/config/schema';
const A = '11111111-1111-1111-1111-111111111111';
function subpage(entry: Record<string, unknown>) {
const gallery = deriveGallery({
subpages: [{ name: 'Travel', albums: [A], ...entry }],
} as unknown as GalleryYaml);
return gallery.subpages[0];
}
/**
* The album covers and the photos inside those albums used to share a single
* `grid` key, so a gap typed into the admin's "Album Cover Grid" retuned every
* photo grid on the page (#523).
*/
describe('subpage grid vs. coverGrid', () => {
it('keeps the two independent once both are set', () => {
const sp = subpage({ grid: { columns: 4, gap: 6 }, coverGrid: { columns: 2, gap: 24 } });
expect(sp.grid).toEqual({ columns: 4, gap: 6 });
expect(sp.coverGrid).toEqual({ columns: 2, gap: 24 });
});
it('leaves the photo grid alone when only the covers are configured', () => {
const sp = subpage({ coverGrid: { columns: 2, gap: 24 } });
expect(sp.grid).toBeUndefined();
expect(sp.coverGrid).toEqual({ columns: 2, gap: 24 });
});
// A gallery.yaml written before the split has no `coverGrid` at all, and its
// covers were sized by `grid`. Falling back to it keeps that page rendering
// exactly as it did.
it('falls back to the photo grid when no coverGrid was written', () => {
const sp = subpage({ grid: { columns: 4, gap: 6, layout: 'uniform' } });
expect(sp.coverGrid).toEqual({ columns: 4, gap: 6, layout: 'uniform' });
});
it('reports no cover override when the page states neither', () => {
expect(subpage({}).coverGrid).toBeUndefined();
});
it('reads coverGrid from the map form of subpages too', () => {
const gallery = deriveGallery({
subpages: {
Travel: { albums: [A], grid: { gap: 6 }, coverGrid: { columns: 2 } },
},
} as unknown as GalleryYaml);
expect(gallery.subpages[0].grid).toEqual({ gap: 6 });
expect(gallery.subpages[0].coverGrid).toEqual({ columns: 2 });
});
});

View File

@@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest';
import { compareVersions, isNewerVersion, parseLatestTag } from '@/lib/updateCheck';
describe('compareVersions', () => {
it.each([
['0.12.0', '0.12.0', 0],
['0.12.1', '0.12.0', 1],
['0.12.0', '0.12.1', -1],
['1.0.0', '0.99.99', 1],
['0.13.0', '0.9.0', 1],
])('%s vs %s', (a, b, expected) => {
expect(Math.sign(compareVersions(a, b))).toBe(expected);
});
/** 10 must not sort before 9, which is what a string compare would do. */
it('compares numerically, not lexically', () => {
expect(compareVersions('0.10.0', '0.9.0')).toBeGreaterThan(0);
expect(compareVersions('0.2.0', '0.10.0')).toBeLessThan(0);
});
it('tolerates the leading v the releases API uses', () => {
expect(compareVersions('v0.12.0', '0.12.0')).toBe(0);
expect(compareVersions('V0.13.0', '0.12.0')).toBeGreaterThan(0);
});
it('treats missing segments as zero', () => {
expect(compareVersions('1', '1.0.0')).toBe(0);
expect(compareVersions('1.1', '1.0.9')).toBeGreaterThan(0);
});
it('compares a pre-release on its numbers alone', () => {
expect(compareVersions('0.13.0-rc.1', '0.12.0')).toBeGreaterThan(0);
expect(compareVersions('0.12.0-rc.1', '0.12.0')).toBe(0);
});
it('does not crash on rubbish', () => {
expect(() => compareVersions('', 'x.y.z')).not.toThrow();
expect(compareVersions('x.y.z', '')).toBe(0);
});
});
describe('isNewerVersion', () => {
it('is true only for a strictly newer release', () => {
expect(isNewerVersion('0.13.0', '0.12.0')).toBe(true);
expect(isNewerVersion('0.12.0', '0.12.0')).toBe(false);
expect(isNewerVersion('0.11.0', '0.12.0')).toBe(false);
});
/** A downgraded tag must never be advertised as an update. */
it('does not nag when running ahead of the latest release', () => {
expect(isNewerVersion('v0.12.0', '0.13.0-dev')).toBe(false);
});
});
describe('parseLatestTag', () => {
it('reads tag_name', () => {
expect(parseLatestTag({ tag_name: 'v0.13.0' })).toBe('v0.13.0');
});
it('trims surrounding whitespace', () => {
expect(parseLatestTag({ tag_name: ' v0.13.0 ' })).toBe('v0.13.0');
});
it.each([
['null', null],
['a string', 'v0.13.0'],
['an array', []],
['an error object served with 200', { message: 'API rate limit exceeded' }],
['a non-string tag', { tag_name: 42 }],
['a tag that is not a version', { tag_name: 'nightly' }],
// The check only guarded the *start* of the tag, so anything beginning
// with a digit slipped through and was then compared as a version. A
// date-style tag reads as version 2024 and leaves every instance showing
// an update that does not exist, permanently.
['a date-style tag', { tag_name: '2024-01-05' }],
['a tag with a digit and then rubbish', { tag_name: '1abc' }],
])('returns null for %s', (_label, payload) => {
expect(parseLatestTag(payload)).toBeNull();
});
it('still accepts the shapes the project actually tags', () => {
expect(parseLatestTag({ tag_name: 'v0.13.0' })).toBe('v0.13.0');
expect(parseLatestTag({ tag_name: '0.13.0' })).toBe('0.13.0');
expect(parseLatestTag({ tag_name: 'v1.0' })).toBe('v1.0');
expect(parseLatestTag({ tag_name: 'v1.2.3-rc1' })).toBe('v1.2.3-rc1');
// Build metadata is ignorable for ordering, so rejecting it would mean
// never mentioning a release that is genuinely newer.
expect(parseLatestTag({ tag_name: 'v1.2.3+build.7' })).toBe('v1.2.3+build.7');
});
});

View File

@@ -17,6 +17,7 @@ import {
assetPlaceholder,
assetExifSummary,
assetAspectRatio,
assetCaption,
} from '@/lib/urls';
describe('imageUrl', () => {
@@ -168,3 +169,58 @@ describe('assetAspectRatio', () => {
).toBeUndefined();
});
});
describe('assetCaption', () => {
/** An asset carrying nothing but the description under test. */
const withDescription = (description: string | null) => ({
exifInfo: {
make: null,
model: null,
lensModel: null,
focalLength: null,
fNumber: null,
exposureTime: null,
iso: null,
exifImageWidth: null,
exifImageHeight: null,
latitude: null,
longitude: null,
city: null,
state: null,
country: null,
dateTimeOriginal: null,
description,
},
});
it('returns the description when captions are shown', () => {
expect(assetCaption(withDescription('A gull over the harbour'), true)).toBe(
'A gull over the harbour',
);
});
it('trims surrounding whitespace', () => {
expect(assetCaption(withDescription(' Low tide\n'), true)).toBe('Low tide');
});
it.each([
['null', null],
['empty', ''],
['whitespace only', ' \n\t '],
])('returns undefined for a %s description, leaving alt=""', (_label, value) => {
expect(assetCaption(withDescription(value), true)).toBeUndefined();
});
it('returns undefined when the asset has no exifInfo at all', () => {
expect(assetCaption({ exifInfo: undefined }, true)).toBeUndefined();
});
/**
* The point of #506: the description is the one field that can hold private
* notes. Alt text publishes it as surely as a visible caption does, so the
* caption switch has to gate it too.
*/
it('withholds the description when captions are switched off', () => {
expect(assetCaption(withDescription('Client hates this crop'), false)).toBeUndefined();
});
});

65
lib/albumNav.ts Normal file
View File

@@ -0,0 +1,65 @@
/**
* Sequential album navigation — the "previous / next album" pair at the foot of
* an album detail page, so a visitor drifting through the portfolio is not
* dropped at a dead end (#483).
*
* The order deliberately comes from whatever list the visitor just came
* through — the surrounding subpage, or the standalone album list — rather than
* from a separate ordering of its own. A "next" that disagreed with the grid
* the visitor was looking at a moment ago would be worse than no control.
*
* Neighbours are not wrapped around: the first album has no previous and the
* last has no next. Wrapping would guarantee a link in every position, but it
* also sends someone who reaches the end of a sequence back to its start with
* no sign that they have been round.
*/
/** The fields of an album this module needs. Structural, so `ImmichAlbum` fits. */
export interface AlbumNavCandidate {
id: string;
slug: string;
albumName: string;
}
export interface AlbumNavLink {
href: string;
name: string;
}
export interface AlbumNavPair {
prev?: AlbumNavLink;
next?: AlbumNavLink;
}
/**
* The neighbours of `currentId` within `siblings`, in that list's own order.
*
* `basePath` is the prefix the album slugs hang off: `''` for standalone albums
* (`/album-slug`) or `/subpage-slug` for albums inside a subpage.
*
* Returns an empty pair when the album is not in the list, or when it is the
* only one — both cases mean there is nothing to offer, and the caller renders
* nothing.
*/
export function albumNeighbours(
siblings: readonly AlbumNavCandidate[],
currentId: string,
basePath = '',
): AlbumNavPair {
const index = siblings.findIndex((a) => a.id === currentId);
if (index === -1 || siblings.length < 2) return {};
const href = (album: AlbumNavCandidate) => `${basePath}/${album.slug}`;
const link = (album: AlbumNavCandidate): AlbumNavLink => ({
href: href(album),
name: album.albumName,
});
const previous = index > 0 ? siblings[index - 1] : undefined;
const following = index < siblings.length - 1 ? siblings[index + 1] : undefined;
return {
...(previous ? { prev: link(previous) } : {}),
...(following ? { next: link(following) } : {}),
};
}

58
lib/assetLocation.ts Normal file
View File

@@ -0,0 +1,58 @@
/**
* The location precision that applies to a single photograph (#469).
*
* `location:` was written for the map, and the map was the only surface that
* honoured it. The lightbox info panel names the city and country of every
* photo from its own EXIF, gated on the global `exifDisplay` group — so an
* album set to `location: hidden`, which asks to be absent from the map
* entirely, still told anyone who clicked "Info" exactly where it was taken.
* Two settings with almost the same name disagreeing about the same fact.
*
* An asset has no album in its own right: the info request carries only an
* asset token. So the question is turned around — of the albums the operator
* deliberately restricted, which ones carry this asset?
*
* That keeps the common case free. Nobody using `location:` means nothing to
* check and no album fetched; the cost is bounded by how many albums were
* restricted, not by how many exist.
*/
import { getConfig } from './config';
import { immich } from './immich';
import { strictestPrecision, type LocationPrecision } from './mapPrecision';
/**
* The strictest `location:` among the albums carrying this asset, or `exact`
* when none of them restricts anything.
*
* An album that cannot be read counts as `hidden`. Membership is then unknown,
* and guessing that the asset is not in it would publish the position the
* setting exists to withhold — so this fails closed, at the cost of the info
* panel dropping its location line site-wide until the album is readable
* again. A configured album that Immich no longer has is already a warning in
* the config doctor.
*/
export async function assetLocationPrecision(assetId: string): Promise<LocationPrecision> {
const restricted = Object.entries(getConfig().albumLocationPrecision).filter(
([, level]) => level !== 'exact',
);
if (restricted.length === 0) return 'exact';
const levels = await Promise.all(
restricted.map(async ([albumId, level]): Promise<LocationPrecision | null> => {
try {
const album = await immich.getAlbum(albumId);
if (!album) return 'hidden';
return album.assets.some((asset) => asset.id === assetId) ? level : null;
} catch {
return 'hidden';
}
}),
);
const applicable = levels.filter((level): level is LocationPrecision => level !== null);
// strictestPrecision() reads an empty list as `hidden` — correct for a map
// marker nothing contributed to, wrong here, where it means no restricted
// album carries this photograph.
return applicable.length === 0 ? 'exact' : strictestPrecision(applicable);
}

View File

@@ -8,6 +8,9 @@
import crypto from 'crypto';
import { getConfig, SubpageConfig } from './config';
// From the pure schema module, not the config barrel: tests that stub out
// '@/lib/config' wholesale would otherwise lose this helper.
import { normalizeSlug } from './config/schema';
import { verifyScrypt, generateScryptHash, isScryptHash } from './password';
const TOKEN_EXPIRY_HOURS = 24;
@@ -92,11 +95,43 @@ function cookieName(key: string, type: ProtectedType): string {
*/
export const SITE_AUTH_COOKIE = 'lb_site_auth';
/**
* Whether this visitor may have the album at all — every gate on every route
* to it, not just the album's own (#475).
*
* A page checks the subpage's password before it renders anything; a route
* handler is reached without ever passing through that page, so it has to ask
* the same question itself. Checking only the album's own password let an
* album that carries none through, even when the sole way to it was a subpage
* that does.
*
* An album listed on the home page is published there whatever else lists it,
* so it stays reachable. Where the only routes are subpages, one of them has
* to be open — which mirrors what the visitor could actually have clicked.
*/
export function isAlbumReachable(
albumId: string,
getCookie: (name: string) => string | undefined,
): boolean {
if (!isAuthenticated(albumId, getCookie, 'album')) return false;
const config = getConfig();
if (config.standaloneAlbums?.includes(albumId)) return true;
const routes = config.subpages.filter(
(sp) => sp.enabled !== false && sp.albumIds.includes(albumId),
);
if (routes.length === 0) return true;
return routes.some((sp) => isAuthenticated(sp.slug, getCookie, 'subpage'));
}
/**
* Find the SubpageConfig for a given slug.
*/
export function findSubpageBySlug(slug: string): SubpageConfig | undefined {
return getConfig().subpages.find((sp) => sp.slug === slug);
const wanted = normalizeSlug(slug);
return getConfig().subpages.find((sp) => sp.slug === wanted);
}
/**
@@ -108,7 +143,8 @@ function findPassword(key: string, type: ProtectedType): string | undefined {
return config.sitePassword || undefined;
}
if (type === 'subpage') {
return config.subpages.find((sp) => sp.slug === key)?.password;
const wanted = normalizeSlug(key);
return config.subpages.find((sp) => sp.slug === wanted)?.password;
}
if (type === 'album') {
return config.albumPasswords[key];

View File

@@ -2,10 +2,12 @@ import fs from 'node:fs';
import path from 'node:path';
import { env } from '../env';
import { resolveAuthSecret } from '../secret';
import { resolveSiteUrl } from '../siteUrl';
import { getInstallCredentials } from '../install';
import { loadYaml, clearYamlCache, validateUuid } from './parser';
import { resolveTheme, VALID_LAYOUTS, DEFAULT_PRESET } from './theme';
import { ALBUM_SORT_MODES, isAlbumSortMode, type AlbumSortMode } from '../albumSort';
import { LOCATION_PRECISIONS, isLocationPrecision, type LocationPrecision } from '../mapPrecision';
import {
slugify,
resolveExifDisplay,
@@ -56,27 +58,54 @@ export function getConfigOrNull(): AppConfig | null {
}
}
/** Converts raw YAML grid overrides into a typed partial GridConfig. */
export function buildSubpageGrid(raw?: {
interface RawGridOverrides {
columns?: number;
gap?: number;
aspectRatio?: string;
layout?: string;
}): { grid: Partial<GridConfig> } | Record<string, never> {
if (!raw) return {};
}
/** Converts raw YAML grid overrides into a typed partial GridConfig. */
function parseGridOverrides(raw: RawGridOverrides): Partial<GridConfig> {
return {
grid: {
...(raw.columns != null ? { columns: raw.columns } : {}),
...(raw.gap != null ? { gap: raw.gap } : {}),
...(raw.aspectRatio != null ? { aspectRatio: raw.aspectRatio } : {}),
...(raw.layout != null
? {
layout: (VALID_LAYOUTS.includes(raw.layout)
? raw.layout
: 'masonry') as GridConfig['layout'],
}
: {}),
},
...(raw.columns != null ? { columns: raw.columns } : {}),
...(raw.gap != null ? { gap: raw.gap } : {}),
...(raw.aspectRatio != null ? { aspectRatio: raw.aspectRatio } : {}),
...(raw.layout != null
? {
layout: (VALID_LAYOUTS.includes(raw.layout)
? raw.layout
: 'masonry') as GridConfig['layout'],
}
: {}),
};
}
/** Converts raw YAML grid overrides into a typed partial GridConfig. */
export function buildSubpageGrid(
raw?: RawGridOverrides,
): { grid: Partial<GridConfig> } | Record<string, never> {
if (!raw) return {};
return { grid: parseGridOverrides(raw) };
}
/**
* The two grids a subpage can override, as one spread-able object.
*
* `grid` sizes the photos inside its albums, `coverGrid` the album-cover tiles
* on the page itself. They were a single key until #523, where the admin field
* labelled "Album Cover Grid" turned out to retune every photo grid on the page
* as well. A gallery.yaml written before the split has no `coverGrid`, so the
* covers fall back to `grid` and such a page renders exactly as it did.
*/
function buildSubpageGrids(
grid?: RawGridOverrides,
coverGrid?: RawGridOverrides,
): { grid?: Partial<GridConfig>; coverGrid?: Partial<GridConfig> } {
const resolved = coverGrid ?? grid;
return {
...buildSubpageGrid(grid),
...(resolved ? { coverGrid: parseGridOverrides(resolved) } : {}),
};
}
@@ -139,6 +168,8 @@ export interface GalleryDerivation {
albumManualOrders: Record<string, string[]>;
albumGrids: Record<string, Partial<GridConfig>>;
albumCoverPositions: Record<string, string>;
albumDownloads: Record<string, boolean>;
albumLocationPrecision: Record<string, LocationPrecision>;
}
/**
@@ -162,18 +193,24 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
const albumManualOrders: Record<string, string[]> = {};
const albumGrids: Record<string, Partial<GridConfig>> = {};
const albumCoverPositions: Record<string, string> = {};
const albumDownloads: Record<string, boolean> = {};
const albumLocationPrecision: Record<string, LocationPrecision> = {};
/** Returns null for an entry whose album ID is not a UUID; callers drop it. */
function processAlbumEntry(
entry: string | Record<string, string | AlbumEntryObject>,
context: string,
inheritedLocation?: LocationPrecision,
): string | null {
if (typeof entry === 'string') {
if (inheritedLocation) albumLocationPrecision[entry] = inheritedLocation;
return validateUuid(entry, context);
}
const [uuid, value] = Object.entries(entry)[0];
const validatedUuid = validateUuid(uuid, context);
if (!validatedUuid) return null;
// The subpage's setting applies unless the album states its own below.
if (inheritedLocation) albumLocationPrecision[validatedUuid] = inheritedLocation;
if (typeof value === 'string') {
albumOverrides[validatedUuid] = value;
@@ -246,6 +283,13 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
}
albumCoverPositions[validatedUuid] = pos;
}
// Opt-in per album, never global: a public portfolio must not start
// handing out full-resolution originals because one client gallery
// needed to (#475).
if (value.download === true) albumDownloads[validatedUuid] = true;
const location = parseLocation(value.location, `${context}: album ${validatedUuid} location`);
if (location) albumLocationPrecision[validatedUuid] = location;
}
return validatedUuid;
}
@@ -254,12 +298,29 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
function processAlbumEntries(
entries: Array<string | Record<string, string | AlbumEntryObject>>,
context: string,
inheritedLocation?: LocationPrecision,
): string[] {
return entries
.map((entry) => processAlbumEntry(entry, context))
.map((entry) => processAlbumEntry(entry, context, inheritedLocation))
.filter((id): id is string => id !== null);
}
/**
* Narrow a raw `location:` value, or throw. Rejected rather than defaulted
* for the same reason as `sort`: a typo in a privacy setting that silently
* fell back to `exact` would publish the position it was meant to withhold.
*/
function parseLocation(raw: string | undefined, context: string): LocationPrecision | undefined {
if (raw == null) return undefined;
if (!isLocationPrecision(raw)) {
throw new Error(
`${context}: unknown location "${raw}". ` +
`Valid values are: ${LOCATION_PRECISIONS.join(', ')}.`,
);
}
return raw;
}
const standaloneAlbumIds = processAlbumEntries(gallery.albums ?? [], 'gallery.yaml albums');
let subpages: SubpageConfig[] = [];
@@ -279,19 +340,31 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
title: sec.title,
slug: slugify(sec.title),
description: sec.description,
albumIds: processAlbumEntries(sec.albums, `subpage "${sp.name}" section "${sec.title}"`),
albumIds: processAlbumEntries(
sec.albums,
`subpage "${sp.name}" section "${sec.title}"`,
parseLocation(sp.location, `subpage "${sp.name}" location`),
),
}));
// flat union of all section albums
albumIds = sections!.flatMap((sec) => sec.albumIds);
// Also include any top-level albums (outside sections)
const topLevel = processAlbumEntries(sp.albums ?? [], `subpage "${sp.name}"`);
const topLevel = processAlbumEntries(
sp.albums ?? [],
`subpage "${sp.name}"`,
parseLocation(sp.location, `subpage "${sp.name}" location`),
);
albumIds = [...topLevel, ...albumIds];
} else {
const albums = sp.albums ?? [];
if (albums.length === 0) {
throw new Error(`Subpage "${sp.name}" must have albums or sections`);
}
albumIds = processAlbumEntries(albums, `subpage "${sp.name}"`);
albumIds = processAlbumEntries(
albums,
`subpage "${sp.name}"`,
parseLocation(sp.location, `subpage "${sp.name}" location`),
);
}
return {
@@ -307,7 +380,7 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
essayText: sp.essayText,
enabled: sp.enabled !== false,
hidden: sp.hidden === true,
...buildSubpageGrid(sp.grid),
...buildSubpageGrids(sp.grid, sp.coverGrid),
};
});
} else if (gallery.subpages) {
@@ -327,14 +400,18 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
slug: slugify(name),
title: sp.title,
subtitle: sp.subtitle,
albumIds: processAlbumEntries(albumEntries, `subpage "${name}"`),
albumIds: processAlbumEntries(
albumEntries,
`subpage "${name}"`,
parseLocation(sp.location, `subpage "${name}" location`),
),
password: sp.password,
proofing: sp.proofing,
essayFile: sp.essayFile,
essayText: sp.essayText,
enabled: sp.enabled !== false,
hidden: sp.hidden === true,
...buildSubpageGrid(sp.grid),
...buildSubpageGrids(sp.grid, sp.coverGrid),
};
});
}
@@ -354,6 +431,8 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
albumManualOrders,
albumGrids,
albumCoverPositions,
albumDownloads,
albumLocationPrecision,
};
}
@@ -408,10 +487,13 @@ export function getConfig(): AppConfig {
title: 'Setup Required',
description: 'Please configure Immich Folio',
titleTemplate: '%s | Setup Required',
license: '',
noIndex: true,
noFollow: true,
},
heroImages: [],
siteUrl: null,
siteUrlSource: 'none',
colorMode: 'dark',
exifOnHover: true,
exif: resolveExifDisplay(),
@@ -434,6 +516,8 @@ export function getConfig(): AppConfig {
albumManualOrders: {},
albumGrids: {},
albumCoverPositions: {},
albumDownloads: {},
albumLocationPrecision: {},
navLinks: [],
cacheTtl: env.CACHE_TTL * 1000,
staleMaxAge: env.STALE_MAX_AGE * 1000,
@@ -460,6 +544,8 @@ export function getConfig(): AppConfig {
albumManualOrders,
albumGrids,
albumCoverPositions,
albumDownloads,
albumLocationPrecision,
} = deriveGallery(gallery);
const siteSeoTitle = settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery';
@@ -486,12 +572,17 @@ export function getConfig(): AppConfig {
titleTemplate: settings.seo?.titleTemplate || `%s | ${siteSeoTitle}`,
noIndex: settings.seo?.noIndex === true,
noFollow: settings.seo?.noFollow === true,
license: settings.seo?.license?.trim() || '',
},
heroImages: gallery.hero
? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero])
.map((id) => validateUuid(id, 'gallery.yaml hero'))
.filter((id): id is string => id !== null)
: [],
...(() => {
const site = resolveSiteUrl(settings.url, env.SITE_URL);
return { siteUrl: site.url, siteUrlSource: site.source };
})(),
colorMode: resolveColorMode(settings.mode),
exifOnHover: settings.exifOnHover !== false,
exif: resolveExifDisplay(settings.exif, settings.exifOnHover),
@@ -547,6 +638,8 @@ export function getConfig(): AppConfig {
albumManualOrders,
albumGrids,
albumCoverPositions,
albumDownloads,
albumLocationPrecision,
navLinks: sanitizeNavLinks(settings.navLinks),
cacheTtl: env.CACHE_TTL * 1000,
staleMaxAge: env.STALE_MAX_AGE * 1000,

View File

@@ -1,4 +1,5 @@
import type { AlbumSortMode } from '../albumSort';
import type { LocationPrecision } from '../mapPrecision';
export interface SubpageSectionConfig {
title: string;
@@ -15,7 +16,13 @@ export interface SubpageConfig {
albumIds: string[]; // flat list (all albums, incl. from sections)
sections?: SubpageSectionConfig[];
password?: string;
/** Photo grid for the albums on this page: global < subpage < album. */
grid?: Partial<GridConfig>;
/**
* The album-cover tiles on this page. Falls back to `grid` when unset, which
* is what a pre-#523 gallery.yaml renders with (#523).
*/
coverGrid?: Partial<GridConfig>;
proofing?: boolean;
essayFile?: string;
essayText?: string;
@@ -29,12 +36,16 @@ export interface SubpageObjectValue {
subtitle?: string;
password?: string;
grid?: Partial<GridConfig>;
/** Album-cover tiles only; falls back to `grid` when unset (#523). */
coverGrid?: Partial<GridConfig>;
proofing?: boolean;
essayFile?: string;
essayText?: string;
enabled?: boolean;
/** EXPERIMENTAL: reachable by direct link, but not shown in navigation */
hidden?: boolean;
/** Map precision for every album here that does not set its own (#469). */
location?: string;
// Both album lists reuse AlbumEntryObject rather than inlining the shape:
// the inline copies had already drifted (they never gained `heroImage`), and
// this path is live for map-style subpages.
@@ -91,6 +102,14 @@ export interface GridConfig {
'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow' | 'essay' | 'justified';
}
/**
* Bounds for the photo grid, shared by the site-wide and per-subpage inputs so
* the two forms cannot offer different ranges for the same setting.
*/
export const PHOTO_GRID_COLUMNS_MIN = 1;
export const PHOTO_GRID_COLUMNS_MAX = 6;
export const PHOTO_GRID_GAP_MAX = 48;
/**
* Bounds for the album-cover grid on a subpage. Shared with the admin inputs so
* the form and the renderer cannot drift apart.
@@ -105,12 +124,16 @@ export const COVER_GRID_TABLET_COLUMNS_MAX = 2;
/**
* The CSS custom properties that size the album-cover grid on a subpage.
*
* `columns` follows the same config as the photo grids (global `settings.yaml`
* < per-subpage override), so "3 columns" in the admin means three columns
* everywhere. `gap` deliberately does not: each theme preset picks the cover
* spacing as part of its look (1px monograph, 20px studio-modern), so the
* variable is only emitted when a subpage sets `gap` explicitly — otherwise the
* preset's own `--subpage-gap` stands.
* The overrides come from the subpage's own `coverGrid`, which sizes the cover
* tiles and nothing else — it used to be the same `grid` key the photos read,
* so a cover gap silently retuned every photo grid on the page (#523).
*
* `columns` still falls back to the global `settings.yaml` count, so a page
* that states nothing tiles its covers like its photos. `gap` deliberately does
* not: each theme preset picks the cover spacing as part of its look (1px
* monograph, 20px studio-modern), so the variable is only emitted when a
* subpage sets `gap` explicitly — otherwise the preset's own `--subpage-gap`
* stands.
*
* `--subpage-columns-tablet` is computed here rather than in CSS because
* `repeat()` does not reliably accept a `min()` expression.
@@ -158,7 +181,16 @@ export interface AppConfig {
titleTemplate: string;
noIndex: boolean;
noFollow: boolean;
/** Emitted as `license` in JSON-LD when set. */
license: string;
};
/**
* The site's own absolute URL, or null when nobody configured one. Sitemap,
* feed and JSON-LD need it and have no request context to derive it from.
*/
siteUrl: string | null;
/** Where `siteUrl` came from, so the admin panel can name it (#472). */
siteUrlSource: 'env' | 'settings' | 'none';
heroImages: string[];
/** Colour mode a first-time visitor lands on; their own choice overrides it. */
colorMode: ColorMode;
@@ -198,6 +230,10 @@ export interface AppConfig {
albumGrids: Record<string, Partial<GridConfig>>;
/** EXPERIMENTAL: per-album focal point for cover crops (CSS object-position). */
albumCoverPositions: Record<string, string>;
/** Albums whose originals may be downloaded. Absent means no (#475). */
albumDownloads: Record<string, boolean>;
/** Per-album map precision. Absent means `exact` (#469). */
albumLocationPrecision: Record<string, LocationPrecision>;
/** EXPERIMENTAL: external links appended to the header navigation. */
navLinks: NavLinkConfig[];
cacheTtl: number;
@@ -258,6 +294,14 @@ export interface AlbumEntryObject {
grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
/** EXPERIMENTAL: focal point for the cover crop, e.g. "50% 25%" or "top" */
coverPosition?: string;
/** Offer the original file for download from the lightbox (#475). Off by default. */
download?: boolean;
/**
* How precisely this album's photographs may be placed on the map:
* `exact` (default), `city`, `country` or `hidden`. Narrowed to
* `LocationPrecision` in deriveGallery(), where a typo has to be rejected.
*/
location?: string;
}
export interface GalleryYaml {
@@ -281,18 +325,29 @@ export interface GalleryYaml {
essayText?: string;
enabled?: boolean;
hidden?: boolean;
/** Map precision inherited by every album here (#469). */
location?: string;
grid?: {
columns?: number;
gap?: number;
aspectRatio?: string;
layout?: string;
};
/** Album covers only; falls back to `grid` when unset (#523). */
coverGrid?: {
columns?: number;
gap?: number;
aspectRatio?: string;
layout?: string;
};
}>;
}
export interface SettingsYaml {
title?: string;
subtitle?: string;
/** Absolute site URL, e.g. https://folio.example. `SITE_URL` overrides it. */
url?: string;
lang?: string;
seo?: {
title?: string;
@@ -300,6 +355,8 @@ export interface SettingsYaml {
titleTemplate?: string;
noIndex?: boolean;
noFollow?: boolean;
/** Licence URL or identifier for structured data, e.g. a CC link (#472). */
license?: string;
};
/** 'dark' (default), 'light' or 'auto' — the visitor's starting colour mode. */
mode?: ColorMode;
@@ -450,11 +507,60 @@ export function resolveWatermarkOpacity(raw?: number): number {
return Math.min(1, Math.max(0, fraction));
}
/**
* URL slug for a display name.
*
* NFD + combining-mark removal folds Latin diacritics ("Café" -> "cafe"), but
* everything else keeps its letters: the separator class is \p{L}\p{N}, not
* [a-z0-9]. The ASCII-only version stripped CJK, Hangul and Cyrillic names down
* to an empty slug, which turned their links into "/" (#522). NFC at the end
* recomposes what NFD split — Hangul syllables decompose into jamo, and only
* the composed form matches what a browser sends back in the URL.
*
* Can still return '' for a name made purely of symbols or emoji; callers that
* need a routable slug must supply a fallback (see albumSlug).
*/
export function slugify(name: string): string {
return name
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
.replace(/[^\p{L}\p{N}]+/gu, '-')
.replace(/^-+|-+$/g, '')
.normalize('NFC');
}
/**
* Routable slug for an album. Falls back to the album id when the name has no
* letters or digits at all, so an emoji-only album is still reachable — and so
* two of them do not collide on the same empty slug.
*/
export function albumSlug(name: string, id: string): string {
return slugify(name) || id;
}
/**
* Normalize a slug that came in from a URL before comparing it to a stored one.
*
* Two things can differ even when the slug is "the same":
*
* - Percent-encoding. A non-ASCII path segment reaches the route handler
* still encoded ("%E5%AE%B6..."), so comparing it raw against the stored
* slug never matches — the half of #522 that survived fixing slugify().
* - Unicode form. Slugs are stored NFC; a non-Latin slug typed or pasted on
* macOS can arrive NFD, and the two are different strings that render the
* same.
*
* ASCII slugs are unaffected by either step. No stored slug can contain a '%',
* since slugify() drops it, so decoding cannot collide with a real slug.
*/
export function normalizeSlug(slug: string): string {
let decoded = slug;
try {
decoded = decodeURIComponent(slug);
} catch {
// Malformed percent sequence — compare what we were actually given rather
// than throwing a 500 on a hand-mangled URL.
}
return decoded.normalize('NFC');
}

69
lib/downloadName.ts Normal file
View File

@@ -0,0 +1,69 @@
/**
* Filenames for downloaded originals (#475).
*
* The name comes from Immich, which took it from the camera or from whatever
* the photographer typed - so it is untrusted input on its way into a
* `Content-Disposition` header. A newline in it would end the header and let
* the rest be read as headers of its own; a path separator would suggest a
* directory to the browser.
*/
/**
* Drop control characters, including the CR and LF that would end a header.
*
* Written as a code-point filter rather than a regular expression: a character
* class of escaped control codes is the kind of source line that gets mangled
* by an editor or a copy-paste and then silently stops matching.
*/
function stripControlChars(value: string): string {
return Array.from(value)
.filter((character) => {
const code = character.charCodeAt(0);
return code > 31 && code !== 127;
})
.join('');
}
/** True for printable ASCII, the range the pre-RFC-5987 filename is limited to. */
function isPrintableAscii(character: string): boolean {
const code = character.charCodeAt(0);
return code >= 32 && code <= 126;
}
/**
* A filename safe to put in a `Content-Disposition` header.
*
* Strips anything that could break out of the header or out of the download
* directory, then falls back to a generic name if nothing usable is left - an
* empty filename would make the browser invent one from the URL, which is the
* opaque token.
*/
export function safeDownloadName(raw: string | undefined, fallback = 'photo'): string {
const cleaned = stripControlChars(raw ?? '')
// Directory separators and traversal.
.replace(/[/\\]/g, '_')
// Quotes and semicolons delimit the header's own parameters.
.replace(/["';]/g, '')
.replace(/^\.+/, '')
.trim();
if (!cleaned) return fallback;
// Long names are the browser's problem, but an unbounded one is ours.
return cleaned.slice(0, 200);
}
/**
* A full `Content-Disposition` value.
*
* Emits both `filename` and `filename*`: the plain parameter is stripped to
* ASCII for clients that predate RFC 5987, and the starred one carries the
* real name percent-encoded for everything since.
*/
export function contentDisposition(name: string | undefined): string {
const safe = safeDownloadName(name);
const ascii =
Array.from(safe)
.map((character) => (isPrintableAscii(character) ? character : '_'))
.join('') || 'photo';
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(safe)}`;
}

View File

@@ -19,6 +19,10 @@ export interface Env {
ADMIN_PASSWORD?: string;
SITE_PASSWORD?: string;
INSTALL_CONTENT_DIR?: string;
/** Whether the admin dashboard may ask GitHub for the latest release. */
UPDATE_CHECK: boolean;
/** Absolute site URL for sitemap, feed and JSON-LD. Overrides settings.yaml. */
SITE_URL?: string;
}
function parseEnv(): Env {
@@ -111,6 +115,11 @@ function parseEnv(): Env {
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || undefined,
SITE_PASSWORD: process.env.SITE_PASSWORD || undefined,
INSTALL_CONTENT_DIR: process.env.INSTALL_CONTENT_DIR || undefined,
// On unless switched off: an instance that never learns about a security
// release is the worse default. Only `false` disables it, so a typo does
// not silently turn the check off.
UPDATE_CHECK: process.env.UPDATE_CHECK !== 'false',
SITE_URL: process.env.SITE_URL || undefined,
};
}

View File

@@ -29,6 +29,11 @@ export const de: Dictionary = {
photos: (n: number) => plural(n, 'Foto', 'Fotos'),
albums: (n: number) => plural(n, 'Album', 'Alben'),
collections: (n: number) => plural(n, 'Sammlung', 'Sammlungen'),
prevAlbum: 'Vorheriges Album',
nextAlbum: 'Nächstes Album',
prevAlbumAria: (name: string) => `Vorheriges Album: ${name}`,
nextAlbumAria: (name: string) => `Nächstes Album: ${name}`,
albumNavAria: 'Albumnavigation',
loadingGallery: 'Galerie wird geladen',
loadingPhotos: 'Fotos werden geladen',
},
@@ -123,11 +128,25 @@ export const de: Dictionary = {
iso: 'ISO',
location: 'Ort',
noExif: 'Keine EXIF-Daten vorhanden',
copyLink: 'Link zu diesem Foto kopieren',
copyLinkTitle: 'Link zu diesem Foto kopieren (c)',
copyLinkShort: 'Link',
copied: 'Kopiert',
copyManual: 'Diesen Link kopieren',
shortcuts: 'Tastaturkürzel',
shortcutNavigate: 'Vorheriges / nächstes Foto',
shortcutInfo: 'Fotoinfos',
shortcutFullscreen: 'Vollbild',
shortcutExitFullscreen: 'Vollbild verlassen',
download: 'Originaldatei herunterladen',
downloadTitle: 'Originaldatei herunterladen (d)',
downloadShort: 'Original',
shortcutDownload: 'Original herunterladen',
shortcutSlideshow: 'Diaschau',
shortcutSlideshowRunning: (seconds: number) => `Diaschau — alle ${seconds}s`,
slideshowStopped: 'Diaschau angehalten',
slideshowRunning: (seconds: number) => `Diaschau läuft, wechselt alle ${seconds} Sekunden`,
shortcutCopyLink: 'Link zum Foto kopieren',
shortcutList: 'Diese Liste',
shortcutClose: 'Ansicht schließen',
},

View File

@@ -29,6 +29,11 @@ export const en = {
photos: (n: number) => plural(n, 'photo', 'photos'),
albums: (n: number) => plural(n, 'album', 'albums'),
collections: (n: number) => plural(n, 'collection', 'collections'),
prevAlbum: 'Previous album',
nextAlbum: 'Next album',
prevAlbumAria: (name: string) => `Previous album: ${name}`,
nextAlbumAria: (name: string) => `Next album: ${name}`,
albumNavAria: 'Album navigation',
loadingGallery: 'Loading gallery',
loadingPhotos: 'Loading photos',
},
@@ -123,11 +128,25 @@ export const en = {
iso: 'ISO',
location: 'Location',
noExif: 'No EXIF data available',
copyLink: 'Copy link to this photo',
copyLinkTitle: 'Copy link to this photo (c)',
copyLinkShort: 'Link',
copied: 'Copied',
copyManual: 'Copy this link',
shortcuts: 'Keyboard shortcuts',
shortcutNavigate: 'Previous / next photo',
shortcutInfo: 'Photo info',
shortcutFullscreen: 'Fullscreen',
shortcutExitFullscreen: 'Leave fullscreen',
download: 'Download the original file',
downloadTitle: 'Download the original file (d)',
downloadShort: 'Original',
shortcutDownload: 'Download the original',
shortcutSlideshow: 'Slideshow',
shortcutSlideshowRunning: (seconds: number) => `Slideshow — every ${seconds}s`,
slideshowStopped: 'Slideshow stopped',
slideshowRunning: (seconds: number) => `Slideshow running, advancing every ${seconds} seconds`,
shortcutCopyLink: 'Copy link to this photo',
shortcutList: 'This list',
shortcutClose: 'Close the viewer',
},

View File

@@ -7,7 +7,7 @@
* requests go through our proxy route.
*/
import { getConfig, slugify, type SubpageConfig } from './config';
import { getConfig, albumSlug, normalizeSlug, type SubpageConfig } from './config';
import { cache } from './cache';
import { compareByCaptureTime, sortAlbumAssets, DEFAULT_ALBUM_SORT } from './albumSort';
@@ -423,7 +423,7 @@ class ImmichClient {
...album,
albumName: name,
description,
slug: slugify(name),
slug: albumSlug(name, album.id),
};
});
@@ -530,9 +530,8 @@ class ImmichClient {
subpageSlug: string,
forceFresh = false,
): Promise<{ subpage: SubpageConfig; albums: ImmichAlbum[] } | null> {
const subpage = this.config.subpages.find(
(sp) => sp.slug === subpageSlug && sp.enabled !== false,
);
const wanted = normalizeSlug(subpageSlug);
const subpage = this.config.subpages.find((sp) => sp.slug === wanted && sp.enabled !== false);
if (!subpage) return null;
const allAlbums = await this.getAlbums(forceFresh);
@@ -700,7 +699,7 @@ class ImmichClient {
const description = this.config.albumDescriptions[album.id] ?? album.description ?? '';
album.albumName = name;
album.description = description;
album.slug = slugify(name);
album.slug = albumSlug(name, album.id);
this.cacheSet(cacheKey, album);
return album;
@@ -728,13 +727,15 @@ class ImmichClient {
let searchSet = albums;
if (subpageSlug) {
const subpage = this.config.subpages.find((sp) => sp.slug === subpageSlug);
const wantedSubpage = normalizeSlug(subpageSlug);
const subpage = this.config.subpages.find((sp) => sp.slug === wantedSubpage);
if (!subpage) return null;
const subpageIds = new Set(subpage.albumIds);
searchSet = albums.filter((a) => subpageIds.has(a.id));
}
const match = searchSet.find((a) => a.slug === slug);
const wanted = normalizeSlug(slug);
const match = searchSet.find((a) => a.slug === wanted);
if (!match) return null;
return this.getAlbum(match.id, forceFresh);
}
@@ -743,7 +744,8 @@ class ImmichClient {
* Check if a slug corresponds to a subpage.
*/
isSubpageSlug(slug: string): boolean {
return this.config.subpages.some((sp) => sp.slug === slug && sp.enabled !== false);
const wanted = normalizeSlug(slug);
return this.config.subpages.some((sp) => sp.slug === wanted && sp.enabled !== false);
}
/**

171
lib/lightboxShortcuts.ts Normal file
View File

@@ -0,0 +1,171 @@
/**
* The lightbox's keyboard shortcuts, in one place (#473).
*
* Three things read this list: the `?` overlay the visitor sees, the help
* section in the admin panel where the site owner finds out these keys exist
* at all, and — since this commit — the viewer's own key handler.
*
* That last one is the point. The catalogue used to describe the keys while
* the handler independently decided which ones it listened for, so a key added
* to the viewer and forgotten here stayed exactly as undiscoverable as the
* whole set was before, and nothing would have said so. Now a key reaches the
* handler only by being declared here, and `LightboxAction` is a closed union
* the handler switches over exhaustively: declaring an action the viewer does
* not implement is a type error, not a silent gap.
*
* The labels stay out of here: they live in the dictionaries, and the two
* dynamic ones (fullscreen flips, the slideshow names its speed) are resolved
* by the viewer, which is the only place that knows the current state.
*/
/** What has to be true for a shortcut to do anything. */
export type ShortcutAvailability =
/** Always present. */
| 'always'
/** Journal entries hide the EXIF toggle, so `i` does nothing there. */
| 'exifPanel'
/** Absent where the browser has no element fullscreen to give (iPhone). */
| 'fullscreen'
/** Only where the album offers its originals. */
| 'download';
/**
* What a key does. Closed on purpose: the viewer switches over this union
* exhaustively, so adding a member without handling it fails to compile.
*/
export type LightboxAction =
| 'close'
| 'prev'
| 'next'
| 'info'
| 'fullscreen'
| 'slideshow'
| 'copyLink'
| 'download'
| 'shortcutList';
export interface ShortcutBinding {
/**
* How the key is written in the two lists. Arrows are stored as names rather
* than glyphs so the list survives an editor that mangles them; both
* renderers ask for the display form via `shortcutKeyLabel()`.
*/
display: string;
/**
* The exact `KeyboardEvent.key` values that trigger it, case variants
* included. `?` is matched on the produced character, not the physical key:
* Shift+/ on a US layout, Shift+ß on a German one.
*/
eventKeys: readonly string[];
action: LightboxAction;
}
export interface LightboxShortcut {
bindings: readonly ShortcutBinding[];
/** Key into the `lightbox` dictionary section. */
labelKey:
| 'shortcutNavigate'
| 'shortcutInfo'
| 'shortcutFullscreen'
| 'shortcutSlideshow'
| 'shortcutCopyLink'
| 'shortcutDownload'
| 'shortcutList'
| 'shortcutClose';
availability: ShortcutAvailability;
/** Shown in the admin help to explain when the key is absent. */
note?: string;
}
/** In the order they are worth learning. */
export const LIGHTBOX_SHORTCUTS: readonly LightboxShortcut[] = [
{
// One row, two actions: the arrows are learnt together but do opposite
// things, which is why a binding carries the action rather than the row.
bindings: [
{ display: 'ARROW_LEFT', eventKeys: ['ArrowLeft'], action: 'prev' },
{ display: 'ARROW_RIGHT', eventKeys: ['ArrowRight'], action: 'next' },
],
labelKey: 'shortcutNavigate',
availability: 'always',
},
{
bindings: [{ display: 'I', eventKeys: ['i', 'I'], action: 'info' }],
labelKey: 'shortcutInfo',
availability: 'exifPanel',
note: 'Hidden where every EXIF group is switched off, and in journal entries.',
},
{
bindings: [{ display: 'F', eventKeys: ['f', 'F'], action: 'fullscreen' }],
labelKey: 'shortcutFullscreen',
availability: 'fullscreen',
note: 'Absent on browsers with no element fullscreen, such as Safari on iPhone.',
},
{
bindings: [{ display: 'S', eventKeys: ['s', 'S'], action: 'slideshow' }],
labelKey: 'shortcutSlideshow',
availability: 'always',
note: 'Cycles off, 3s, 5s, 10s and back to off. Any arrow or swipe stops it.',
},
{
bindings: [{ display: 'C', eventKeys: ['c', 'C'], action: 'copyLink' }],
labelKey: 'shortcutCopyLink',
availability: 'always',
note: 'Copies a link to the photo on screen. Positional: reordering the album moves where it lands.',
},
{
bindings: [{ display: 'D', eventKeys: ['d', 'D'], action: 'download' }],
labelKey: 'shortcutDownload',
availability: 'download',
note: 'Only for albums with download: true in gallery.yaml.',
},
{
// `h` is the escape hatch for layouts where `?` is awkward — and the one
// key someone guesses without having been told.
bindings: [
{ display: '?', eventKeys: ['?'], action: 'shortcutList' },
{ display: 'H', eventKeys: ['h', 'H'], action: 'shortcutList' },
],
labelKey: 'shortcutList',
availability: 'always',
},
{
bindings: [{ display: 'Esc', eventKeys: ['Escape'], action: 'close' }],
labelKey: 'shortcutClose',
availability: 'always',
},
];
/**
* Arrow names to glyphs. Ordinary keys pass through, so both renderers can map
* every display key through this without asking which kind it is.
*/
export function shortcutKeyLabel(key: string): string {
if (key === 'ARROW_LEFT') return String.fromCharCode(0x2190);
if (key === 'ARROW_RIGHT') return String.fromCharCode(0x2192);
return key;
}
/** The keys of one shortcut, ready to render. */
export function shortcutDisplayKeys(shortcut: LightboxShortcut): string[] {
return shortcut.bindings.map((binding) => shortcutKeyLabel(binding.display));
}
const ACTION_BY_EVENT_KEY: ReadonlyMap<string, LightboxAction> = new Map(
LIGHTBOX_SHORTCUTS.flatMap((shortcut) =>
shortcut.bindings.flatMap((binding) =>
binding.eventKeys.map((key) => [key, binding.action] as const),
),
),
);
/**
* What a `KeyboardEvent.key` should do, or null if the viewer does not bind it.
*
* This is the only route from a keypress to an action, so a key the catalogue
* does not list cannot reach the viewer, and one it does list cannot be
* silently missing from the help.
*/
export function lightboxActionFor(eventKey: string): LightboxAction | null {
return ACTION_BY_EVENT_KEY.get(eventKey) ?? null;
}

107
lib/mapPrecision.ts Normal file
View File

@@ -0,0 +1,107 @@
/**
* How precisely an album's photographs may be placed on the map (#469).
*
* The map is the only public GPS surface: the EXIF route does not expose
* coordinates at all, and password-protected albums are dropped before
* aggregation. What was left was public albums, whose marker sits at the mean
* of the actual coordinates — and for an album shot in one place (a garden, a
* studio, a client's home, a fragile site) that mean *is* that place. The
* marker is named after the city and stands on the property. Nobody chose
* that; it follows from the camera writing GPS.
*
* Coordinates are quantised rather than snapped to city centres, so the
* project takes on no geodata dependency.
*/
export const LOCATION_PRECISIONS = ['exact', 'city', 'country', 'hidden'] as const;
export type LocationPrecision = (typeof LOCATION_PRECISIONS)[number];
export function isLocationPrecision(value: string): value is LocationPrecision {
return (LOCATION_PRECISIONS as readonly string[]).includes(value);
}
/**
* Grid each level snaps to. `decimals` trims the floating-point tail that
* dividing and re-multiplying leaves behind — without it 0.05° rounding emits
* values like 52.900000000000006, which would be a needlessly precise-looking
* number for a deliberately imprecise position.
*/
const GRID: Record<Exclude<LocationPrecision, 'hidden'>, { step: number; decimals: number }> = {
exact: { step: 0, decimals: 6 },
city: { step: 0.05, decimals: 2 }, // ≈ 5 km
country: { step: 1, decimals: 0 }, // ≈ 100 km
};
/** Strictness order — later wins when albums disagree. */
const RANK: Record<LocationPrecision, number> = {
exact: 0,
city: 1,
country: 2,
hidden: 3,
};
/**
* The strictest level among the albums contributing to one marker.
*
* One marker merges several albums, so the cautious setting has to govern the
* whole marker. Splitting the marker instead would itself reveal that one of
* its albums is set more cautiously — which is the thing being hidden.
*
* An empty list is `hidden`: nothing contributed, so there is nothing to show.
*/
export function strictestPrecision(levels: readonly LocationPrecision[]): LocationPrecision {
if (levels.length === 0) return 'hidden';
return levels.reduce((worst, level) => (RANK[level] > RANK[worst] ? level : worst), 'exact');
}
/**
* Snap a coordinate to the level's grid.
*
* A fixed global grid, not a random offset per request: jitter could be
* averaged away by asking repeatedly, which would hand back the true position
* to anyone patient enough to ask twice.
*/
export function quantiseCoordinate(value: number, level: LocationPrecision): number {
if (level === 'hidden') return 0;
const { step, decimals } = GRID[level];
if (step === 0) return value;
return Number((Math.round(value / step) * step).toFixed(decimals));
}
/**
* Apply a precision level to a position. Returns null for `hidden`, which the
* caller must treat as "this marker does not exist" rather than "this marker
* is at 0,0".
*/
export function applyPrecision(
position: { lat: number; lng: number },
level: LocationPrecision,
): { lat: number; lng: number } | null {
if (level === 'hidden') return null;
return {
lat: quantiseCoordinate(position.lat, level),
lng: quantiseCoordinate(position.lng, level),
};
}
/**
* The place name a marker may carry at this precision.
*
* Quantising the coordinates is only half of withholding a position: the
* marker is also *named*, and a city name is far more precise than the 1° grid
* `country` snaps to. At `country` the name is reduced to the country, which
* is what the coordinates already say. `city` keeps it — a 0.05° cell is
* roughly what naming a city discloses anyway.
*
* `hidden` returns neither, so a caller that forgets to drop the marker
* entirely cannot leak the place through its label.
*/
export function placeLabel(
level: LocationPrecision,
place: { city: string; country: string },
): { city: string; country: string } {
if (level === 'hidden') return { city: '', country: '' };
if (level === 'country') return { city: '', country: place.country };
return { city: place.city, country: place.country };
}

36
lib/photoHash.ts Normal file
View File

@@ -0,0 +1,36 @@
/**
* The `#photo-N` permalink shared by the photo grid and the lightbox.
*
* The grid writes and restores it; the lightbox offers a button that copies it
* (#478). The format lives here so the two cannot drift apart.
*
* The link is positional, as `gallery.yaml.example` documents: it names a
* place in the album, not an asset, so reordering an album moves where a
* shared link lands.
*/
/** Parse `#photo-N`. Returns a 0-based index, or null if it is not one. */
export function parsePhotoHash(hash: string): number | null {
const match = hash.match(/^#photo-(\d+)$/);
if (!match) return null;
const index = parseInt(match[1], 10) - 1; // 1-indexed in the URL
return index >= 0 ? index : null;
}
/** The hash for a 0-based index, 1-indexed for the reader. */
export function buildPhotoHash(index: number): string {
return `#photo-${index + 1}`;
}
/**
* An absolute permalink to one photo.
*
* Takes the location parts rather than reading `window`, so it is pure and the
* caller does not depend on the grid's hash-sync effect having run yet.
*/
export function buildPhotoPermalink(
location: { origin: string; pathname: string; search: string },
index: number,
): string {
return `${location.origin}${location.pathname}${location.search}${buildPhotoHash(index)}`;
}

96
lib/publicPages.ts Normal file
View File

@@ -0,0 +1,96 @@
/**
* Which pages a crawler — or a feed reader — may be told about (#472, #471).
*
* This is the part that matters. A sitemap and a feed collect exactly what
* access control otherwise hides: titles, cover images, publication dates. It
* is a leak surface nobody notices, because it never shows up in the browser.
*
* The rules are kept here, as a pure function over a description of the site,
* so both surfaces answer the same question the same way and every exclusion
* can be stated as a test.
*/
export interface PublicAlbum {
id: string;
slug: string;
}
export interface PublicSubpage {
slug: string;
/** `enabled: false` in gallery.yaml — the subpage does not exist publicly. */
enabled: boolean;
/** `hidden: true` — reachable by direct link only, which a listing is not. */
hidden: boolean;
/** The subpage carries a password. */
isProtected: boolean;
albums: PublicAlbum[];
}
export interface PublicJournalEntry {
slug: string;
draft: boolean;
}
export interface SiteShape {
/** A global site password is on and no one is past it. */
siteLocked: boolean;
subpages: PublicSubpage[];
/** Albums that are not part of any subpage. */
standaloneAlbums: PublicAlbum[];
journal: PublicJournalEntry[];
/** Whether an album carries its own password. Keyed by Immich album id. */
isAlbumProtected: (albumId: string) => boolean;
aboutEnabled: boolean;
mapEnabled: boolean;
journalEnabled: boolean;
}
/**
* Every path that may appear in a public listing, in a stable order.
*
* A locked site yields nothing at all: the gate covers the whole site, so
* naming its pages would describe exactly what the password withholds.
*/
export function publicPaths(site: SiteShape): string[] {
if (site.siteLocked) return [];
const paths: string[] = ['/'];
if (site.aboutEnabled) paths.push('/about');
if (site.mapEnabled) paths.push('/map');
if (site.journalEnabled) paths.push('/journal');
for (const subpage of site.subpages) {
// A subpage that is off, unlisted, or behind a password is not a public
// page — and neither is anything underneath it. Inheritance is the case
// that is easy to miss: a public album under a protected subpage is not
// reachable, so listing it would advertise a URL that only asks for a
// password, and name the album while doing it.
if (!subpage.enabled || subpage.hidden || subpage.isProtected) continue;
paths.push(`/${subpage.slug}`);
for (const album of subpage.albums) {
if (site.isAlbumProtected(album.id)) continue;
paths.push(`/${subpage.slug}/${album.slug}`);
}
}
for (const album of site.standaloneAlbums) {
if (site.isAlbumProtected(album.id)) continue;
paths.push(`/${album.slug}`);
}
if (site.journalEnabled) {
for (const entry of site.journal) {
// Drafts stay visible to the logged-in admin and absent for everyone
// else; a listing is "everyone else".
if (entry.draft) continue;
paths.push(`/journal/${entry.slug}`);
}
}
// A slug collision between a subpage and a standalone album would otherwise
// emit the same URL twice.
return [...new Set(paths)];
}

50
lib/siteShape.ts Normal file
View File

@@ -0,0 +1,50 @@
/**
* Build the description of the site that `publicPaths()` reasons about (#472).
*
* Kept apart from the pure rules so the exclusions stay testable without a
* running Immich, and so the sitemap and the feed cannot answer the visibility
* question differently.
*/
import { getConfig } from './config';
import { immich } from './immich';
import { isProtected, isSiteLocked } from './auth';
import { listJournalEntries } from './admin/journal-service';
import type { SiteShape, PublicAlbum } from './publicPages';
export async function buildSiteShape(): Promise<SiteShape> {
const config = getConfig();
// Allowlisted albums only — getAlbums() already drops everything else, so an
// album that was never published cannot reach a listing through here.
const albums = await immich.getAlbums();
const byId = new Map(albums.map((album) => [album.id, album]));
const resolve = (ids: string[]): PublicAlbum[] =>
ids
.map((id) => byId.get(id))
.filter((album): album is NonNullable<typeof album> => Boolean(album))
.map((album) => ({ id: album.id, slug: album.slug }));
const journal = await listJournalEntries().catch(() => []);
return {
siteLocked: isSiteLocked(),
subpages: config.subpages.map((subpage) => ({
slug: subpage.slug,
enabled: subpage.enabled !== false,
hidden: subpage.hidden === true,
isProtected: isProtected(subpage.slug, 'subpage'),
albums: resolve(subpage.albumIds),
})),
standaloneAlbums: resolve(config.standaloneAlbums),
journal: journal.map((entry) => ({
slug: entry.slug,
draft: entry.frontmatter.draft === true,
})),
isAlbumProtected: (albumId) => isProtected(albumId, 'album'),
aboutEnabled: config.aboutEnabled,
mapEnabled: config.map,
// There is no switch for the journal; it exists when it has entries.
journalEnabled: journal.length > 0,
};
}

94
lib/siteUrl.ts Normal file
View File

@@ -0,0 +1,94 @@
/**
* The site's own absolute URL (#472).
*
* Sitemaps and feeds need absolute URLs, and `app/sitemap.ts` runs without a
* request context — so the host cannot be read off a header the way the rest of
* the app does it. It has to be configured.
*
* Two sources: `url:` in settings.yaml, editable in the admin panel, and
* `SITE_URL` in the environment as a fallback for anyone who would rather
* configure it there and never open the panel.
*
* settings.yaml wins. #472 proposed the opposite, on the grounds that
* `SITE_TITLE`/`SITE_SUBTITLE` already work that way — but they do not:
* lib/config/index.ts resolves them as `settings.title ?? env.SITE_TITLE`, so
* the file already takes precedence. Following that convention means the admin
* field always does what it appears to do, which is what the issue actually
* wanted; the alternative was to introduce a field that silently has no effect
* and then build a mechanism to explain it.
*/
/**
* Normalise a configured site URL, or return null if it cannot be used.
*
* Rejects anything that is not absolute http(s): a bare host, a `javascript:`
* URL or a typo would otherwise be emitted into a sitemap, a feed and a
* JSON-LD block, where a wrong value is worse than a missing one.
*
* Keeps a path (a portfolio may live under a sub-path) but drops the trailing
* slash, query and fragment, so callers can join with `/` without producing
* `//` or carrying a stray `?`.
*/
export function normaliseSiteUrl(raw: string | undefined | null): string | null {
const trimmed = (raw ?? '').trim();
if (!trimmed) return null;
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
return null;
}
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return null;
if (!parsed.hostname) return null;
const path = parsed.pathname.replace(/\/+$/, '');
return `${parsed.origin}${path}`;
}
/**
* The effective site URL, and where it came from. The origin is reported so
* the admin panel can tell the operator that the value it shows came from the
* environment rather than from the field they are looking at.
*/
export function resolveSiteUrl(
settingsUrl: string | undefined,
envUrl: string | undefined,
): { url: string | null; source: 'env' | 'settings' | 'none' } {
const fromSettings = normaliseSiteUrl(settingsUrl);
if (fromSettings) return { url: fromSettings, source: 'settings' };
const fromEnv = normaliseSiteUrl(envUrl);
if (fromEnv) return { url: fromEnv, source: 'env' };
return { url: null, source: 'none' };
}
/**
* Join the site URL with a path, for a sitemap entry or a feed link.
*
* Returns null without a site URL: a relative link in a sitemap is invalid and
* a relative link in a feed points at the reader, so there is nothing sensible
* to fall back to.
*/
export function absoluteUrl(siteUrl: string | null, path: string): string | null {
if (!siteUrl) return null;
const raw = path.startsWith('/') ? path : `/${path}`;
const suffix = raw === '/' ? '/' : raw.replace(/\/+$/, '');
return `${siteUrl}${encodePathBytes(suffix)}`;
}
/**
* Percent-encode exactly what a URI may not carry: anything outside printable
* ASCII. Album slugs keep their own script since #522, so a path can now hold
* characters a sitemap, a canonical link or a JSON-LD block must not.
*
* Deliberately not `encodeURI()`, which would also re-encode the `%` of an
* already-encoded path and corrupt it, and deliberately not `new URL(path,
* siteUrl)`, which would drop the sub-path a portfolio may be published under
* — the one thing `normaliseSiteUrl()` goes out of its way to keep.
*/
function encodePathBytes(path: string): string {
return path.replace(/[^\x20-\x7E]/g, (character) => encodeURIComponent(character));
}

24
lib/slideshow.ts Normal file
View File

@@ -0,0 +1,24 @@
/**
* Slideshow speeds for the lightbox (#473).
*
* Three speeds and off, cycled by one key, so a gallery can be left running at
* an exhibition, a fair booth or on a second screen without the viewer growing
* a control of its own.
*/
/** Seconds between advances, slowest last. `null` is off. */
export const SLIDESHOW_SPEEDS = [null, 3, 5, 10] as const;
export type SlideshowSpeed = (typeof SLIDESHOW_SPEEDS)[number];
/**
* The next speed in the cycle: off → 3s → 5s → 10s → off.
*
* An unrecognised value returns the first speed, so the cycle can always be
* re-entered rather than sticking.
*/
export function nextSlideshowSpeed(current: SlideshowSpeed): SlideshowSpeed {
const position = SLIDESHOW_SPEEDS.indexOf(current);
if (position === -1) return SLIDESHOW_SPEEDS[1];
return SLIDESHOW_SPEEDS[(position + 1) % SLIDESHOW_SPEEDS.length];
}

60
lib/structuredData.ts Normal file
View File

@@ -0,0 +1,60 @@
/**
* The JSON-LD payload for an album page (#472).
*
* `ImageGallery` with an `ImageObject` for the cover is the annotation that
* pays off for a photography portfolio: it is what a search result can show,
* and it is where `creator` and `license` belong.
*
* Built as a pure function so the shape can be asserted without rendering, and
* so the "no site URL, no structured data" rule is stated once.
*/
export interface AlbumStructuredDataInput {
siteUrl: string | null;
/** Absolute URL of the album page. */
pageUrl: string | null;
albumName: string;
description?: string;
/** Absolute URL of the cover image, if there is one. */
coverUrl?: string | null;
/** The photographer, for `creator`. */
creator?: string;
/** A licence URL or short identifier, e.g. a Creative Commons link. */
license?: string;
photoCount?: number;
}
export function albumStructuredData(
input: AlbumStructuredDataInput,
): Record<string, unknown> | null {
// Structured data is a set of claims about URLs. Without an absolute site URL
// there is nothing truthful to claim, so nothing is emitted.
if (!input.siteUrl || !input.pageUrl) return null;
if (!input.albumName.trim()) return null;
const creator = input.creator?.trim()
? { '@type': 'Person', name: input.creator.trim() }
: undefined;
const license = input.license?.trim() || undefined;
return {
'@context': 'https://schema.org',
'@type': 'ImageGallery',
name: input.albumName.trim(),
url: input.pageUrl,
...(input.description?.trim() ? { description: input.description.trim() } : {}),
...(typeof input.photoCount === 'number' ? { numberOfItems: input.photoCount } : {}),
...(creator ? { creator } : {}),
...(license ? { license } : {}),
...(input.coverUrl
? {
image: {
'@type': 'ImageObject',
contentUrl: input.coverUrl,
...(creator ? { creator } : {}),
...(license ? { license } : {}),
},
}
: {}),
};
}

134
lib/updateCheck.ts Normal file
View File

@@ -0,0 +1,134 @@
/**
* Update notice for the admin dashboard (#496).
*
* Self-hosted software that never mentions a new release gets run for months
* on an old version, which matters here because there have already been
* security releases.
*
* The check is a single call to the GitHub releases API, answered from a
* day-long cache, and switchable off with `UPDATE_CHECK=false`. It fails
* quietly in every direction: no network, a rate limit, a malformed answer or
* an offline instance must never cost the dashboard its other information.
*/
import { env } from './env';
import pkg from '../package.json';
/** The version this instance is running. */
export const CURRENT_VERSION: string = pkg.version;
const RELEASES_URL = 'https://api.github.com/repos/ralksta/immich-folio/releases/latest';
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const REQUEST_TIMEOUT_MS = 5000;
export interface UpdateStatus {
current: string;
latest: string | null;
/** Null when the check is off, or nothing is known yet. */
updateAvailable: boolean | null;
}
/**
* Compare two dot-separated versions numerically.
*
* Returns a negative number when `a` is older, positive when newer, 0 when
* equal. A leading `v` is tolerated because that is how the releases API spells
* a tag. Anything after a `-` is dropped: a pre-release is compared on its
* numbers alone, which is enough to decide whether to mention it.
*/
export function compareVersions(a: string, b: string): number {
const parts = (version: string) =>
version
.trim()
.replace(/^v/i, '')
.split('+')[0]
.split('-')[0]
.split('.')
.map((part) => Number.parseInt(part, 10) || 0);
const left = parts(a);
const right = parts(b);
const length = Math.max(left.length, right.length);
for (let i = 0; i < length; i++) {
const difference = (left[i] ?? 0) - (right[i] ?? 0);
if (difference !== 0) return difference;
}
return 0;
}
/** Whether `latest` is worth telling the operator about. */
export function isNewerVersion(latest: string, current: string): boolean {
return compareVersions(latest, current) > 0;
}
/**
* Pull the tag out of a releases API payload.
*
* Written against `unknown` because this is a third-party response: a shape
* that changed, or an error object served with a 200, must yield null rather
* than throw inside the dashboard.
*/
export function parseLatestTag(payload: unknown): string | null {
if (typeof payload !== 'object' || payload === null) return null;
const tag = (payload as { tag_name?: unknown }).tag_name;
if (typeof tag !== 'string') return null;
const trimmed = tag.trim();
// A tag that is not a version cannot be compared, and guessing would be
// worse than saying nothing. Anchored at both ends: guarding only the start
// let anything beginning with a digit through, and a date-style tag then
// read as version 2024 and left every instance showing an update that does
// not exist. A pre-release suffix is allowed but may not itself contain a
// '-', which is what keeps "2024-01-05" out; build metadata is allowed and
// ignored when ordering, as semver says.
return /^v?\d+(\.\d+)*(-[0-9A-Za-z.]+)?(\+[0-9A-Za-z.]+)?$/.test(trimmed) ? trimmed : null;
}
let cached: { latest: string | null; at: number } | null = null;
/** Drop the memoised answer. Exposed for tests. */
export function resetUpdateCache(): void {
cached = null;
}
/**
* The latest published version, or null if it is not known.
*
* Cached for a day whether the answer was a version or a failure, so a
* dashboard left open does not hammer the API and an outage is not retried on
* every page load.
*/
async function fetchLatestVersion(): Promise<string | null> {
if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.latest;
let latest: string | null = null;
try {
const response = await fetch(RELEASES_URL, {
headers: { Accept: 'application/vnd.github+json' },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
cache: 'no-store',
});
if (response.ok) latest = parseLatestTag(await response.json());
} catch {
// Offline, blocked, rate-limited or timed out. The dashboard simply does
// not mention updates this time.
latest = null;
}
cached = { latest, at: Date.now() };
return latest;
}
/** The update status for the admin dashboard. Never throws. */
export async function getUpdateStatus(): Promise<UpdateStatus> {
if (!env.UPDATE_CHECK) {
return { current: CURRENT_VERSION, latest: null, updateAvailable: null };
}
const latest = await fetchLatestVersion();
return {
current: CURRENT_VERSION,
latest,
updateAvailable: latest === null ? null : isNewerVersion(latest, CURRENT_VERSION),
};
}

View File

@@ -96,6 +96,37 @@ export function assetExifSummary(asset: Pick<ImmichAsset, 'exifInfo'>): ExifSumm
return { camera, lens, focalLength };
}
/**
* The download URL for an original file (#475).
*
* Carries the album as well as the asset: the route authorises the download
* against the album that offered it, and checks the asset really belongs to
* that album.
*/
export function downloadUrl(albumId: string, assetId: string): string {
return `/api/download/${encodeAssetId(albumId)}/${encodeAssetId(assetId)}`;
}
/**
* The Immich asset description, trimmed, for use as image alt text.
*
* Gated on the `caption` EXIF group rather than served unconditionally. That
* switch exists because the description is the one field that can hold private
* notes (#506), and an alt attribute publishes it to crawlers and view-source
* exactly as a visible caption would. A site that has switched captions off has
* said it does not want that text on the page.
*
* Returns undefined when there is nothing to say, which leaves `alt=""` — the
* correct markup for a decorative image, and better than inventing filler.
*/
export function assetCaption(
asset: Pick<ImmichAsset, 'exifInfo'>,
showCaption: boolean,
): string | undefined {
if (!showCaption) return undefined;
return asset.exifInfo?.description?.trim() || undefined;
}
/**
* Compute the natural aspect ratio (width / height) from EXIF dimensions.
* Returns undefined if dimensions are not available.