🛡️ Sentinel: [HIGH] Fix timingSafeEqual DoS Vulnerability (#423)

* Fix timingSafeEqual DoS Vulnerability in Webhook

Co-authored-by: ralksta <16941050+ralksta@users.noreply.github.com>

* Fix memory exhaustion DoS in token decoding

Co-authored-by: ralksta <16941050+ralksta@users.noreply.github.com>

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
This commit is contained in:
Ralksta
2026-08-14 11:10:42 +02:00
committed by GitHub
parent f120f05157
commit d4d8c8b42f
20 changed files with 1574 additions and 221 deletions

View File

@@ -0,0 +1,211 @@
import { describe, it, expect } from 'vitest';
import {
ALBUM_SORT_MODES,
isAlbumSortMode,
sortAlbumAssets,
type AlbumSortMode,
type SortableAsset,
} from '@/lib/albumSort';
function asset(id: string, overrides: Partial<SortableAsset> = {}): SortableAsset {
return {
id,
originalFileName: `${id}.jpg`,
fileCreatedAt: '2024-01-01T00:00:00.000Z',
localDateTime: '2024-01-01T00:00:00.000Z',
...overrides,
};
}
const ids = (assets: SortableAsset[]) => assets.map((a) => a.id);
describe('sortAlbumAssets is pure', () => {
it('returns a new array and leaves the input untouched', () => {
const input = [
asset('b', { localDateTime: '2024-02-01T00:00:00.000Z' }),
asset('a', { localDateTime: '2024-01-01T00:00:00.000Z' }),
];
const before = ids(input);
const out = sortAlbumAssets(input, { mode: 'oldest' });
expect(out).not.toBe(input);
expect(ids(input)).toEqual(before);
});
// The cached album is shared, so any mode that dropped or duplicated an asset
// would corrupt what every later request sees. This is the guard that any
// future rewrite of the manual branch has to survive.
it.each(ALBUM_SORT_MODES)('preserves the exact set of assets under %s', (mode) => {
const input = [asset('a'), asset('b'), asset('c'), asset('d')];
const out = sortAlbumAssets(input, {
mode: mode as AlbumSortMode,
immichOrder: 'asc',
manualOrder: ['c', 'zzz'],
});
expect(out).toHaveLength(input.length);
expect(ids(out).sort()).toEqual(['a', 'b', 'c', 'd']);
});
});
describe('capture-time modes', () => {
const jan = asset('jan', { localDateTime: '2024-01-01T00:00:00.000Z' });
const feb = asset('feb', { localDateTime: '2024-02-01T00:00:00.000Z' });
const mar = asset('mar', { localDateTime: '2024-03-01T00:00:00.000Z' });
it('orders newest first', () => {
expect(ids(sortAlbumAssets([jan, mar, feb], { mode: 'newest' }))).toEqual([
'mar',
'feb',
'jan',
]);
});
it('orders oldest first, exactly reversing newest', () => {
const input = [jan, mar, feb];
expect(ids(sortAlbumAssets(input, { mode: 'oldest' }))).toEqual(
ids(sortAlbumAssets(input, { mode: 'newest' })).reverse(),
);
});
it('falls back to fileCreatedAt when localDateTime ties', () => {
const early = asset('early', {
localDateTime: '2024-01-01T00:00:00.000Z',
fileCreatedAt: '2024-01-01T08:00:00.000Z',
});
const late = asset('late', {
localDateTime: '2024-01-01T00:00:00.000Z',
fileCreatedAt: '2024-01-01T20:00:00.000Z',
});
expect(ids(sortAlbumAssets([late, early], { mode: 'oldest' }))).toEqual(['early', 'late']);
});
// Date.parse() on garbage yields NaN, and a comparator returning NaN makes
// sort() incoherent — the result depends on the engine's algorithm, not the data.
it('treats an unparseable date as epoch rather than producing NaN', () => {
const broken = asset('broken', { localDateTime: 'not-a-date', fileCreatedAt: 'also-not' });
const out = sortAlbumAssets([feb, broken, jan], { mode: 'oldest' });
expect(ids(out)).toEqual(['broken', 'jan', 'feb']);
});
it('immich follows the album direction', () => {
const input = [feb, jan, mar];
expect(ids(sortAlbumAssets(input, { mode: 'immich', immichOrder: 'asc' }))).toEqual([
'jan',
'feb',
'mar',
]);
expect(ids(sortAlbumAssets(input, { mode: 'immich', immichOrder: 'desc' }))).toEqual([
'mar',
'feb',
'jan',
]);
});
});
describe('filename mode', () => {
it('collates numerically, so IMG_2 precedes IMG_10', () => {
const input = [
asset('c', { originalFileName: 'IMG_10.jpg' }),
asset('a', { originalFileName: 'IMG_2.jpg' }),
asset('b', { originalFileName: 'IMG_9.jpg' }),
];
expect(ids(sortAlbumAssets(input, { mode: 'filename' }))).toEqual(['a', 'b', 'c']);
});
it('is case-insensitive', () => {
const input = [
asset('b', { originalFileName: 'beta.jpg' }),
asset('a', { originalFileName: 'Alpha.jpg' }),
];
expect(ids(sortAlbumAssets(input, { mode: 'filename' }))).toEqual(['a', 'b']);
});
// Duplicate filenames across import batches are common; without the id
// tiebreak the order of a tie would follow the input and drift per request.
it('breaks ties on id so the order is stable', () => {
const one = asset('aaa', { originalFileName: 'DSC.jpg' });
const two = asset('bbb', { originalFileName: 'dsc.jpg' });
expect(ids(sortAlbumAssets([two, one], { mode: 'filename' }))).toEqual(['aaa', 'bbb']);
expect(ids(sortAlbumAssets([one, two], { mode: 'filename' }))).toEqual(['aaa', 'bbb']);
});
});
describe('manual mode is a pinned prefix', () => {
const a = asset('a', { localDateTime: '2024-01-01T00:00:00.000Z' });
const b = asset('b', { localDateTime: '2024-02-01T00:00:00.000Z' });
const c = asset('c', { localDateTime: '2024-03-01T00:00:00.000Z' });
it('puts pinned assets first, in the pinned order', () => {
const out = sortAlbumAssets([a, b, c], {
mode: 'manual',
immichOrder: 'asc',
manualOrder: ['c', 'a'],
});
expect(ids(out)).toEqual(['c', 'a', 'b']);
});
it('appends unpinned assets in the album Immich order', () => {
const desc = sortAlbumAssets([a, b, c], {
mode: 'manual',
immichOrder: 'desc',
manualOrder: ['a'],
});
const asc = sortAlbumAssets([a, b, c], {
mode: 'manual',
immichOrder: 'asc',
manualOrder: ['a'],
});
expect(ids(desc)).toEqual(['a', 'c', 'b']);
expect(ids(asc)).toEqual(['a', 'b', 'c']);
});
// The album can change in Immich after the order was saved; a stale id must
// not shift or drop anything.
it('ignores pinned ids that are no longer in the album', () => {
const out = sortAlbumAssets([a, b], {
mode: 'manual',
immichOrder: 'asc',
manualOrder: ['gone', 'b'],
});
expect(ids(out)).toEqual(['b', 'a']);
});
it('pins a duplicated id once, at its first position', () => {
const out = sortAlbumAssets([a, b, c], {
mode: 'manual',
immichOrder: 'asc',
manualOrder: ['c', 'a', 'c'],
});
expect(ids(out)).toEqual(['c', 'a', 'b']);
});
it('falls back to the album order when nothing is pinned', () => {
const input = [c, a, b];
expect(ids(sortAlbumAssets(input, { mode: 'manual', immichOrder: 'asc' }))).toEqual(
ids(sortAlbumAssets(input, { mode: 'immich', immichOrder: 'asc' })),
);
});
});
describe('isAlbumSortMode', () => {
it('accepts every declared mode and nothing else', () => {
for (const mode of ALBUM_SORT_MODES) expect(isAlbumSortMode(mode)).toBe(true);
expect(isAlbumSortMode('bogus')).toBe(false);
expect(isAlbumSortMode(undefined)).toBe(false);
expect(isAlbumSortMode(null)).toBe(false);
});
});

View File

@@ -76,6 +76,27 @@ describe('gallery.yaml per-album heroImage', () => {
expect(getConfig().albumHeroImages[ALBUM_ID]).toBe(HERO_ID);
});
// Same class of ID, same requirement: a pinned asset ID from `sort: manual`
// is written by the admin panel and can be hand-edited, so it goes through
// the same gate. A substituted zero UUID simply never matches an asset in the
// album, so the pin is ignored rather than corrupting the order.
it('routes manual assetOrder IDs through validateUuid, naming the album', () => {
vi.mocked(loadYaml).mockImplementation((filename: string) => {
if (filename === 'gallery.yaml') {
return { albums: [{ [ALBUM_ID]: { sort: 'manual', assetOrder: [HERO_ID] } }] };
}
if (filename === 'settings.yaml') return {};
return null;
});
invalidateConfigCache();
const config = getConfig();
expect(config.albumManualOrders[ALBUM_ID]).toEqual([HERO_ID]);
const call = validateUuidSpy.mock.calls.find((c) => c[0] === HERO_ID);
expect(call?.[1]).toContain(ALBUM_ID);
});
// The substitution the wiring above depends on: validateUuid never throws, so
// a bad ID degrades to a 404 image rather than taking the whole page down.
it('real validateUuid warns and substitutes rather than throwing', async () => {

View File

@@ -104,6 +104,77 @@ describe('deriveGallery accepts valid structures', () => {
expect(result.albumHeroImages[A]).toBe(B);
});
it('collects the per-album sort mode', () => {
const result = deriveGallery({
albums: [{ [A]: { title: 'Series', sort: 'filename' } }, B],
} as unknown as GalleryYaml);
expect(result.albumSortModes[A]).toBe('filename');
// Absent means the default; an explicit entry would make `immich` and
// "never configured" indistinguishable downstream.
expect(result.albumSortModes[B]).toBeUndefined();
});
// A typo would otherwise be invisible: the album would keep rendering in
// Immich order and nothing would say why. Throwing is also what gives the
// admin PUT its 400, via the deriveGallery dry-run.
it('rejects an unknown sort mode, naming the album and the valid values', () => {
expect(() =>
deriveGallery({ albums: [{ [A]: { sort: 'alphabetical' } }] } as unknown as GalleryYaml),
).toThrow(/alphabetical/);
expect(() =>
deriveGallery({ albums: [{ [A]: { sort: 'alphabetical' } }] } as unknown as GalleryYaml),
).toThrow(new RegExp(A));
expect(() =>
deriveGallery({ albums: [{ [A]: { sort: 'alphabetical' } }] } as unknown as GalleryYaml),
).toThrow(/filename/);
});
it('collects the manual asset order, preserving its order', () => {
const result = deriveGallery({
albums: [{ [A]: { sort: 'manual', assetOrder: [B, A] } }],
} as unknown as GalleryYaml);
expect(result.albumManualOrders[A]).toEqual([B, A]);
});
// Kept regardless of the mode, so toggling manual → newest → manual in the
// admin panel does not silently destroy a hand-curated order.
it('keeps the asset order even when the mode is not manual', () => {
const result = deriveGallery({
albums: [{ [A]: { sort: 'newest', assetOrder: [B] } }],
} as unknown as GalleryYaml);
expect(result.albumSortModes[A]).toBe('newest');
expect(result.albumManualOrders[A]).toEqual([B]);
});
it('ignores an empty asset order rather than storing one', () => {
const result = deriveGallery({
albums: [{ [A]: { sort: 'manual', assetOrder: [] } }],
} as unknown as GalleryYaml);
expect(result.albumManualOrders[A]).toBeUndefined();
});
// processAlbumEntry is reached from four places; only the array-subpage path
// was covered before, so a regression in the others would go unnoticed.
it('applies the sort mode to section and map-style subpage albums too', () => {
const sectioned = deriveGallery({
subpages: [
{ name: 'Work', sections: [{ title: 'Recent', albums: [{ [A]: { sort: 'oldest' } }] }] },
],
} as unknown as GalleryYaml);
expect(sectioned.albumSortModes[A]).toBe('oldest');
const mapStyle = deriveGallery({
subpages: { Trips: { albums: [{ [B]: { sort: 'filename' } }] } },
} as unknown as GalleryYaml);
expect(mapStyle.albumSortModes[B]).toBe('filename');
});
it('parses enabled property on subpages correctly', () => {
const result = deriveGallery({
subpages: [

View File

@@ -2,6 +2,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { immich, ImmichUnavailableError } from '../immich';
import * as config from '../config';
import { cache } from '../cache';
import type { AlbumSortMode } from '../albumSort';
// Per-album sort is read on every getAlbum(), so the tests below need to be
// able to change it between calls without rebuilding the whole mock.
const albumSortModes: Record<string, AlbumSortMode> = {};
const albumManualOrders: Record<string, string[]> = {};
// Mock the config by wrapping it in a factory
vi.mock('../config', async () => {
@@ -16,6 +22,8 @@ vi.mock('../config', async () => {
subpages: [],
albumOverrides: { 'album-1': 'Override Name' },
albumDescriptions: {},
albumSortModes,
albumManualOrders,
// Non-zero: a 0 TTL expires an entry in the same millisecond it is
// written, which would make any caching assertion flaky. Isolation comes
// from the cache.clear() in beforeEach, not from an unusable TTL.
@@ -37,6 +45,8 @@ describe('ImmichClient', () => {
// getAlbum() memoises into the module-level LRU; without this, later tests
// read a previous test's album and never reach the mocked fetch.
cache.clear();
for (const key of Object.keys(albumSortModes)) delete albumSortModes[key];
for (const key of Object.keys(albumManualOrders)) delete albumManualOrders[key];
});
describe('request()', () => {
@@ -579,6 +589,69 @@ describe('ImmichClient', () => {
const album = await immich.getAlbum('album-1');
expect(album?.assets.map((a) => a.id)).toEqual(['frame-1', 'frame-2', 'frame-3']);
});
/**
* The configured sort is applied on the way out of getAlbum(), so the LRU
* keeps one canonical Immich-ordered copy per album. Sorting the cached
* array in place would reorder the shared entry, and the next reader with a
* different mode would then be sorting an already-sorted array.
*/
describe('per-album sort', () => {
const items = [
{ ...asset('b', '2024-02-01T00:00:00Z'), originalFileName: 'IMG_2.jpg' },
{ ...asset('c', '2024-03-01T00:00:00Z'), originalFileName: 'IMG_10.jpg' },
{ ...asset('a', '2024-01-01T00:00:00Z'), originalFileName: 'IMG_1.jpg' },
];
it('overrides the Immich direction', async () => {
albumSortModes['album-1'] = 'oldest';
mockAlbumApi({ order: 'desc', pages: [{ items, nextPage: null }] });
const album = await immich.getAlbum('album-1');
expect(album?.assets.map((a) => a.id)).toEqual(['a', 'b', 'c']);
});
it('applies the manual pin order', async () => {
albumSortModes['album-1'] = 'manual';
albumManualOrders['album-1'] = ['c'];
mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
const album = await immich.getAlbum('album-1');
expect(album?.assets.map((a) => a.id)).toEqual(['c', 'a', 'b']);
});
it('leaves the cached album in canonical order', async () => {
albumSortModes['album-1'] = 'filename';
mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
const first = await immich.getAlbum('album-1');
expect(first?.assets.map((a) => a.id)).toEqual(['a', 'b', 'c']); // IMG_1, IMG_2, IMG_10
// Same cache entry, different mode: if the first call had sorted the
// stored array, the album would still be in filename order here.
albumSortModes['album-1'] = 'newest';
const second = await immich.getAlbum('album-1');
expect(second?.assets.map((a) => a.id)).toEqual(['c', 'b', 'a']);
expect(second?.assets).not.toBe(first?.assets);
});
it('gives concurrent callers independent arrays', async () => {
albumSortModes['album-1'] = 'manual';
albumManualOrders['album-1'] = ['b'];
mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
// Coalesced through the same pending promise, but each caller applies
// the sort itself — so neither can be handed the other's array.
const [one, two] = await Promise.all([
immich.getAlbum('album-1'),
immich.getAlbum('album-1'),
]);
expect(one?.assets.map((a) => a.id)).toEqual(['b', 'a', 'c']);
expect(two?.assets.map((a) => a.id)).toEqual(['b', 'a', 'c']);
expect(one?.assets).not.toBe(two?.assets);
});
});
});
});

148
lib/albumSort.ts Normal file
View File

@@ -0,0 +1,148 @@
/**
* Per-album asset ordering.
*
* Album order is Immich's by default — a Folio album mirrors the Immich
* timeline. A portfolio often needs something else: a curated series has a
* narrative order that has nothing to do with capture time, and changing the
* sort on the Immich album would change it for the archive too.
*
* Everything here is pure and never sorts in place. `lib/immich.ts` applies
* these *after* the cache read, so the LRU keeps exactly one canonical
* Immich-ordered copy per album; sorting the cached array directly would
* reorder the shared entry, and the next reader with a different mode would
* then sort an already-sorted array.
*/
/**
* The fields ordering actually needs. Deliberately structural rather than
* `ImmichAsset`: the admin reorder endpoint works on a lighter projection and
* must produce a baseline byte-identical to the site's, and depending on the
* narrow shape keeps this module free of any import from `lib/immich.ts`.
*/
export interface SortableAsset {
id: string;
originalFileName: string;
fileCreatedAt: string;
localDateTime?: string;
}
export const ALBUM_SORT_MODES = ['immich', 'newest', 'oldest', 'filename', 'manual'] as const;
export type AlbumSortMode = (typeof ALBUM_SORT_MODES)[number];
export const DEFAULT_ALBUM_SORT: AlbumSortMode = 'immich';
export function isAlbumSortMode(value: unknown): value is AlbumSortMode {
return typeof value === 'string' && (ALBUM_SORT_MODES as readonly string[]).includes(value);
}
/**
* Mirrors Immich's own timeline query: primary key `localDateTime` (capture
* time in the photographer's local zone), tie-broken by `fileCreatedAt` (the
* same instant in UTC). The two only diverge for albums spanning time zones,
* and there local time is what the Immich UI shows. Sorting on
* `exifInfo.dateTimeOriginal` instead would be near-pointless: Immich already
* derives `fileCreatedAt` from that same EXIF chain, and it is null for assets
* without EXIF.
*
* Compare parsed instants, not strings — string order breaks on fractional
* seconds ('.' collates before '+'), which misorders bursts where only some
* frames carry sub-second precision. Unparseable dates fall back to 0 rather
* than NaN, which would make sort() incoherent.
*/
export function compareByCaptureTime(dir: 1 | -1) {
const ts = (value: string | undefined) => Date.parse(value ?? '') || 0;
return (a: SortableAsset, b: SortableAsset): number =>
dir * (ts(a.localDateTime) - ts(b.localDateTime)) ||
dir * (ts(a.fileCreatedAt) - ts(b.fileCreatedAt));
}
/**
* Natural collation, so `IMG_2.jpg` precedes `IMG_10.jpg` — this is the point
* of the mode, and it is what scanned-film and camera-export workflows encode
* their intended order in.
*/
const filenameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
function compareByFilename(a: SortableAsset, b: SortableAsset): number {
// Tie-break on id: `sensitivity: 'base'` treats "a.jpg" and "A.jpg" as equal,
// and duplicate filenames across import batches are common. Without this the
// order of a tie would depend on the input order and drift between requests.
return filenameCollator.compare(a.originalFileName, b.originalFileName) || (a.id < b.id ? -1 : 1);
}
export interface AlbumSortOptions {
mode: AlbumSortMode;
/** The Immich album's own sort direction, used by `immich` and as the fallback. */
immichOrder?: 'asc' | 'desc';
/** Pinned asset IDs for `manual`, in the order they should appear. */
manualOrder?: string[];
}
/**
* `manual` is a pinned *prefix*, not a full permutation: `manualOrder` lists
* only the assets the owner placed by hand, and everything else follows in the
* album's Immich order.
*
* That keeps `gallery.yaml` small — a full permutation of a 2,000-photo album
* is ~80 KB of UUIDs in a file that is rewritten and copied to `content/.backups/`
* on every admin save — and it makes drift handling fall out for free: assets
* removed from the album in Immich simply never match, and new ones land in the
* tail rather than being hidden.
*
* New assets go last, not first. Choosing `manual` is an explicit opt-out of
* chronology, and prepending would silently change position 1 — the featured
* tile in the showcase layout, and the album's visual opener. A surprise at the
* end of a curated sequence is recoverable; one at the top is a regression the
* owner sees on the homepage.
*/
export function sortAlbumAssets<T extends SortableAsset>(
assets: T[],
{ mode, immichOrder, manualOrder }: AlbumSortOptions,
): T[] {
const dir: 1 | -1 = immichOrder === 'asc' ? 1 : -1;
switch (mode) {
case 'newest':
return [...assets].sort(compareByCaptureTime(-1));
case 'oldest':
return [...assets].sort(compareByCaptureTime(1));
case 'filename':
return [...assets].sort(compareByFilename);
case 'manual': {
if (!manualOrder?.length) return [...assets].sort(compareByCaptureTime(dir));
// Rank lookup rather than indexOf-per-comparison: an album with a few
// thousand assets would otherwise be quadratic. First occurrence wins, so
// a duplicated id in the YAML pins the asset once instead of dropping it.
const rank = new Map<string, number>();
manualOrder.forEach((id, i) => {
if (!rank.has(id)) rank.set(id, i);
});
const pinned: T[] = [];
const rest: T[] = [];
for (const asset of assets) {
(rank.has(asset.id) ? pinned : rest).push(asset);
}
pinned.sort((a, b) => rank.get(a.id)! - rank.get(b.id)!);
// Order the tail explicitly rather than trusting the caller to hand over
// an already-canonical array. `getAlbum()` does, but the guarantee should
// not live outside this function.
rest.sort(compareByCaptureTime(dir));
return [...pinned, ...rest];
}
// `immich` is not a fourth algorithm — Immich albums only carry an
// asc/desc flag, so inheriting it is capture-time order in that direction.
// It means "inherit from Immich", not "the drag order in the Immich web
// UI": that order is not exposed by the metadata search this client uses.
case 'immich':
default:
return [...assets].sort(compareByCaptureTime(dir));
}
}

View File

@@ -2,9 +2,11 @@ import { env } from '../env';
import { resolveAuthSecret } from '../secret';
import { loadYaml, clearYamlCache, validateUuid } from './parser';
import { resolveTheme, VALID_LAYOUTS } from './theme';
import { ALBUM_SORT_MODES, isAlbumSortMode, type AlbumSortMode } from '../albumSort';
import {
slugify,
AppConfig,
AlbumEntryObject,
SubpageConfig,
SubpageSectionConfig,
SubpageObjectValue,
@@ -82,6 +84,8 @@ export interface GalleryDerivation {
albumDescriptions: Record<string, string>;
albumPasswords: Record<string, string>;
albumHeroImages: Record<string, string>;
albumSortModes: Record<string, AlbumSortMode>;
albumManualOrders: Record<string, string[]>;
}
/**
@@ -99,14 +103,11 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
const albumDescriptions: Record<string, string> = {};
const albumPasswords: Record<string, string> = {};
const albumHeroImages: Record<string, string> = {};
const albumSortModes: Record<string, AlbumSortMode> = {};
const albumManualOrders: Record<string, string[]> = {};
function processAlbumEntry(
entry:
| string
| Record<
string,
string | { title: string; description?: string; password?: string; heroImage?: string }
>,
entry: string | Record<string, string | AlbumEntryObject>,
context: string,
): string {
if (typeof entry === 'string') {
@@ -133,6 +134,31 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
`${context} heroImage for album ${validatedUuid}`,
);
}
// Throw rather than fall back to the default: a typo would otherwise be
// invisible — the album would just keep rendering in Immich order and the
// owner would be left wondering why the setting does nothing. Throwing
// also gives the admin PUT its 400 for free via the deriveGallery
// dry-run, and getConfigOrNull() degrades a hand-edited file to the setup
// screen rather than taking the site down. Name the album and the legal
// values so the log line is enough to fix it.
if (value.sort != null) {
if (!isAlbumSortMode(value.sort)) {
throw new Error(
`${context}: album ${validatedUuid} has an unknown sort "${value.sort}". ` +
`Valid values are: ${ALBUM_SORT_MODES.join(', ')}.`,
);
}
albumSortModes[validatedUuid] = value.sort;
}
// Kept regardless of the mode, so switching manual → newest → manual does
// not silently destroy a hand-curated order.
if (value.assetOrder?.length) {
albumManualOrders[validatedUuid] = value.assetOrder.map((assetId, i) =>
validateUuid(assetId, `${context} assetOrder[${i}] for album ${validatedUuid}`),
);
}
}
return validatedUuid;
}
@@ -240,6 +266,8 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
albumDescriptions,
albumPasswords,
albumHeroImages,
albumSortModes,
albumManualOrders,
};
}
@@ -287,6 +315,8 @@ export function getConfig(): AppConfig {
albumDescriptions: {},
albumPasswords: {},
albumHeroImages: {},
albumSortModes: {},
albumManualOrders: {},
cacheTtl: env.CACHE_TTL * 1000,
staleMaxAge: env.STALE_MAX_AGE * 1000,
immichTimeoutMs: env.IMMICH_TIMEOUT_MS,
@@ -306,6 +336,8 @@ export function getConfig(): AppConfig {
albumDescriptions,
albumPasswords,
albumHeroImages,
albumSortModes,
albumManualOrders,
} = deriveGallery(gallery);
const siteSeoTitle = settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery';
@@ -378,6 +410,8 @@ export function getConfig(): AppConfig {
albumDescriptions,
albumPasswords,
albumHeroImages,
albumSortModes,
albumManualOrders,
cacheTtl: env.CACHE_TTL * 1000,
staleMaxAge: env.STALE_MAX_AGE * 1000,
immichTimeoutMs: env.IMMICH_TIMEOUT_MS,

View File

@@ -1,3 +1,5 @@
import type { AlbumSortMode } from '../albumSort';
export interface SubpageSectionConfig {
title: string;
slug: string; // auto-derived from title, used as anchor
@@ -29,15 +31,14 @@ export interface SubpageObjectValue {
essayFile?: string;
essayText?: string;
enabled?: boolean;
albums?: Array<
string | Record<string, string | { title: string; description?: string; password?: 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.
albums?: Array<string | Record<string, string | AlbumEntryObject>>;
sections?: Array<{
title: string;
description?: string;
albums: Array<
string | Record<string, string | { title: string; description?: string; password?: string }>
>;
albums: Array<string | Record<string, string | AlbumEntryObject>>;
}>;
}
@@ -112,6 +113,10 @@ export interface AppConfig {
albumDescriptions: Record<string, string>;
albumPasswords: Record<string, string>;
albumHeroImages: Record<string, string>;
/** Per-album sort mode. Absent means `immich` — the album's own order. */
albumSortModes: Record<string, AlbumSortMode>;
/** Pinned asset UUIDs per album for `sort: manual`, in display order. */
albumManualOrders: Record<string, string[]>;
cacheTtl: number;
/** How long (ms) an expired cache entry may still be served while Immich is unreachable. */
staleMaxAge: number;
@@ -133,10 +138,18 @@ export interface AppConfig {
}
export interface AlbumEntryObject {
title: string;
/** Optional: an entry may carry only a sort mode and no title override. */
title?: string;
description?: string;
password?: string;
heroImage?: string;
/**
* Raw YAML shape — narrowed to `AlbumSortMode` in deriveGallery(), which is
* where an unknown value has to be rejected with a message a human can act on.
*/
sort?: string;
/** Pinned asset UUIDs for `sort: manual`. Everything else follows automatically. */
assetOrder?: string[];
}
export interface GalleryYaml {

View File

@@ -9,6 +9,7 @@
import { getConfig, slugify, type SubpageConfig } from './config';
import { cache } from './cache';
import { compareByCaptureTime, sortAlbumAssets, DEFAULT_ALBUM_SORT } from './albumSort';
// ── Types ──────────────────────────────────────────────────────
@@ -36,6 +37,8 @@ export interface ImmichAsset {
localDateTime?: string;
exifInfo?: ImmichExifInfo;
isTrashed: boolean;
/** Only surfaced in the admin pickers. Optional: older Immich responses omit it. */
isFavorite?: boolean;
}
export interface ImmichExifInfo {
@@ -544,7 +547,61 @@ class ImmichClient {
return assets;
}
/**
* Every asset of an album, in the album's canonical Immich order, bypassing
* the allowlist.
*
* For the admin panel only. `getAlbum()` refuses albums that are not in the
* saved config, which is right for the public site but wrong for the page
* builder: an album just dragged in has not been saved yet, so the picker
* and the reorder editor would come up empty. Admin auth is the gate here.
*/
async getAlbumAssetsRaw(albumId: string): Promise<ImmichAsset[]> {
const [album, assets] = await Promise.all([
this.request<ImmichAlbum>(`/albums/${encodeURIComponent(albumId)}`),
this.fetchAlbumAssets(albumId),
]);
if (!album) return [];
// Same order the site would render under `sort: immich`. The reorder
// editor's baseline has to match it exactly, or the assets it shows as
// "follows automatically" would not be the ones that actually follow.
return assets
.filter((a) => !a.isTrashed)
.sort(compareByCaptureTime(album.order === 'asc' ? 1 : -1));
}
/**
* Apply the album's configured sort on the way out.
*
* Deliberately not folded into the cached load: the LRU holds one canonical
* Immich-ordered copy per album, so the mode never has to enter the cache key
* and a config change takes effect without depending on invalidation.
*
* The clone is load-bearing. `cache.get()` hands back the stored object by
* reference, so sorting `album.assets` here would permanently reorder the
* cached entry — and under request coalescing every concurrent caller would
* be sorting the same array.
*/
private withSort(album: ImmichAlbum): ImmichAlbum {
const mode = this.config.albumSortModes[album.id] ?? DEFAULT_ALBUM_SORT;
return {
...album,
assets: sortAlbumAssets(album.assets, {
mode,
immichOrder: album.order,
manualOrder: this.config.albumManualOrders[album.id],
}),
};
}
async getAlbum(albumId: string, forceFresh = false): Promise<ImmichAlbum | null> {
const album = await this.loadAlbum(albumId, forceFresh);
return album ? this.withSort(album) : null;
}
/** The cached load. Always yields the canonical Immich order; see withSort(). */
private async loadAlbum(albumId: string, forceFresh: boolean): Promise<ImmichAlbum | null> {
// Security: only serve configured albums
if (!this.config.albums.includes(albumId)) {
console.warn(`[Immich] Album ${albumId} is not in LIGHTBOX_ALBUMS`);
@@ -593,25 +650,10 @@ class ImmichClient {
// order. Metadata search happens to default to fileCreatedAt desc, but
// that is not contractual — sort explicitly so 'asc' albums are right.
//
// Mirrors Immich's own timeline query: primary key `localDateTime`
// (capture time in the photographer's local zone), tie-broken by
// `fileCreatedAt` (the same instant in UTC). The two only diverge for
// albums spanning time zones, and there local time is what the Immich
// UI shows. Sorting on `exifInfo.dateTimeOriginal` instead would be
// near-pointless: Immich already derives `fileCreatedAt` from that
// same EXIF chain, and it is null for assets without EXIF.
//
// Compare parsed instants, not strings — string order breaks on
// fractional seconds ('.' collates before '+'), which misorders bursts
// where only some frames carry sub-second precision. Unparseable dates
// fall back to 0 rather than NaN, which would make sort() incoherent.
const dir = album.order === 'asc' ? 1 : -1;
const ts = (value: string | undefined) => Date.parse(value ?? '') || 0;
album.assets.sort(
(a, b) =>
dir * (ts(a.localDateTime) - ts(b.localDateTime)) ||
dir * (ts(a.fileCreatedAt) - ts(b.fileCreatedAt)),
);
// This is the *canonical* order, and it is what goes into the cache.
// A configured per-album sort is applied later, on the way out of
// getAlbum(); see withSort().
album.assets.sort(compareByCaptureTime(album.order === 'asc' ? 1 : -1));
const name = this.config.albumOverrides[album.id] ?? album.albumName;
const description = this.config.albumDescriptions[album.id] ?? album.description ?? '';

View File

@@ -45,6 +45,10 @@ export function encodeAssetId(assetId: string): string {
* Returns null if the token is invalid.
*/
export function decodeAssetId(token: string): string | null {
// 🛡️ SECURITY: Enforce maximum token length to prevent memory exhaustion DoS
// A v2 token for a 36-char UUID is ~110 chars. 256 is generous but safe.
if (token.length > 256) return null;
try {
const key = getKey();