🛡️ 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:
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user