🛡️ 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

4
.jules/sentinel.md Normal file
View File

@@ -0,0 +1,4 @@
## 2024-08-11 - Unconstrained Base64URL Decoding DoS
**Vulnerability:** The decodeAssetId function in lib/tokens.ts accepted arbitrary length tokens and passed them directly to Buffer.from(..., 'base64url'), creating a risk of massive memory allocation (memory exhaustion/CPU DoS).
**Learning:** Functions that decode tokens from URLs (like GET /api/image/:token) must limit the input string length before passing it to Buffer.from. Otherwise, a maliciously crafted huge token can cause the Node.js process to throw RangeError (Invalid string length) or exhaust memory, despite being wrapped in a try/catch.
**Prevention:** Always enforce a strict maximum length check on opaque URL tokens before decoding them.

View File

@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Releases up to and including v0.9.2 are documented in the Releases up to and including v0.9.2 are documented in the
[GitHub releases](https://github.com/ralksta/immich-folio/releases). [GitHub releases](https://github.com/ralksta/immich-folio/releases).
## [Unreleased]
### Security
- **Next.js 16.3.0** — picks up the fix for CVE-2025-13465 in Next's vendored
lodash ([#402](https://github.com/ralksta/immich-folio/pull/402)).
### Internal
Nothing user-facing; recorded so the next release notes are complete.
- **Dependency PRs now target `dev`**, and CI runs on pull requests to `dev` as
well ([#411](https://github.com/ralksta/immich-folio/pull/411)). The workflow
previously triggered on `main` only, so a PR against `dev` carried no checks
at all. `CONTRIBUTING.md` and the PR template now state the target branch
([#412](https://github.com/ralksta/immich-folio/pull/412)) — it was nowhere
documented before.
- **`scripts/screenshots.ts` type-checks against sharp 0.34 and 0.35**
([#410](https://github.com/ralksta/immich-folio/pull/410)). sharp arrives
transitively through Next, and 0.35 moved from `export =` to ESM: the module
namespace stopped being callable and the factory moved to `.default`. The
Next bump above would otherwise have broken `npx tsc --noEmit`.
- Dev-dependency bumps: prettier 3.9.6, eslint 9.39.5, `@types/leaflet` 1.9.22,
and `github/codeql-action` v4.
## [0.10.0] — 2026-08-10 ## [0.10.0] — 2026-08-10
### Added ### Added
@@ -60,6 +85,12 @@ Releases up to and including v0.9.2 are documented in the
were visible on top of each other. were visible on top of each other.
- **Docker** — health check targets `127.0.0.1` instead of `localhost`, base - **Docker** — health check targets `127.0.0.1` instead of `localhost`, base
image moved to `node:22-alpine`. image moved to `node:22-alpine`.
- **The Trivy scan never ran.** The publish workflow referenced
`aquasecurity/trivy-action@0.29.0`, but that repository's tags carry a
leading `v`, so the job failed during action setup — before the image was
built. No image was published for the first v0.10.0 attempt, and no scan had
run since the step was introduced. The action is now pinned to a commit SHA
([#409](https://github.com/ralksta/immich-folio/pull/409)).
- **Docs** — the "nature / travel journal" example in `docs/theming.md` used a - **Docs** — the "nature / travel journal" example in `docs/theming.md` used a
preset named `botanica` that does not exist; copying it produced an preset named `botanica` that does not exist; copying it produced an
`Unknown theme preset` error. It now uses `editorial`. `Unknown theme preset` error. It now uses `editorial`.

View File

@@ -1510,6 +1510,10 @@
.asset-picker-tab { .asset-picker-tab {
flex: 1; flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
padding: 0.7rem 1rem; padding: 0.7rem 1rem;
background: none; background: none;
border: none; border: none;
@@ -1607,8 +1611,11 @@
position: absolute; position: absolute;
top: 4px; top: 4px;
left: 4px; left: 4px;
font-size: 0.7rem; display: flex;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6); font-size: 0.85rem;
color: var(--admin-accent);
/* drop-shadow, not text-shadow: the badge is an inline SVG, not a glyph. */
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
} }
.asset-picker-uuid-section { .asset-picker-uuid-section {
@@ -1782,8 +1789,10 @@
color 0.15s ease; color 0.15s ease;
} }
/* Drag handles use IconGripVertical's square 24x24 viewBox, so keep the box
square too — a 12x18 box would letterbox the glyph down to 12x12. */
.svg-drag { .svg-drag {
width: 12px; width: 18px;
height: 18px; height: 18px;
} }
@@ -1924,6 +1933,17 @@ h3 .svg-icon {
background: rgba(74, 222, 128, 0.05); background: rgba(74, 222, 128, 0.05);
} }
/* Carries a mode name ("filename", "oldest"), so it cannot use the fixed
icon-sized box the other badges share. */
.album-tile-badges .badge-sort {
width: auto;
min-width: 18px;
padding: 0 5px;
font-size: 0.62rem;
letter-spacing: 0.03em;
text-transform: uppercase;
}
/* ── Breitwand-Kiste Centered Modal styling ── */ /* ── Breitwand-Kiste Centered Modal styling ── */
.album-drawer-overlay { .album-drawer-overlay {
position: fixed; position: fixed;
@@ -3821,3 +3841,140 @@ h3 .svg-icon {
background: var(--admin-btn-primary-bg, #ffffff); background: var(--admin-btn-primary-bg, #ffffff);
color: var(--admin-btn-primary-text, #000000); color: var(--admin-btn-primary-text, #000000);
} }
/* ── Per-album photo order (sort: manual) ────────────────────── */
.admin-field-hint {
display: block;
margin-top: 0.35rem;
font-size: 0.75rem;
line-height: 1.4;
color: var(--admin-text-muted);
}
.order-editor-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0.75rem;
}
.order-editor-notice {
margin: 0 0 0.75rem;
padding: 0.5rem 0.7rem;
border-radius: 6px;
font-size: 0.78rem;
line-height: 1.4;
color: var(--admin-accent);
border: 1px solid rgba(245, 158, 11, 0.3);
background: rgba(245, 158, 11, 0.05);
}
.order-editor-section + .order-editor-section {
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid var(--admin-border);
}
.order-editor-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.order-editor-section-head h4 {
margin: 0;
font-size: 0.85rem;
font-weight: 600;
color: var(--admin-text);
}
.order-editor-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
gap: 0.5rem;
margin-top: 0.5rem;
}
.order-tile {
position: relative;
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
background: var(--admin-bg-elevated);
border: 2px solid transparent;
cursor: pointer;
transition:
border-color 0.15s,
opacity 0.15s;
}
.order-tile img {
width: 100%;
height: 100%;
object-fit: cover;
/* The whole tile is a drag handle; without this the browser's native image
drag wins and dnd-kit never sees the gesture. */
-webkit-user-drag: none;
user-select: none;
}
.order-tile-pinned {
border-color: var(--admin-accent);
cursor: grab;
}
.order-tile-pinned:active {
cursor: grabbing;
}
.order-tile-unpinned {
opacity: 0.55;
}
.order-tile-unpinned:hover {
opacity: 1;
border-color: var(--admin-border);
}
.order-tile-index {
position: absolute;
bottom: 4px;
left: 4px;
min-width: 18px;
padding: 0 4px;
border-radius: 4px;
font-size: 0.68rem;
font-weight: 700;
text-align: center;
color: #000;
background: var(--admin-accent);
}
.order-tile-action {
position: absolute;
top: 4px;
right: 4px;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 50%;
font-size: 0.85rem;
line-height: 1;
cursor: pointer;
color: var(--admin-text);
background: var(--admin-overlay, rgba(0, 0, 0, 0.6));
}
.order-editor-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.75rem;
border-top: 1px solid var(--admin-border);
}

View File

@@ -0,0 +1,277 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
rectSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
interface AssetInfo {
id: string;
type: string;
originalFileName: string;
fileCreatedAt: string;
}
interface Props {
albumId: string;
albumName: string;
/** Currently pinned asset IDs, in display order. */
assetOrder: string[];
onSave: (assetOrder: string[]) => void;
onClose: () => void;
}
function SortableAssetTile({
asset,
index,
onUnpin,
}: {
asset: AssetInfo;
index: number;
onUnpin: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: asset.id,
});
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
}}
className="order-tile order-tile-pinned"
title={asset.originalFileName}
{...attributes}
{...listeners}
>
<img src={`/api/admin/thumbnail/${asset.id}`} alt={asset.originalFileName} loading="lazy" />
<span className="order-tile-index">{index + 1}</span>
<button
className="order-tile-action"
title="Unpin — let this photo follow automatically"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onUnpin();
}}
>
×
</button>
</div>
);
}
/**
* Editor for `sort: manual`.
*
* `manual` is a pinned *prefix*: the saved list holds only the photos placed by
* hand, and everything else follows in the album's Immich order. The two zones
* exist to make that visible rather than something the owner has to be told.
*/
export default function AssetOrderEditor({
albumId,
albumName,
assetOrder,
onSave,
onClose,
}: Props) {
const [assets, setAssets] = useState<AssetInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [pinnedIds, setPinnedIds] = useState<string[]>([]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
useEffect(() => {
let cancelled = false;
(async () => {
try {
// types=all: the public grid renders videos too, so a video has to be
// pinnable — otherwise it could only ever land in the unpinned tail.
const res = await fetch(`/api/admin/albums/${albumId}/assets?types=all`);
if (!res.ok) throw new Error(`Request failed with ${res.status}`);
const data = await res.json();
if (cancelled) return;
setAssets(data.assets);
// Reconcile against the *live* album rather than rendering the stored
// list: IDs removed from the album in Immich drop out, and new photos
// appear in the unpinned zone instead of being invisible.
const live = new Set<string>(data.assets.map((a: AssetInfo) => a.id));
setPinnedIds(assetOrder.filter((id) => live.has(id)));
} catch (err) {
if (cancelled) return;
console.error('Failed to load album assets:', err);
setError('Could not load the album’s photos from Immich.');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [albumId, assetOrder]);
const byId = useMemo(() => new Map(assets.map((a) => [a.id, a])), [assets]);
const pinned = useMemo(
() => pinnedIds.map((id) => byId.get(id)).filter((a): a is AssetInfo => Boolean(a)),
[pinnedIds, byId],
);
const unpinned = useMemo(() => {
const set = new Set(pinnedIds);
return assets.filter((a) => !set.has(a.id));
}, [assets, pinnedIds]);
// Drift is reported, never acted on by itself. A pruned list is written only
// when the user saves — auto-saving it would let one failed Immich request
// quietly destroy a hand-curated order.
const dropped = useMemo(() => {
if (loading || error) return 0;
const live = new Set(assets.map((a) => a.id));
return assetOrder.filter((id) => !live.has(id)).length;
}, [assetOrder, assets, loading, error]);
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const from = pinnedIds.indexOf(String(active.id));
const to = pinnedIds.indexOf(String(over.id));
if (from < 0 || to < 0) return;
setPinnedIds(arrayMove(pinnedIds, from, to));
}
return (
<div className="picker-overlay" onClick={onClose}>
<div className="asset-picker-modal" onClick={(e) => e.stopPropagation()}>
<div className="picker-header">
<h3>Photo order — {albumName}</h3>
<button className="admin-btn-icon" onClick={onClose}>
×
</button>
</div>
{loading ? (
<div className="asset-picker-loading">
<div className="admin-spinner" />
</div>
) : error ? (
<p className="empty-hint">{error}</p>
) : (
<div className="order-editor-body">
{dropped > 0 && (
<p className="order-editor-notice">
{dropped} pinned {dropped === 1 ? 'photo is' : 'photos are'} no longer in this
album. {dropped === 1 ? 'It' : 'They'} will be removed from the order when you save.
</p>
)}
<div className="order-editor-section">
<div className="order-editor-section-head">
<h4>Pinned ({pinned.length})</h4>
{pinned.length > 0 && (
<button className="admin-btn admin-btn-sm" onClick={() => setPinnedIds([])}>
Unpin all
</button>
)}
</div>
{pinned.length === 0 ? (
<p className="empty-hint">
Nothing pinned yet — the album shows in Immich order. Pin the photos that should
open the album.
</p>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={pinnedIds} strategy={rectSortingStrategy}>
<div className="order-editor-grid">
{pinned.map((asset, i) => (
<SortableAssetTile
key={asset.id}
asset={asset}
index={i}
onUnpin={() => setPinnedIds(pinnedIds.filter((id) => id !== asset.id))}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
<div className="order-editor-section">
<div className="order-editor-section-head">
<h4>Follows automatically ({unpinned.length})</h4>
{unpinned.length > 0 && (
<button
className="admin-btn admin-btn-sm"
onClick={() => setPinnedIds([...pinnedIds, ...unpinned.map((a) => a.id)])}
>
Pin all
</button>
)}
</div>
<p className="empty-hint">
These follow the pinned photos in the album’s Immich order. Click one to pin it.
</p>
<div className="order-editor-grid">
{unpinned.map((asset) => (
<div
key={asset.id}
className="order-tile order-tile-unpinned"
title={asset.originalFileName}
onClick={() => setPinnedIds([...pinnedIds, asset.id])}
>
<img
src={`/api/admin/thumbnail/${asset.id}`}
alt={asset.originalFileName}
loading="lazy"
/>
<span className="order-tile-action">+</span>
</div>
))}
</div>
</div>
</div>
)}
<div className="order-editor-footer">
<button className="admin-btn admin-btn-ghost" onClick={onClose}>
Cancel
</button>
<button
className="admin-btn admin-btn-primary"
// Saving after a failed load would persist an empty or truncated
// order over the real one.
disabled={loading || !!error}
onClick={() => onSave(pinnedIds)}
>
Apply order
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { IconFolder, IconImage, IconStar } from './Icons';
interface AssetInfo { interface AssetInfo {
id: string; id: string;
@@ -126,20 +127,23 @@ export default function AssetPicker({
className={`asset-picker-tab ${tab === 'album' ? 'active' : ''}`} className={`asset-picker-tab ${tab === 'album' ? 'active' : ''}`}
onClick={() => setTab('album')} onClick={() => setTab('album')}
> >
📂 This Album <IconFolder size={14} />
This Album
</button> </button>
)} )}
<button <button
className={`asset-picker-tab ${tab === 'favorites' ? 'active' : ''}`} className={`asset-picker-tab ${tab === 'favorites' ? 'active' : ''}`}
onClick={() => setTab('favorites')} onClick={() => setTab('favorites')}
> >
⭐ Favorites <IconStar size={14} />
Favorites
</button> </button>
<button <button
className={`asset-picker-tab ${tab === 'all' ? 'active' : ''}`} className={`asset-picker-tab ${tab === 'all' ? 'active' : ''}`}
onClick={() => setTab('all')} onClick={() => setTab('all')}
> >
🖼️ All Photos <IconImage size={14} />
All Photos
</button> </button>
</div> </div>
@@ -173,7 +177,9 @@ export default function AssetPicker({
/> />
{isUsed && <div className="asset-picker-used-badge">✓</div>} {isUsed && <div className="asset-picker-used-badge">✓</div>}
{asset.isFavorite && !isUsed && ( {asset.isFavorite && !isUsed && (
<div className="asset-picker-fav-badge">⭐</div> <div className="asset-picker-fav-badge" title="Favorite in Immich">
<IconStar size={14} />
</div>
)} )}
</div> </div>
); );

View File

@@ -256,6 +256,25 @@ export function IconHeart({ size = 16, className = 'svg-icon', strokeWidth = 2,
); );
} }
/** Filled by default — it marks a state (favourite), not an action. */
export function IconStar({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="m12 2 3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14l-5-4.87 6.91-1.01Z" />
</svg>
);
}
export function IconImage({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
);
}
export function IconBarChart({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { export function IconBarChart({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return ( return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}> <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
@@ -279,6 +298,40 @@ export function IconGripVertical({ size = 16, className = 'svg-icon', strokeWidt
); );
} }
export function IconX({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
);
}
export function IconPlus({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M5 12h14M12 5v14" />
</svg>
);
}
export function IconHome({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
);
}
export function IconCopy({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
);
}
export function IconQuote({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { export function IconQuote({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return ( return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}> <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>

View File

@@ -21,97 +21,43 @@ import {
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import AlbumPicker from './AlbumPicker'; import AlbumPicker from './AlbumPicker';
import AssetPicker from './AssetPicker'; import AssetPicker from './AssetPicker';
import AssetOrderEditor from './AssetOrderEditor';
import { EssayBlockEditor } from './EssayBlockEditor'; import { EssayBlockEditor } from './EssayBlockEditor';
import {
ALBUM_SORT_MODES,
DEFAULT_ALBUM_SORT,
isAlbumSortMode,
type AlbumSortMode,
} from '@/lib/albumSort';
import type { AlbumEntryObject } from '@/lib/config/schema';
import {
IconCamera,
IconCopy,
IconFolder,
IconGripVertical,
IconHome,
IconImage,
IconLock,
IconPencil,
IconPlus,
IconSearch,
IconTrash,
IconX,
} from './Icons';
// ── Icons ────────────────────────────────────────────────────── /**
const Icons = { * Labels for the per-album sort select. Order matches ALBUM_SORT_MODES.
Drag: () => ( *
<svg width="12" height="18" viewBox="0 0 12 18" fill="currentColor" className="svg-icon svg-drag"> * Plain text, no emoji: an <option> cannot contain markup, so the SVG set from
<circle cx="2" cy="2" r="1.5" /> * `./Icons` is not available here — and a decorative emoji is not a substitute
<circle cx="2" cy="9" r="1.5" /> * for it. The badge on the album card uses a real icon instead.
<circle cx="2" cy="16" r="1.5" /> */
<circle cx="10" cy="2" r="1.5" /> const SORT_LABELS: Record<AlbumSortMode, string> = {
<circle cx="10" cy="9" r="1.5" /> immich: 'Immich order (default)',
<circle cx="10" cy="16" r="1.5" /> newest: 'Newest first',
</svg> oldest: 'Oldest first',
), filename: 'Filename',
Edit: () => ( manual: 'Manual',
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
),
Trash: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
),
Close: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
),
Folder: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</svg>
),
Camera: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3Z" />
<circle cx="12" cy="13" r="3" />
</svg>
),
ExternalLink: () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
),
Search: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
),
Lock: () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
),
Plus: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<path d="M5 12h14M12 5v14" />
</svg>
),
Home: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
),
Copy: () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
),
Check: () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<polyline points="20 6 9 17 4 12" />
</svg>
),
Image: () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
),
}; };
// ── Types ────────────────────────────────────────────────────── // ── Types ──────────────────────────────────────────────────────
@@ -121,6 +67,9 @@ interface AlbumEntry {
description?: string; description?: string;
password?: string; password?: string;
heroImage?: string; heroImage?: string;
sort?: AlbumSortMode;
/** Pinned asset UUIDs for `sort: manual`; everything else follows automatically. */
assetOrder?: string[];
} }
interface Section { interface Section {
@@ -166,17 +115,27 @@ interface ImmichAlbumInfo {
// ── Helpers ──────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────
function parseAlbumEntries( type RawAlbumEntry = string | Record<string, string | AlbumEntryObject>;
raw:
| Array< /**
| string * Whether an entry carries anything beyond its ID.
| Record< *
string, * Both collapse rules below depend on this, and they are the reason every new
string | { title: string; description?: string; password?: string; heroImage?: string } * per-album option has to be listed here: an entry that looks "empty" is
> * serialized back to a bare UUID string, so a field missing from this check is
> * silently dropped on the next save.
| undefined, */
): AlbumEntry[] { function hasAlbumOptions(entry: AlbumEntry): boolean {
return Boolean(
entry.description ||
entry.password ||
entry.heroImage ||
(entry.sort && entry.sort !== DEFAULT_ALBUM_SORT) ||
entry.assetOrder?.length,
);
}
function parseAlbumEntries(raw: RawAlbumEntry[] | undefined): AlbumEntry[] {
if (!raw) return []; if (!raw) return [];
return raw.map((entry) => { return raw.map((entry) => {
if (typeof entry === 'string') return { id: entry }; if (typeof entry === 'string') return { id: entry };
@@ -188,27 +147,29 @@ function parseAlbumEntries(
description: value.description, description: value.description,
password: value.password, password: value.password,
heroImage: value.heroImage, heroImage: value.heroImage,
sort: isAlbumSortMode(value.sort) ? value.sort : undefined,
assetOrder: value.assetOrder,
}; };
}); });
} }
function serializeAlbumEntries( function serializeAlbumEntries(entries: AlbumEntry[]): RawAlbumEntry[] {
entries: AlbumEntry[],
): Array<
| string
| Record<string, string | { title: string; description?: string; password?: string; heroImage?: string }>
> {
return entries.map((entry) => { return entries.map((entry) => {
if (!entry.title && !entry.description && !entry.password && !entry.heroImage) return entry.id; const extras = hasAlbumOptions(entry);
if (entry.title && !entry.description && !entry.password && !entry.heroImage) { if (!entry.title && !extras) return entry.id;
return { [entry.id]: entry.title }; if (entry.title && !extras) return { [entry.id]: entry.title };
}
const val: { title: string; description?: string; password?: string; heroImage?: string } = { const val: AlbumEntryObject = {};
title: entry.title || '', // Only when set: a sort-only entry would otherwise be written with an empty
}; // title, which deriveGallery ignores but which still lands in the YAML.
if (entry.title) val.title = entry.title;
if (entry.description) val.description = entry.description; if (entry.description) val.description = entry.description;
if (entry.password) val.password = entry.password; if (entry.password) val.password = entry.password;
if (entry.heroImage) val.heroImage = entry.heroImage; if (entry.heroImage) val.heroImage = entry.heroImage;
if (entry.sort && entry.sort !== DEFAULT_ALBUM_SORT) val.sort = entry.sort;
// Persisted regardless of the mode, so manual → newest → manual does not
// throw away a hand-curated order.
if (entry.assetOrder?.length) val.assetOrder = entry.assetOrder;
return { [entry.id]: val }; return { [entry.id]: val };
}); });
} }
@@ -238,11 +199,11 @@ function SortableHeroTile({
return ( return (
<div ref={setNodeRef} style={style} className="hero-tile" {...attributes}> <div ref={setNodeRef} style={style} className="hero-tile" {...attributes}>
<div className="hero-tile-drag" {...listeners} title="Drag to reorder"> <div className="hero-tile-drag" {...listeners} title="Drag to reorder">
<Icons.Drag /> <IconGripVertical size={18} className="svg-icon svg-drag" />
</div> </div>
<img src={`/api/admin/thumbnail/${id}`} alt="" loading="lazy" /> <img src={`/api/admin/thumbnail/${id}`} alt="" loading="lazy" />
<button className="hero-tile-remove" onClick={onRemove} title="Remove"> <button className="hero-tile-remove" onClick={onRemove} title="Remove">
<Icons.Close /> <IconX size={14} />
</button> </button>
<span className="hero-tile-index">{index + 1}</span> <span className="hero-tile-index">{index + 1}</span>
</div> </div>
@@ -334,7 +295,7 @@ function SortableSubpageTile({
{...attributes} {...attributes}
> >
<div className="subpage-tile-drag" {...listeners} title="Drag to reorder"> <div className="subpage-tile-drag" {...listeners} title="Drag to reorder">
<Icons.Drag /> <IconGripVertical size={18} className="svg-icon svg-drag" />
</div> </div>
{sp.enabled === false && ( {sp.enabled === false && (
@@ -345,7 +306,7 @@ function SortableSubpageTile({
{sp.password && ( {sp.password && (
<span className="subpage-badge-protected" title="Password protected"> <span className="subpage-badge-protected" title="Password protected">
<Icons.Lock /> Password <IconLock size={12} /> Password
</span> </span>
)} )}
@@ -354,11 +315,11 @@ function SortableSubpageTile({
<img src={`/api/admin/thumbnail/${firstThumb}`} alt="" loading="lazy" /> <img src={`/api/admin/thumbnail/${firstThumb}`} alt="" loading="lazy" />
) : ( ) : (
<div className="subpage-tile-placeholder"> <div className="subpage-tile-placeholder">
<Icons.Folder /> <IconFolder />
</div> </div>
)} )}
<div className="subpage-hover-overlay"> <div className="subpage-hover-overlay">
<span className="hover-action-btn"><Icons.Edit /> Edit Page</span> <span className="hover-action-btn"><IconPencil size={14} /> Edit Page</span>
</div> </div>
</div> </div>
<div className="subpage-tile-info"> <div className="subpage-tile-info">
@@ -367,7 +328,7 @@ function SortableSubpageTile({
<span className="subpage-tile-slug">/{slug}</span> <span className="subpage-tile-slug">/{slug}</span>
</div> </div>
<span className="subpage-tile-meta"> <span className="subpage-tile-meta">
<Icons.Folder /> {totalAlbums} album{totalAlbums !== 1 ? 's' : ''} <IconFolder /> {totalAlbums} album{totalAlbums !== 1 ? 's' : ''}
</span> </span>
</div> </div>
</div> </div>
@@ -396,6 +357,12 @@ export default function PageBuilder() {
currentAssetIds?: string[]; currentAssetIds?: string[];
title?: string; title?: string;
} | null>(null); } | null>(null);
const [orderEditorTarget, setOrderEditorTarget] = useState<{
albumId: string;
albumName: string;
assetOrder: string[];
onSave: (assetOrder: string[]) => void;
} | null>(null);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [editingAlbumAddress, setEditingAlbumAddress] = useState<ActiveEditAlbumAddress | null>(null); const [editingAlbumAddress, setEditingAlbumAddress] = useState<ActiveEditAlbumAddress | null>(null);
@@ -929,7 +896,7 @@ export default function PageBuilder() {
{/* Search Bar */} {/* Search Bar */}
<div className="builder-search-container"> <div className="builder-search-container">
<div className="builder-search-wrapper"> <div className="builder-search-wrapper">
<span className="builder-search-icon"><Icons.Search /></span> <span className="builder-search-icon"><IconSearch size={14} /></span>
<input <input
type="text" type="text"
className="builder-search-input" className="builder-search-input"
@@ -948,7 +915,7 @@ export default function PageBuilder() {
{/* Hero Section */} {/* Hero Section */}
<section className="builder-section"> <section className="builder-section">
<div className="builder-section-header"> <div className="builder-section-header">
<h2><Icons.Home /> Homepage Hero</h2> <h2><IconHome /> Homepage Hero</h2>
<button <button
className="admin-btn admin-btn-sm" className="admin-btn admin-btn-sm"
onClick={() => onClick={() =>
@@ -959,7 +926,7 @@ export default function PageBuilder() {
}) })
} }
> >
<Icons.Plus /> Add Hero <IconPlus size={14} /> Add Hero
</button> </button>
</div> </div>
<DndContext <DndContext
@@ -993,7 +960,10 @@ export default function PageBuilder() {
{/* Standalone Albums */} {/* Standalone Albums */}
<section className="builder-section"> <section className="builder-section">
<div className="builder-section-header"> <div className="builder-section-header">
<h2>📷 Standalone Albums</h2> <h2>
<IconCamera />
Standalone Albums
</h2>
<button <button
className="admin-btn admin-btn-sm" className="admin-btn admin-btn-sm"
onClick={() => setPickerTarget({ type: 'standalone' })} onClick={() => setPickerTarget({ type: 'standalone' })}
@@ -1042,7 +1012,10 @@ export default function PageBuilder() {
{/* Subpages */} {/* Subpages */}
<section className="builder-section"> <section className="builder-section">
<div className="builder-section-header"> <div className="builder-section-header">
<h2>📂 Subpages</h2> <h2>
<IconFolder />
Subpages
</h2>
<button className="admin-btn admin-btn-sm" onClick={addSubpage}> <button className="admin-btn admin-btn-sm" onClick={addSubpage}>
+ New Subpage + New Subpage
</button> </button>
@@ -1107,7 +1080,7 @@ export default function PageBuilder() {
onClick={() => setDrawerMode('edit')} onClick={() => setDrawerMode('edit')}
style={{ padding: '0.2rem 0.6rem', fontSize: '0.75rem' }} style={{ padding: '0.2rem 0.6rem', fontSize: '0.75rem' }}
> >
<Icons.Edit /> Edit <IconPencil size={14} /> Edit
</button> </button>
<button <button
type="button" type="button"
@@ -1115,7 +1088,7 @@ export default function PageBuilder() {
onClick={() => setDrawerMode('preview')} onClick={() => setDrawerMode('preview')}
style={{ padding: '0.2rem 0.6rem', fontSize: '0.75rem' }} style={{ padding: '0.2rem 0.6rem', fontSize: '0.75rem' }}
> >
<Icons.Search /> Live Preview <IconSearch size={14} /> Live Preview
</button> </button>
</div> </div>
@@ -1162,7 +1135,7 @@ export default function PageBuilder() {
{thumb ? ( {thumb ? (
<img src={`/api/admin/thumbnail/${thumb}`} alt="" /> <img src={`/api/admin/thumbnail/${thumb}`} alt="" />
) : ( ) : (
<div className="subpage-tile-placeholder"><Icons.Folder /></div> <div className="subpage-tile-placeholder"><IconFolder /></div>
)} )}
</div> </div>
<span className="preview-album-title"> <span className="preview-album-title">
@@ -1186,7 +1159,7 @@ export default function PageBuilder() {
{thumb ? ( {thumb ? (
<img src={`/api/admin/thumbnail/${thumb}`} alt="" /> <img src={`/api/admin/thumbnail/${thumb}`} alt="" />
) : ( ) : (
<div className="subpage-tile-placeholder"><Icons.Folder /></div> <div className="subpage-tile-placeholder"><IconFolder /></div>
)} )}
</div> </div>
<span className="preview-album-title"> <span className="preview-album-title">
@@ -1242,7 +1215,7 @@ export default function PageBuilder() {
<div className="admin-field"> <div className="admin-field">
<label>Password Protection (optional)</label> <label>Password Protection (optional)</label>
<div className="password-input-wrapper"> <div className="password-input-wrapper">
<span className="password-icon"><Icons.Lock /></span> <span className="password-icon"><IconLock size={12} /></span>
<input <input
type="password" type="password"
value={sp.password || ''} value={sp.password || ''}
@@ -1289,12 +1262,17 @@ export default function PageBuilder() {
}} }}
style={{ padding: '8px 12px', borderRadius: '6px', width: '100%', fontSize: '0.9rem' }} style={{ padding: '8px 12px', borderRadius: '6px', width: '100%', fontSize: '0.9rem' }}
> >
<option value="masonry">📷 Masonry Grid (Dynamic Height)</option> {/*
<option value="uniform">🔲 Uniform Grid (Square Tiles)</option> Plain text, no emoji: an <option> cannot contain markup, so the
<option value="showcase">⭐ Showcase (Featured First Asset)</option> SVG icon set is not available here — and a decorative emoji is
<option value="filmstrip">🎞️ Filmstrip (Horizontal Scroll)</option> not a substitute for it.
<option value="editorial-flow">📰 Editorial Flow</option> */}
<option value="essay">📖 Photo Essay Mode (Storytelling Editor)</option> <option value="masonry">Masonry Grid (Dynamic Height)</option>
<option value="uniform">Uniform Grid (Square Tiles)</option>
<option value="showcase">Showcase (Featured First Asset)</option>
<option value="filmstrip">Filmstrip (Horizontal Scroll)</option>
<option value="editorial-flow">Editorial Flow</option>
<option value="essay">Photo Essay Mode (Storytelling Editor)</option>
</select> </select>
</div> </div>
</div> </div>
@@ -1395,7 +1373,7 @@ export default function PageBuilder() {
onClick={() => removeSection(spIndex, secIndex)} onClick={() => removeSection(spIndex, secIndex)}
title="Remove section" title="Remove section"
> >
<Icons.Close /> <IconX size={14} />
</button> </button>
</div> </div>
<div className="admin-field"> <div className="admin-field">
@@ -1465,7 +1443,7 @@ export default function PageBuilder() {
setExpandedSubpage(null); setExpandedSubpage(null);
}} }}
> >
<Icons.Trash /> Delete Subpage <IconTrash size={14} /> Delete Subpage
</button> </button>
<button <button
className="admin-btn admin-btn-primary admin-btn-sm" className="admin-btn admin-btn-primary admin-btn-sm"
@@ -1505,14 +1483,14 @@ export default function PageBuilder() {
<div className="album-drawer" onClick={(e) => e.stopPropagation()}> <div className="album-drawer" onClick={(e) => e.stopPropagation()}>
<div className="album-drawer-header"> <div className="album-drawer-header">
<h3> <h3>
<Icons.Camera /> Edit Album Details <IconCamera /> Edit Album Details
</h3> </h3>
<button <button
className="admin-btn-icon" className="admin-btn-icon"
onClick={() => setEditingAlbumAddress(null)} onClick={() => setEditingAlbumAddress(null)}
title="Close details" title="Close details"
> >
<Icons.Close /> <IconX size={14} />
</button> </button>
</div> </div>
<div className="album-drawer-body"> <div className="album-drawer-body">
@@ -1523,7 +1501,7 @@ export default function PageBuilder() {
<img src={`/api/admin/thumbnail/${heroThumb}`} alt="" loading="lazy" /> <img src={`/api/admin/thumbnail/${heroThumb}`} alt="" loading="lazy" />
) : ( ) : (
<div className="modal-cover-placeholder"> <div className="modal-cover-placeholder">
<Icons.Camera /> <IconCamera />
</div> </div>
)} )}
</div> </div>
@@ -1562,7 +1540,7 @@ export default function PageBuilder() {
}} }}
title="Copy UUID" title="Copy UUID"
> >
<Icons.Copy /> <IconCopy size={12} />
</button> </button>
</div> </div>
</div> </div>
@@ -1613,7 +1591,7 @@ export default function PageBuilder() {
onClick={() => onUpdate({ heroImage: undefined })} onClick={() => onUpdate({ heroImage: undefined })}
title="Remove custom hero image" title="Remove custom hero image"
> >
<Icons.Close /> <IconX size={14} />
</button> </button>
</div> </div>
) : ( ) : (
@@ -1633,10 +1611,64 @@ export default function PageBuilder() {
}) })
} }
> >
<Icons.Image /> {album.heroImage ? 'Change Image' : 'Pick Hero Image'} <IconImage size={12} /> {album.heroImage ? 'Change Image' : 'Pick Hero Image'}
</button> </button>
</div> </div>
</div> </div>
{/* Photo order */}
<div className="admin-field">
<label>Photo order</label>
<select
value={album.sort || DEFAULT_ALBUM_SORT}
onChange={(e) =>
onUpdate({
sort: isAlbumSortMode(e.target.value) ? e.target.value : undefined,
})
}
style={{
padding: '8px 12px',
borderRadius: '6px',
width: '100%',
fontSize: '0.9rem',
}}
>
{ALBUM_SORT_MODES.map((mode) => (
<option key={mode} value={mode}>
{SORT_LABELS[mode]}
</option>
))}
</select>
<span className="admin-field-hint">
{album.sort === 'manual'
? 'Pinned photos come first, in the order you set. Everything else follows in the album’s Immich order.'
: 'Immich order follows the album’s own sort setting in Immich.'}
</span>
{album.sort === 'manual' && (
<div style={{ marginTop: '0.75rem' }}>
<button
className="admin-btn admin-btn-sm"
onClick={() =>
setOrderEditorTarget({
albumId: album.id,
albumName: album.title || name,
assetOrder: album.assetOrder || [],
onSave: (assetOrder) => {
onUpdate({ assetOrder });
setOrderEditorTarget(null);
},
})
}
>
<IconGripVertical size={18} className="svg-icon svg-drag" />{' '}
{album.assetOrder?.length
? `Reorder photos (${album.assetOrder.length} pinned)`
: 'Reorder photos'}
</button>
</div>
)}
</div>
</div> </div>
</div> </div>
<div className="album-drawer-footer"> <div className="album-drawer-footer">
@@ -1645,7 +1677,7 @@ export default function PageBuilder() {
onClick={onRemove} onClick={onRemove}
style={{ maxWidth: '160px' }} style={{ maxWidth: '160px' }}
> >
<Icons.Trash /> Remove Album <IconTrash size={14} /> Remove Album
</button> </button>
<button <button
className="admin-btn admin-btn-primary" className="admin-btn admin-btn-primary"
@@ -1670,6 +1702,17 @@ export default function PageBuilder() {
title={heroPickerTarget.title} title={heroPickerTarget.title}
/> />
)} )}
{/* Manual Photo Order Editor */}
{orderEditorTarget && (
<AssetOrderEditor
albumId={orderEditorTarget.albumId}
albumName={orderEditorTarget.albumName}
assetOrder={orderEditorTarget.assetOrder}
onSave={orderEditorTarget.onSave}
onClose={() => setOrderEditorTarget(null)}
/>
)}
</div> </div>
); );
@@ -1797,14 +1840,14 @@ function AlbumCard({
<div className="album-tile-cover"> <div className="album-tile-cover">
{dragListeners && ( {dragListeners && (
<div className="album-tile-drag" {...dragListeners} title="Drag to reorder"> <div className="album-tile-drag" {...dragListeners} title="Drag to reorder">
<Icons.Drag /> <IconGripVertical size={18} className="svg-icon svg-drag" />
</div> </div>
)} )}
{heroThumb ? ( {heroThumb ? (
<img src={`/api/admin/thumbnail/${heroThumb}`} alt="" loading="lazy" /> <img src={`/api/admin/thumbnail/${heroThumb}`} alt="" loading="lazy" />
) : ( ) : (
<div className="album-tile-placeholder"> <div className="album-tile-placeholder">
<Icons.Camera /> <IconCamera />
</div> </div>
)} )}
<div className="album-tile-overlay"> <div className="album-tile-overlay">
@@ -1813,14 +1856,14 @@ function AlbumCard({
onClick={onEdit} onClick={onEdit}
title="Edit details" title="Edit details"
> >
<Icons.Edit /> <IconPencil size={14} />
</button> </button>
<button <button
className="album-tile-btn album-tile-btn-danger" className="album-tile-btn album-tile-btn-danger"
onClick={onRemove} onClick={onRemove}
title="Remove album" title="Remove album"
> >
<Icons.Trash /> <IconTrash size={14} />
</button> </button>
</div> </div>
</div> </div>
@@ -1832,12 +1875,17 @@ function AlbumCard({
<div className="album-tile-badges"> <div className="album-tile-badges">
{hasPassword && ( {hasPassword && (
<span className="badge badge-password" title="Password protected"> <span className="badge badge-password" title="Password protected">
<Icons.Lock /> <IconLock size={12} />
</span> </span>
)} )}
{album.heroImage && ( {album.heroImage && (
<span className="badge badge-hero" title="Custom Hero Image set"> <span className="badge badge-hero" title="Custom Hero Image set">
<Icons.Image /> <IconImage size={12} />
</span>
)}
{album.sort && album.sort !== DEFAULT_ALBUM_SORT && (
<span className="badge badge-sort" title={`Photo order: ${SORT_LABELS[album.sort]}`}>
{album.sort === 'manual' ? <IconGripVertical size={18} className="svg-icon svg-drag" /> : album.sort}
</span> </span>
)} )}
</div> </div>

View File

@@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
vi.mock('@/lib/admin/auth', () => ({
isAdminEnabled: () => true,
isAdminAuthenticated: async () => true,
}));
vi.mock('@/lib/config', () => ({
getConfig: () => ({ needsSetup: false }),
}));
const getAlbumAssetsRaw = vi.fn();
vi.mock('@/lib/immich', () => ({
immich: {
get getAlbumAssetsRaw() {
return getAlbumAssetsRaw;
},
},
}));
import { GET } from '../route';
const ALBUM = '11111111-1111-1111-1111-111111111111';
const asset = (id: string, type: 'IMAGE' | 'VIDEO') => ({
id,
type,
originalFileName: `${id}.jpg`,
fileCreatedAt: '2024-01-01T00:00:00.000Z',
isFavorite: false,
});
const call = (albumId: string, query = '') =>
GET(new NextRequest(`http://localhost/api/admin/albums/${albumId}/assets${query}`), {
params: Promise.resolve({ albumId }),
});
describe('GET /api/admin/albums/[albumId]/assets', () => {
beforeEach(() => {
vi.clearAllMocks();
getAlbumAssetsRaw.mockResolvedValue([
asset('img-1', 'IMAGE'),
asset('vid-1', 'VIDEO'),
asset('img-2', 'IMAGE'),
]);
});
// Without this, `..%2f..%2fusers` resolves inside fetch() to an arbitrary
// Immich endpoint, called with the server's API key.
it('rejects an album ID that is not a UUID', async () => {
const res = await call('..%2f..%2fusers');
expect(res.status).toBe(400);
expect(getAlbumAssetsRaw).not.toHaveBeenCalled();
});
it('returns images only by default, so the hero picker is unaffected', async () => {
const res = await call(ALBUM);
const body = await res.json();
expect(res.status).toBe(200);
expect(body.assets.map((a: { id: string }) => a.id)).toEqual(['img-1', 'img-2']);
});
// The public grid renders videos, so the sort editor has to be able to pin
// one — otherwise a video could only ever sit in the unpinned tail.
it('includes videos when types=all', async () => {
const res = await call(ALBUM, '?types=all');
const body = await res.json();
expect(body.assets.map((a: { id: string }) => a.id)).toEqual(['img-1', 'vid-1', 'img-2']);
});
/**
* Immich 3.x stopped embedding assets in `GET /albums/:id`, which is what
* this route used to read — so it returned nothing, and threw outright when
* the key was absent. It has to go through the client's paginated metadata
* search instead.
*/
it('reads assets through the Immich client rather than the album endpoint', async () => {
await call(ALBUM);
expect(getAlbumAssetsRaw).toHaveBeenCalledWith(ALBUM);
});
it('reports an upstream failure as a 500 rather than an empty album', async () => {
getAlbumAssetsRaw.mockRejectedValue(new Error('immich down'));
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
const res = await call(ALBUM);
expect(res.status).toBe(500);
err.mockRestore();
});
});

View File

@@ -1,25 +1,16 @@
/** /**
* Admin API: Get assets of a specific album for the hero image picker. * Admin API: assets of a specific album, for the hero image picker and the
* manual sort editor.
*/ */
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth'; import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
import { getConfig } from '@/lib/config'; import { getConfig } from '@/lib/config';
import { immich } from '@/lib/immich';
import { isUuid } from '@/lib/uuid'; import { isUuid } from '@/lib/uuid';
interface ImmichAlbumDetail {
assets: Array<{
id: string;
type: string;
originalFileName: string;
thumbhash: string | null;
fileCreatedAt: string;
isFavorite: boolean;
}>;
}
export async function GET( export async function GET(
_request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ albumId: string }> }, { params }: { params: Promise<{ albumId: string }> },
) { ) {
if (!isAdminEnabled()) { if (!isAdminEnabled()) {
@@ -42,27 +33,27 @@ export async function GET(
return NextResponse.json({ error: 'Invalid album ID' }, { status: 400 }); return NextResponse.json({ error: 'Invalid album ID' }, { status: 400 });
} }
// Videos are opt-in. The hero picker feeds imageUrl()/assetPlaceholder and
// wants images only; the sort editor must show everything the public grid
// renders, or a video could never be pinned and would always fall to the tail.
const includeVideos = request.nextUrl.searchParams.get('types') === 'all';
try { try {
const res = await fetch(`${config.immich.apiUrl}/albums/${encodeURIComponent(albumId)}`, { // Via the client, not a bare fetch of `GET /albums/:id`: Immich 3.x no
headers: { // longer embeds assets in that response, so reading `album.assets` returned
'x-api-key': config.immich.apiKey, // nothing (and threw outright when the key was absent). getAlbumAssetsRaw()
Accept: 'application/json', // pages through the metadata search and applies the album's own order, so
}, // this list matches what the site renders under `sort: immich`.
}); const all = await immich.getAlbumAssetsRaw(albumId);
if (!res.ok) { const assets = all
return NextResponse.json({ error: `Immich API returned ${res.status}` }, { status: 502 }); .filter((a) => a.type === 'IMAGE' || (includeVideos && a.type === 'VIDEO'))
}
const album = (await res.json()) as ImmichAlbumDetail;
const assets = album.assets
.filter((a) => a.type === 'IMAGE')
.map((a) => ({ .map((a) => ({
id: a.id, id: a.id,
type: a.type,
originalFileName: a.originalFileName, originalFileName: a.originalFileName,
fileCreatedAt: a.fileCreatedAt, fileCreatedAt: a.fileCreatedAt,
isFavorite: a.isFavorite, isFavorite: a.isFavorite ?? false,
})); }));
return NextResponse.json({ assets, nextPage: null }); return NextResponse.json({ assets, nextPage: null });

View File

@@ -8,9 +8,39 @@ hero:
- "your-asset-uuid-2" - "your-asset-uuid-2"
# Standalone albums (shown on the home page) # Standalone albums (shown on the home page)
#
# Photo order inside an album: by default a Folio album mirrors the Immich
# timeline. The optional `sort` key lets the portfolio order differ from the
# archive order, without changing anything in Immich.
#
# immich Default. Inherits the album's own asc/desc setting in Immich.
# This is capture-time order — Immich does not expose the drag
# order from its web UI, so Folio cannot reproduce that.
# newest Capture time, newest first, whatever Immich is set to.
# oldest Capture time, oldest first.
# filename originalFileName, natural order (IMG_2 before IMG_10).
# Matches scanned-film and camera-export workflows.
# manual Pinned photos first, in the order given by `assetOrder`;
# everything else follows in the album's Immich order. Far easier
# to set in /admin, which has a drag & drop editor for it.
#
# Two things worth knowing:
# • On a subpage with several albums, `sort` orders photos *within* each
# album — the albums themselves stay in the order they are listed in.
# • Lightbox permalinks (#photo-3) are positional, so changing an album's
# sort moves where a previously shared link lands.
#
# `sort` works the same on subpage and section albums, shown further down.
albums: albums:
- "your-album-uuid-1" - "your-album-uuid-1" # no sort key → Immich order, exactly as before
- "your-album-uuid-2" - "your-album-uuid-2":
sort: filename # an entry may carry only a sort, with no title override
- "your-album-uuid-3":
title: "Hokkaido, in sequence"
sort: manual
assetOrder: # pinned, in this order. Everything else follows automatically.
- "asset-uuid-opening-frame"
- "asset-uuid-second-frame"
# Subpages: group albums under custom URL paths # Subpages: group albums under custom URL paths
subpages: subpages:

View File

@@ -94,7 +94,7 @@ Each section can have:
| `description` | string | ➖ | Optional subline under the heading | | `description` | string | ➖ | Optional subline under the heading |
| `albums` | list | ✅ | Album UUIDs (same format as regular albums) | | `albums` | list | ✅ | Album UUIDs (same format as regular albums) |
Within the `albums` list you can use a plain UUID, a simple title override, or an object with `title` and `password`: Within the `albums` list you can use a plain UUID, a simple title override, or an object with any of `title`, `description`, `password`, `heroImage`, `sort` and `assetOrder`:
```yaml ```yaml
albums: albums:
@@ -103,6 +103,8 @@ albums:
- 'album-uuid': - 'album-uuid':
title: My Private Title title: My Private Title
password: 'secure-password' # → adds a password gate to this specific album password: 'secure-password' # → adds a password gate to this specific album
heroImage: 'asset-uuid' # → overrides the album cover
sort: filename # → see "Photo Order Within an Album" below
``` ```
**Full example:** **Full example:**
@@ -133,6 +135,47 @@ subpages:
> [!NOTE] > [!NOTE]
> Each section title is automatically converted to a URL-safe anchor (`#tokyo`, `#kyoto`, …). The TOC appearance (separator character, numbering style, section rule) is fully controlled by the active theme. > Each section title is automatically converted to a URL-safe anchor (`#tokyo`, `#kyoto`, …). The TOC appearance (separator character, numbering style, section rule) is fully controlled by the active theme.
## Photo Order Within an Album
By default a Folio album mirrors the Immich timeline. That is usually right, but 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. The optional `sort` key decouples the two.
| Mode | Order |
| ---------- | --------------------------------------------------------------------------------------- |
| `immich` | Default. Inherits the album's own `asc`/`desc` setting in Immich. |
| `newest` | Capture time, newest first, regardless of the Immich setting. |
| `oldest` | Capture time, oldest first. |
| `filename` | `originalFileName` in natural order — `IMG_2` before `IMG_10`. |
| `manual` | Pinned photos first, in the order you set; everything else follows in the Immich order. |
```yaml
albums:
- 'album-uuid' # no sort key → Immich order, exactly as before
- 'album-uuid':
sort: filename # an entry may carry only a sort, with no title override
- 'album-uuid':
title: Hokkaido, in sequence
sort: manual
assetOrder: # pinned, in this order
- 'asset-uuid-opening-frame'
- 'asset-uuid-second-frame'
```
`sort` works the same on standalone, subpage and section albums.
### About `manual`
`manual` is a pinned **prefix**, not a full permutation: `assetOrder` lists only the photos you placed by hand, and everything else follows in the album's Immich order. Curating an opening sequence therefore costs a handful of UUIDs rather than one per photo in the album.
This also makes the album resilient to changes in Immich. Photos removed from the album are ignored, and photos added later appear at the end rather than being hidden — so `gallery.yaml` never has to be kept in sync by hand.
The `/admin` page builder has a drag & drop editor for this (album → **Photo order** → **Manual** → **Reorder photos**), which is considerably easier than writing asset UUIDs by hand. It only writes a cleaned-up `assetOrder` when you explicitly apply it.
> [!NOTE]
> Two consequences worth knowing. On a subpage with several albums, `sort` orders photos _within_ each album — the albums themselves stay in the order they are listed in. And lightbox permalinks (`#photo-3`) are positional, so changing an album's sort moves where a previously shared link lands.
> [!TIP]
> `immich` means "inherit the album's sort setting from Immich", which is capture-time order in one direction or the other. It is not the manual drag order from the Immich web UI — Immich does not expose that order through its API, so Folio cannot reproduce it. Use `manual` for a hand-picked sequence.
## Grid Layout ## Grid Layout
Configure the photo grid globally or per-subpage: Configure the photo grid globally or per-subpage:

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); 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 // 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. // 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 () => { 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); 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', () => { it('parses enabled property on subpages correctly', () => {
const result = deriveGallery({ const result = deriveGallery({
subpages: [ subpages: [

View File

@@ -2,6 +2,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { immich, ImmichUnavailableError } from '../immich'; import { immich, ImmichUnavailableError } from '../immich';
import * as config from '../config'; import * as config from '../config';
import { cache } from '../cache'; 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 // Mock the config by wrapping it in a factory
vi.mock('../config', async () => { vi.mock('../config', async () => {
@@ -16,6 +22,8 @@ vi.mock('../config', async () => {
subpages: [], subpages: [],
albumOverrides: { 'album-1': 'Override Name' }, albumOverrides: { 'album-1': 'Override Name' },
albumDescriptions: {}, albumDescriptions: {},
albumSortModes,
albumManualOrders,
// Non-zero: a 0 TTL expires an entry in the same millisecond it is // Non-zero: a 0 TTL expires an entry in the same millisecond it is
// written, which would make any caching assertion flaky. Isolation comes // written, which would make any caching assertion flaky. Isolation comes
// from the cache.clear() in beforeEach, not from an unusable TTL. // 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 // getAlbum() memoises into the module-level LRU; without this, later tests
// read a previous test's album and never reach the mocked fetch. // read a previous test's album and never reach the mocked fetch.
cache.clear(); cache.clear();
for (const key of Object.keys(albumSortModes)) delete albumSortModes[key];
for (const key of Object.keys(albumManualOrders)) delete albumManualOrders[key];
}); });
describe('request()', () => { describe('request()', () => {
@@ -579,6 +589,69 @@ describe('ImmichClient', () => {
const album = await immich.getAlbum('album-1'); const album = await immich.getAlbum('album-1');
expect(album?.assets.map((a) => a.id)).toEqual(['frame-1', 'frame-2', 'frame-3']); 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 { resolveAuthSecret } from '../secret';
import { loadYaml, clearYamlCache, validateUuid } from './parser'; import { loadYaml, clearYamlCache, validateUuid } from './parser';
import { resolveTheme, VALID_LAYOUTS } from './theme'; import { resolveTheme, VALID_LAYOUTS } from './theme';
import { ALBUM_SORT_MODES, isAlbumSortMode, type AlbumSortMode } from '../albumSort';
import { import {
slugify, slugify,
AppConfig, AppConfig,
AlbumEntryObject,
SubpageConfig, SubpageConfig,
SubpageSectionConfig, SubpageSectionConfig,
SubpageObjectValue, SubpageObjectValue,
@@ -82,6 +84,8 @@ export interface GalleryDerivation {
albumDescriptions: Record<string, string>; albumDescriptions: Record<string, string>;
albumPasswords: Record<string, string>; albumPasswords: Record<string, string>;
albumHeroImages: 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 albumDescriptions: Record<string, string> = {};
const albumPasswords: Record<string, string> = {}; const albumPasswords: Record<string, string> = {};
const albumHeroImages: Record<string, string> = {}; const albumHeroImages: Record<string, string> = {};
const albumSortModes: Record<string, AlbumSortMode> = {};
const albumManualOrders: Record<string, string[]> = {};
function processAlbumEntry( function processAlbumEntry(
entry: entry: string | Record<string, string | AlbumEntryObject>,
| string
| Record<
string,
string | { title: string; description?: string; password?: string; heroImage?: string }
>,
context: string, context: string,
): string { ): string {
if (typeof entry === 'string') { if (typeof entry === 'string') {
@@ -133,6 +134,31 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
`${context} heroImage for album ${validatedUuid}`, `${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; return validatedUuid;
} }
@@ -240,6 +266,8 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
albumDescriptions, albumDescriptions,
albumPasswords, albumPasswords,
albumHeroImages, albumHeroImages,
albumSortModes,
albumManualOrders,
}; };
} }
@@ -287,6 +315,8 @@ export function getConfig(): AppConfig {
albumDescriptions: {}, albumDescriptions: {},
albumPasswords: {}, albumPasswords: {},
albumHeroImages: {}, albumHeroImages: {},
albumSortModes: {},
albumManualOrders: {},
cacheTtl: env.CACHE_TTL * 1000, cacheTtl: env.CACHE_TTL * 1000,
staleMaxAge: env.STALE_MAX_AGE * 1000, staleMaxAge: env.STALE_MAX_AGE * 1000,
immichTimeoutMs: env.IMMICH_TIMEOUT_MS, immichTimeoutMs: env.IMMICH_TIMEOUT_MS,
@@ -306,6 +336,8 @@ export function getConfig(): AppConfig {
albumDescriptions, albumDescriptions,
albumPasswords, albumPasswords,
albumHeroImages, albumHeroImages,
albumSortModes,
albumManualOrders,
} = deriveGallery(gallery); } = deriveGallery(gallery);
const siteSeoTitle = settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery'; const siteSeoTitle = settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery';
@@ -378,6 +410,8 @@ export function getConfig(): AppConfig {
albumDescriptions, albumDescriptions,
albumPasswords, albumPasswords,
albumHeroImages, albumHeroImages,
albumSortModes,
albumManualOrders,
cacheTtl: env.CACHE_TTL * 1000, cacheTtl: env.CACHE_TTL * 1000,
staleMaxAge: env.STALE_MAX_AGE * 1000, staleMaxAge: env.STALE_MAX_AGE * 1000,
immichTimeoutMs: env.IMMICH_TIMEOUT_MS, immichTimeoutMs: env.IMMICH_TIMEOUT_MS,

View File

@@ -1,3 +1,5 @@
import type { AlbumSortMode } from '../albumSort';
export interface SubpageSectionConfig { export interface SubpageSectionConfig {
title: string; title: string;
slug: string; // auto-derived from title, used as anchor slug: string; // auto-derived from title, used as anchor
@@ -29,15 +31,14 @@ export interface SubpageObjectValue {
essayFile?: string; essayFile?: string;
essayText?: string; essayText?: string;
enabled?: boolean; enabled?: boolean;
albums?: Array< // Both album lists reuse AlbumEntryObject rather than inlining the shape:
string | Record<string, string | { title: string; description?: string; password?: string }> // 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<{ sections?: Array<{
title: string; title: string;
description?: string; description?: string;
albums: Array< albums: Array<string | Record<string, string | AlbumEntryObject>>;
string | Record<string, string | { title: string; description?: string; password?: string }>
>;
}>; }>;
} }
@@ -112,6 +113,10 @@ export interface AppConfig {
albumDescriptions: Record<string, string>; albumDescriptions: Record<string, string>;
albumPasswords: Record<string, string>; albumPasswords: Record<string, string>;
albumHeroImages: 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; cacheTtl: number;
/** How long (ms) an expired cache entry may still be served while Immich is unreachable. */ /** How long (ms) an expired cache entry may still be served while Immich is unreachable. */
staleMaxAge: number; staleMaxAge: number;
@@ -133,10 +138,18 @@ export interface AppConfig {
} }
export interface AlbumEntryObject { export interface AlbumEntryObject {
title: string; /** Optional: an entry may carry only a sort mode and no title override. */
title?: string;
description?: string; description?: string;
password?: string; password?: string;
heroImage?: 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 { export interface GalleryYaml {

View File

@@ -9,6 +9,7 @@
import { getConfig, slugify, type SubpageConfig } from './config'; import { getConfig, slugify, type SubpageConfig } from './config';
import { cache } from './cache'; import { cache } from './cache';
import { compareByCaptureTime, sortAlbumAssets, DEFAULT_ALBUM_SORT } from './albumSort';
// ── Types ────────────────────────────────────────────────────── // ── Types ──────────────────────────────────────────────────────
@@ -36,6 +37,8 @@ export interface ImmichAsset {
localDateTime?: string; localDateTime?: string;
exifInfo?: ImmichExifInfo; exifInfo?: ImmichExifInfo;
isTrashed: boolean; isTrashed: boolean;
/** Only surfaced in the admin pickers. Optional: older Immich responses omit it. */
isFavorite?: boolean;
} }
export interface ImmichExifInfo { export interface ImmichExifInfo {
@@ -544,7 +547,61 @@ class ImmichClient {
return assets; 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> { 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 // Security: only serve configured albums
if (!this.config.albums.includes(albumId)) { if (!this.config.albums.includes(albumId)) {
console.warn(`[Immich] Album ${albumId} is not in LIGHTBOX_ALBUMS`); 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 // order. Metadata search happens to default to fileCreatedAt desc, but
// that is not contractual — sort explicitly so 'asc' albums are right. // that is not contractual — sort explicitly so 'asc' albums are right.
// //
// Mirrors Immich's own timeline query: primary key `localDateTime` // This is the *canonical* order, and it is what goes into the cache.
// (capture time in the photographer's local zone), tie-broken by // A configured per-album sort is applied later, on the way out of
// `fileCreatedAt` (the same instant in UTC). The two only diverge for // getAlbum(); see withSort().
// albums spanning time zones, and there local time is what the Immich album.assets.sort(compareByCaptureTime(album.order === 'asc' ? 1 : -1));
// 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)),
);
const name = this.config.albumOverrides[album.id] ?? album.albumName; const name = this.config.albumOverrides[album.id] ?? album.albumName;
const description = this.config.albumDescriptions[album.id] ?? album.description ?? ''; 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. * Returns null if the token is invalid.
*/ */
export function decodeAssetId(token: string): string | null { 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 { try {
const key = getKey(); const key = getKey();