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:
58
lib/__tests__/albumNav.test.ts
Normal file
58
lib/__tests__/albumNav.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
89
lib/__tests__/assetLocation.test.ts
Normal file
89
lib/__tests__/assetLocation.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
79
lib/__tests__/derive-location.test.ts
Normal file
79
lib/__tests__/derive-location.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
87
lib/__tests__/downloadName.test.ts
Normal file
87
lib/__tests__/downloadName.test.ts
Normal 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"');
|
||||
});
|
||||
});
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
135
lib/__tests__/lightboxShortcuts.test.ts
Normal file
135
lib/__tests__/lightboxShortcuts.test.ts
Normal 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_/);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
132
lib/__tests__/mapPrecision.test.ts
Normal file
132
lib/__tests__/mapPrecision.test.ts
Normal 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: '' });
|
||||
});
|
||||
});
|
||||
58
lib/__tests__/photoHash.test.ts
Normal file
58
lib/__tests__/photoHash.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
168
lib/__tests__/publicPages.test.ts
Normal file
168
lib/__tests__/publicPages.test.ts
Normal 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(/\/\//);
|
||||
}
|
||||
});
|
||||
});
|
||||
150
lib/__tests__/siteUrl.test.ts
Normal file
150
lib/__tests__/siteUrl.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
29
lib/__tests__/slideshow.test.ts
Normal file
29
lib/__tests__/slideshow.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
90
lib/__tests__/structuredData.test.ts
Normal file
90
lib/__tests__/structuredData.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
73
lib/__tests__/subpage-cover-grid.test.ts
Normal file
73
lib/__tests__/subpage-cover-grid.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
90
lib/__tests__/updateCheck.test.ts
Normal file
90
lib/__tests__/updateCheck.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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
65
lib/albumNav.ts
Normal 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
58
lib/assetLocation.ts
Normal 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);
|
||||
}
|
||||
40
lib/auth.ts
40
lib/auth.ts
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
69
lib/downloadName.ts
Normal 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)}`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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
171
lib/lightboxShortcuts.ts
Normal 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
107
lib/mapPrecision.ts
Normal 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
36
lib/photoHash.ts
Normal 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
96
lib/publicPages.ts
Normal 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
50
lib/siteShape.ts
Normal 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
94
lib/siteUrl.ts
Normal 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
24
lib/slideshow.ts
Normal 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
60
lib/structuredData.ts
Normal 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
134
lib/updateCheck.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
31
lib/urls.ts
31
lib/urls.ts
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user