🛡️ 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:
211
lib/__tests__/albumSort.test.ts
Normal file
211
lib/__tests__/albumSort.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
148
lib/albumSort.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ?? '';
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user