* 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>
149 lines
6.2 KiB
TypeScript
149 lines
6.2 KiB
TypeScript
/**
|
|
* 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));
|
|
}
|
|
}
|