diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 0000000..a1c3b66 --- /dev/null +++ b/.jules/sentinel.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e5874c..8d80f22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 [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 ### Added @@ -60,6 +85,12 @@ Releases up to and including v0.9.2 are documented in the were visible on top of each other. - **Docker** — health check targets `127.0.0.1` instead of `localhost`, base 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 preset named `botanica` that does not exist; copying it produced an `Unknown theme preset` error. It now uses `editorial`. diff --git a/app/admin/admin.css b/app/admin/admin.css index f28773e..503f9a0 100644 --- a/app/admin/admin.css +++ b/app/admin/admin.css @@ -1510,6 +1510,10 @@ .asset-picker-tab { flex: 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.45rem; padding: 0.7rem 1rem; background: none; border: none; @@ -1607,8 +1611,11 @@ position: absolute; top: 4px; left: 4px; - font-size: 0.7rem; - text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6); + display: flex; + 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 { @@ -1782,8 +1789,10 @@ 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 { - width: 12px; + width: 18px; height: 18px; } @@ -1924,6 +1933,17 @@ h3 .svg-icon { 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 ── */ .album-drawer-overlay { position: fixed; @@ -3821,3 +3841,140 @@ h3 .svg-icon { background: var(--admin-btn-primary-bg, #ffffff); 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); +} diff --git a/app/admin/components/AssetOrderEditor.tsx b/app/admin/components/AssetOrderEditor.tsx new file mode 100644 index 0000000..d998045 --- /dev/null +++ b/app/admin/components/AssetOrderEditor.tsx @@ -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 ( +
+ {asset.originalFileName} + {index + 1} + +
+ ); +} + +/** + * 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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [pinnedIds, setPinnedIds] = useState([]); + + 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(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 ( +
+
e.stopPropagation()}> +
+

Photo order — {albumName}

+ +
+ + {loading ? ( +
+
+
+ ) : error ? ( +

{error}

+ ) : ( +
+ {dropped > 0 && ( +

+ {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. +

+ )} + +
+
+

Pinned ({pinned.length})

+ {pinned.length > 0 && ( + + )} +
+ {pinned.length === 0 ? ( +

+ Nothing pinned yet — the album shows in Immich order. Pin the photos that should + open the album. +

+ ) : ( + + +
+ {pinned.map((asset, i) => ( + setPinnedIds(pinnedIds.filter((id) => id !== asset.id))} + /> + ))} +
+
+
+ )} +
+ +
+
+

Follows automatically ({unpinned.length})

+ {unpinned.length > 0 && ( + + )} +
+

+ These follow the pinned photos in the album’s Immich order. Click one to pin it. +

+
+ {unpinned.map((asset) => ( +
setPinnedIds([...pinnedIds, asset.id])} + > + {asset.originalFileName} + + +
+ ))} +
+
+
+ )} + +
+ + +
+
+
+ ); +} diff --git a/app/admin/components/AssetPicker.tsx b/app/admin/components/AssetPicker.tsx index bbae474..8f2226f 100644 --- a/app/admin/components/AssetPicker.tsx +++ b/app/admin/components/AssetPicker.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useEffect, useCallback, useRef } from 'react'; +import { IconFolder, IconImage, IconStar } from './Icons'; interface AssetInfo { id: string; @@ -126,20 +127,23 @@ export default function AssetPicker({ className={`asset-picker-tab ${tab === 'album' ? 'active' : ''}`} onClick={() => setTab('album')} > - 📂 This Album + + This Album )}
@@ -173,7 +177,9 @@ export default function AssetPicker({ /> {isUsed &&
✓
} {asset.isFavorite && !isUsed && ( -
⭐
+
+ +
)} ); diff --git a/app/admin/components/Icons.tsx b/app/admin/components/Icons.tsx index 6474097..6f1b6c1 100644 --- a/app/admin/components/Icons.tsx +++ b/app/admin/components/Icons.tsx @@ -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 ( + + + + ); +} + +export function IconImage({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { + return ( + + + + + + ); +} + export function IconBarChart({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { return ( @@ -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 ( + + + + ); +} + +export function IconPlus({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { + return ( + + + + ); +} + +export function IconHome({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { + return ( + + + + + ); +} + +export function IconCopy({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { + return ( + + + + + ); +} + export function IconQuote({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { return ( diff --git a/app/admin/components/PageBuilder.tsx b/app/admin/components/PageBuilder.tsx index 166d420..5603ae4 100644 --- a/app/admin/components/PageBuilder.tsx +++ b/app/admin/components/PageBuilder.tsx @@ -21,97 +21,43 @@ import { import { CSS } from '@dnd-kit/utilities'; import AlbumPicker from './AlbumPicker'; import AssetPicker from './AssetPicker'; +import AssetOrderEditor from './AssetOrderEditor'; 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 = { - Drag: () => ( - - - - - - - - - ), - Edit: () => ( - - - - - ), - Trash: () => ( - - - - - - ), - Close: () => ( - - - - ), - Folder: () => ( - - - - ), - Camera: () => ( - - - - - ), - ExternalLink: () => ( - - - - - - ), - Search: () => ( - - - - - ), - Lock: () => ( - - - - - ), - Plus: () => ( - - - - ), - Home: () => ( - - - - - ), - Copy: () => ( - - - - - ), - Check: () => ( - - - - ), - Image: () => ( - - - - - - ), +/** + * Labels for the per-album sort select. Order matches ALBUM_SORT_MODES. + * + * Plain text, no emoji: an
- +
{index + 1}
@@ -334,7 +295,7 @@ function SortableSubpageTile({ {...attributes} >
- +
{sp.enabled === false && ( @@ -345,7 +306,7 @@ function SortableSubpageTile({ {sp.password && ( - Password + Password )} @@ -354,11 +315,11 @@ function SortableSubpageTile({ ) : (
- +
)}
- Edit Page + Edit Page
@@ -367,7 +328,7 @@ function SortableSubpageTile({ /{slug}
- {totalAlbums} album{totalAlbums !== 1 ? 's' : ''} + {totalAlbums} album{totalAlbums !== 1 ? 's' : ''} @@ -396,6 +357,12 @@ export default function PageBuilder() { currentAssetIds?: string[]; title?: string; } | null>(null); + const [orderEditorTarget, setOrderEditorTarget] = useState<{ + albumId: string; + albumName: string; + assetOrder: string[]; + onSave: (assetOrder: string[]) => void; + } | null>(null); const [searchQuery, setSearchQuery] = useState(''); const [editingAlbumAddress, setEditingAlbumAddress] = useState(null); @@ -929,7 +896,7 @@ export default function PageBuilder() { {/* Search Bar */}
- +
-

Homepage Hero

+

Homepage Hero

-

📷 Standalone Albums

+

+ + Standalone Albums +

@@ -1107,7 +1080,7 @@ export default function PageBuilder() { onClick={() => setDrawerMode('edit')} style={{ padding: '0.2rem 0.6rem', fontSize: '0.75rem' }} > - Edit + Edit
@@ -1162,7 +1135,7 @@ export default function PageBuilder() { {thumb ? ( ) : ( -
+
)}
@@ -1186,7 +1159,7 @@ export default function PageBuilder() { {thumb ? ( ) : ( -
+
)}
@@ -1242,7 +1215,7 @@ export default function PageBuilder() {
- + - - - - - - + {/* + Plain text, no emoji: an + + + + +
@@ -1395,7 +1373,7 @@ export default function PageBuilder() { onClick={() => removeSection(spIndex, secIndex)} title="Remove section" > - +
@@ -1465,7 +1443,7 @@ export default function PageBuilder() { setExpandedSubpage(null); }} > - Delete Subpage + Delete Subpage
@@ -1523,7 +1501,7 @@ export default function PageBuilder() { ) : (
- +
)}
@@ -1562,7 +1540,7 @@ export default function PageBuilder() { }} title="Copy UUID" > - + @@ -1613,7 +1591,7 @@ export default function PageBuilder() { onClick={() => onUpdate({ heroImage: undefined })} title="Remove custom hero image" > - + ) : ( @@ -1633,10 +1611,64 @@ export default function PageBuilder() { }) } > - {album.heroImage ? 'Change Image' : 'Pick Hero Image'} + {album.heroImage ? 'Change Image' : 'Pick Hero Image'} + + {/* Photo order */} +
+ + + + {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.'} + + + {album.sort === 'manual' && ( +
+ +
+ )} +
@@ -1645,7 +1677,7 @@ export default function PageBuilder() { onClick={onRemove} style={{ maxWidth: '160px' }} > - Remove Album + Remove Album
); @@ -1797,14 +1840,14 @@ function AlbumCard({
{dragListeners && (
- +
)} {heroThumb ? ( ) : (
- +
)}
@@ -1813,14 +1856,14 @@ function AlbumCard({ onClick={onEdit} title="Edit details" > - +
@@ -1832,12 +1875,17 @@ function AlbumCard({
{hasPassword && ( - + )} {album.heroImage && ( - + + + )} + {album.sort && album.sort !== DEFAULT_ALBUM_SORT && ( + + {album.sort === 'manual' ? : album.sort} )}
diff --git a/app/api/admin/albums/[albumId]/assets/__tests__/route.test.ts b/app/api/admin/albums/[albumId]/assets/__tests__/route.test.ts new file mode 100644 index 0000000..edfc14e --- /dev/null +++ b/app/api/admin/albums/[albumId]/assets/__tests__/route.test.ts @@ -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(); + }); +}); diff --git a/app/api/admin/albums/[albumId]/assets/route.ts b/app/api/admin/albums/[albumId]/assets/route.ts index 1d2310f..a463187 100644 --- a/app/api/admin/albums/[albumId]/assets/route.ts +++ b/app/api/admin/albums/[albumId]/assets/route.ts @@ -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 { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth'; import { getConfig } from '@/lib/config'; +import { immich } from '@/lib/immich'; 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( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ albumId: string }> }, ) { if (!isAdminEnabled()) { @@ -42,27 +33,27 @@ export async function GET( 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 { - const res = await fetch(`${config.immich.apiUrl}/albums/${encodeURIComponent(albumId)}`, { - headers: { - 'x-api-key': config.immich.apiKey, - Accept: 'application/json', - }, - }); + // Via the client, not a bare fetch of `GET /albums/:id`: Immich 3.x no + // longer embeds assets in that response, so reading `album.assets` returned + // nothing (and threw outright when the key was absent). getAlbumAssetsRaw() + // 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) { - return NextResponse.json({ error: `Immich API returned ${res.status}` }, { status: 502 }); - } - - const album = (await res.json()) as ImmichAlbumDetail; - - const assets = album.assets - .filter((a) => a.type === 'IMAGE') + const assets = all + .filter((a) => a.type === 'IMAGE' || (includeVideos && a.type === 'VIDEO')) .map((a) => ({ id: a.id, + type: a.type, originalFileName: a.originalFileName, fileCreatedAt: a.fileCreatedAt, - isFavorite: a.isFavorite, + isFavorite: a.isFavorite ?? false, })); return NextResponse.json({ assets, nextPage: null }); diff --git a/content/gallery.yaml.example b/content/gallery.yaml.example index 16aeff3..cf97359 100644 --- a/content/gallery.yaml.example +++ b/content/gallery.yaml.example @@ -8,9 +8,39 @@ hero: - "your-asset-uuid-2" # 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: - - "your-album-uuid-1" - - "your-album-uuid-2" + - "your-album-uuid-1" # no sort key → Immich order, exactly as before + - "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: diff --git a/docs/gallery-config.md b/docs/gallery-config.md index 19428d0..8be2d88 100644 --- a/docs/gallery-config.md +++ b/docs/gallery-config.md @@ -94,7 +94,7 @@ Each section can have: | `description` | string | ➖ | Optional subline under the heading | | `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 albums: @@ -103,6 +103,8 @@ albums: - 'album-uuid': title: My Private Title 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:** @@ -133,6 +135,47 @@ subpages: > [!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. +## 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 Configure the photo grid globally or per-subpage: diff --git a/lib/__tests__/albumSort.test.ts b/lib/__tests__/albumSort.test.ts new file mode 100644 index 0000000..331dfdf --- /dev/null +++ b/lib/__tests__/albumSort.test.ts @@ -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 { + 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); + }); +}); diff --git a/lib/__tests__/config-hero-uuid.test.ts b/lib/__tests__/config-hero-uuid.test.ts index a61d9a4..04a5a8a 100644 --- a/lib/__tests__/config-hero-uuid.test.ts +++ b/lib/__tests__/config-hero-uuid.test.ts @@ -76,6 +76,27 @@ describe('gallery.yaml per-album heroImage', () => { expect(getConfig().albumHeroImages[ALBUM_ID]).toBe(HERO_ID); }); + // Same class of ID, same requirement: a pinned asset ID from `sort: manual` + // is written by the admin panel and can be hand-edited, so it goes through + // the same gate. A substituted zero UUID simply never matches an asset in the + // album, so the pin is ignored rather than corrupting the order. + it('routes manual assetOrder IDs through validateUuid, naming the album', () => { + vi.mocked(loadYaml).mockImplementation((filename: string) => { + if (filename === 'gallery.yaml') { + return { albums: [{ [ALBUM_ID]: { sort: 'manual', assetOrder: [HERO_ID] } }] }; + } + if (filename === 'settings.yaml') return {}; + return null; + }); + invalidateConfigCache(); + + const config = getConfig(); + + expect(config.albumManualOrders[ALBUM_ID]).toEqual([HERO_ID]); + const call = validateUuidSpy.mock.calls.find((c) => c[0] === HERO_ID); + expect(call?.[1]).toContain(ALBUM_ID); + }); + // The substitution the wiring above depends on: validateUuid never throws, so // a bad ID degrades to a 404 image rather than taking the whole page down. it('real validateUuid warns and substitutes rather than throwing', async () => { diff --git a/lib/__tests__/derive-gallery.test.ts b/lib/__tests__/derive-gallery.test.ts index c8582f4..1ee6dba 100644 --- a/lib/__tests__/derive-gallery.test.ts +++ b/lib/__tests__/derive-gallery.test.ts @@ -104,6 +104,77 @@ describe('deriveGallery accepts valid structures', () => { expect(result.albumHeroImages[A]).toBe(B); }); + it('collects the per-album sort mode', () => { + const result = deriveGallery({ + albums: [{ [A]: { title: 'Series', sort: 'filename' } }, B], + } as unknown as GalleryYaml); + + expect(result.albumSortModes[A]).toBe('filename'); + // Absent means the default; an explicit entry would make `immich` and + // "never configured" indistinguishable downstream. + expect(result.albumSortModes[B]).toBeUndefined(); + }); + + // A typo would otherwise be invisible: the album would keep rendering in + // Immich order and nothing would say why. Throwing is also what gives the + // admin PUT its 400, via the deriveGallery dry-run. + it('rejects an unknown sort mode, naming the album and the valid values', () => { + expect(() => + deriveGallery({ albums: [{ [A]: { sort: 'alphabetical' } }] } as unknown as GalleryYaml), + ).toThrow(/alphabetical/); + + expect(() => + deriveGallery({ albums: [{ [A]: { sort: 'alphabetical' } }] } as unknown as GalleryYaml), + ).toThrow(new RegExp(A)); + + expect(() => + deriveGallery({ albums: [{ [A]: { sort: 'alphabetical' } }] } as unknown as GalleryYaml), + ).toThrow(/filename/); + }); + + it('collects the manual asset order, preserving its order', () => { + const result = deriveGallery({ + albums: [{ [A]: { sort: 'manual', assetOrder: [B, A] } }], + } as unknown as GalleryYaml); + + expect(result.albumManualOrders[A]).toEqual([B, A]); + }); + + // Kept regardless of the mode, so toggling manual → newest → manual in the + // admin panel does not silently destroy a hand-curated order. + it('keeps the asset order even when the mode is not manual', () => { + const result = deriveGallery({ + albums: [{ [A]: { sort: 'newest', assetOrder: [B] } }], + } as unknown as GalleryYaml); + + expect(result.albumSortModes[A]).toBe('newest'); + expect(result.albumManualOrders[A]).toEqual([B]); + }); + + it('ignores an empty asset order rather than storing one', () => { + const result = deriveGallery({ + albums: [{ [A]: { sort: 'manual', assetOrder: [] } }], + } as unknown as GalleryYaml); + + expect(result.albumManualOrders[A]).toBeUndefined(); + }); + + // processAlbumEntry is reached from four places; only the array-subpage path + // was covered before, so a regression in the others would go unnoticed. + it('applies the sort mode to section and map-style subpage albums too', () => { + const sectioned = deriveGallery({ + subpages: [ + { name: 'Work', sections: [{ title: 'Recent', albums: [{ [A]: { sort: 'oldest' } }] }] }, + ], + } as unknown as GalleryYaml); + expect(sectioned.albumSortModes[A]).toBe('oldest'); + + const mapStyle = deriveGallery({ + subpages: { Trips: { albums: [{ [B]: { sort: 'filename' } }] } }, + } as unknown as GalleryYaml); + expect(mapStyle.albumSortModes[B]).toBe('filename'); + }); + it('parses enabled property on subpages correctly', () => { const result = deriveGallery({ subpages: [ diff --git a/lib/__tests__/immich.test.ts b/lib/__tests__/immich.test.ts index 92820ef..4c44e4c 100644 --- a/lib/__tests__/immich.test.ts +++ b/lib/__tests__/immich.test.ts @@ -2,6 +2,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { immich, ImmichUnavailableError } from '../immich'; import * as config from '../config'; import { cache } from '../cache'; +import type { AlbumSortMode } from '../albumSort'; + +// Per-album sort is read on every getAlbum(), so the tests below need to be +// able to change it between calls without rebuilding the whole mock. +const albumSortModes: Record = {}; +const albumManualOrders: Record = {}; // Mock the config by wrapping it in a factory vi.mock('../config', async () => { @@ -16,6 +22,8 @@ vi.mock('../config', async () => { subpages: [], albumOverrides: { 'album-1': 'Override Name' }, albumDescriptions: {}, + albumSortModes, + albumManualOrders, // Non-zero: a 0 TTL expires an entry in the same millisecond it is // written, which would make any caching assertion flaky. Isolation comes // from the cache.clear() in beforeEach, not from an unusable TTL. @@ -37,6 +45,8 @@ describe('ImmichClient', () => { // getAlbum() memoises into the module-level LRU; without this, later tests // read a previous test's album and never reach the mocked fetch. cache.clear(); + for (const key of Object.keys(albumSortModes)) delete albumSortModes[key]; + for (const key of Object.keys(albumManualOrders)) delete albumManualOrders[key]; }); describe('request()', () => { @@ -579,6 +589,69 @@ describe('ImmichClient', () => { const album = await immich.getAlbum('album-1'); expect(album?.assets.map((a) => a.id)).toEqual(['frame-1', 'frame-2', 'frame-3']); }); + + /** + * The configured sort is applied on the way out of getAlbum(), so the LRU + * keeps one canonical Immich-ordered copy per album. Sorting the cached + * array in place would reorder the shared entry, and the next reader with a + * different mode would then be sorting an already-sorted array. + */ + describe('per-album sort', () => { + const items = [ + { ...asset('b', '2024-02-01T00:00:00Z'), originalFileName: 'IMG_2.jpg' }, + { ...asset('c', '2024-03-01T00:00:00Z'), originalFileName: 'IMG_10.jpg' }, + { ...asset('a', '2024-01-01T00:00:00Z'), originalFileName: 'IMG_1.jpg' }, + ]; + + it('overrides the Immich direction', async () => { + albumSortModes['album-1'] = 'oldest'; + mockAlbumApi({ order: 'desc', pages: [{ items, nextPage: null }] }); + + const album = await immich.getAlbum('album-1'); + expect(album?.assets.map((a) => a.id)).toEqual(['a', 'b', 'c']); + }); + + it('applies the manual pin order', async () => { + albumSortModes['album-1'] = 'manual'; + albumManualOrders['album-1'] = ['c']; + mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] }); + + const album = await immich.getAlbum('album-1'); + expect(album?.assets.map((a) => a.id)).toEqual(['c', 'a', 'b']); + }); + + it('leaves the cached album in canonical order', async () => { + albumSortModes['album-1'] = 'filename'; + mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] }); + + const first = await immich.getAlbum('album-1'); + expect(first?.assets.map((a) => a.id)).toEqual(['a', 'b', 'c']); // IMG_1, IMG_2, IMG_10 + + // Same cache entry, different mode: if the first call had sorted the + // stored array, the album would still be in filename order here. + albumSortModes['album-1'] = 'newest'; + const second = await immich.getAlbum('album-1'); + expect(second?.assets.map((a) => a.id)).toEqual(['c', 'b', 'a']); + expect(second?.assets).not.toBe(first?.assets); + }); + + it('gives concurrent callers independent arrays', async () => { + albumSortModes['album-1'] = 'manual'; + albumManualOrders['album-1'] = ['b']; + mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] }); + + // Coalesced through the same pending promise, but each caller applies + // the sort itself — so neither can be handed the other's array. + const [one, two] = await Promise.all([ + immich.getAlbum('album-1'), + immich.getAlbum('album-1'), + ]); + + expect(one?.assets.map((a) => a.id)).toEqual(['b', 'a', 'c']); + expect(two?.assets.map((a) => a.id)).toEqual(['b', 'a', 'c']); + expect(one?.assets).not.toBe(two?.assets); + }); + }); }); }); diff --git a/lib/albumSort.ts b/lib/albumSort.ts new file mode 100644 index 0000000..88939c8 --- /dev/null +++ b/lib/albumSort.ts @@ -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( + 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(); + 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)); + } +} diff --git a/lib/config/index.ts b/lib/config/index.ts index fc3fd94..bf3d8a9 100644 --- a/lib/config/index.ts +++ b/lib/config/index.ts @@ -2,9 +2,11 @@ import { env } from '../env'; import { resolveAuthSecret } from '../secret'; import { loadYaml, clearYamlCache, validateUuid } from './parser'; import { resolveTheme, VALID_LAYOUTS } from './theme'; +import { ALBUM_SORT_MODES, isAlbumSortMode, type AlbumSortMode } from '../albumSort'; import { slugify, AppConfig, + AlbumEntryObject, SubpageConfig, SubpageSectionConfig, SubpageObjectValue, @@ -82,6 +84,8 @@ export interface GalleryDerivation { albumDescriptions: Record; albumPasswords: Record; albumHeroImages: Record; + albumSortModes: Record; + albumManualOrders: Record; } /** @@ -99,14 +103,11 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation { const albumDescriptions: Record = {}; const albumPasswords: Record = {}; const albumHeroImages: Record = {}; + const albumSortModes: Record = {}; + const albumManualOrders: Record = {}; function processAlbumEntry( - entry: - | string - | Record< - string, - string | { title: string; description?: string; password?: string; heroImage?: string } - >, + entry: string | Record, context: string, ): string { if (typeof entry === 'string') { @@ -133,6 +134,31 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation { `${context} heroImage for album ${validatedUuid}`, ); } + + // Throw rather than fall back to the default: a typo would otherwise be + // invisible — the album would just keep rendering in Immich order and the + // owner would be left wondering why the setting does nothing. Throwing + // also gives the admin PUT its 400 for free via the deriveGallery + // dry-run, and getConfigOrNull() degrades a hand-edited file to the setup + // screen rather than taking the site down. Name the album and the legal + // values so the log line is enough to fix it. + if (value.sort != null) { + if (!isAlbumSortMode(value.sort)) { + throw new Error( + `${context}: album ${validatedUuid} has an unknown sort "${value.sort}". ` + + `Valid values are: ${ALBUM_SORT_MODES.join(', ')}.`, + ); + } + albumSortModes[validatedUuid] = value.sort; + } + + // Kept regardless of the mode, so switching manual → newest → manual does + // not silently destroy a hand-curated order. + if (value.assetOrder?.length) { + albumManualOrders[validatedUuid] = value.assetOrder.map((assetId, i) => + validateUuid(assetId, `${context} assetOrder[${i}] for album ${validatedUuid}`), + ); + } } return validatedUuid; } @@ -240,6 +266,8 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation { albumDescriptions, albumPasswords, albumHeroImages, + albumSortModes, + albumManualOrders, }; } @@ -287,6 +315,8 @@ export function getConfig(): AppConfig { albumDescriptions: {}, albumPasswords: {}, albumHeroImages: {}, + albumSortModes: {}, + albumManualOrders: {}, cacheTtl: env.CACHE_TTL * 1000, staleMaxAge: env.STALE_MAX_AGE * 1000, immichTimeoutMs: env.IMMICH_TIMEOUT_MS, @@ -306,6 +336,8 @@ export function getConfig(): AppConfig { albumDescriptions, albumPasswords, albumHeroImages, + albumSortModes, + albumManualOrders, } = deriveGallery(gallery); const siteSeoTitle = settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery'; @@ -378,6 +410,8 @@ export function getConfig(): AppConfig { albumDescriptions, albumPasswords, albumHeroImages, + albumSortModes, + albumManualOrders, cacheTtl: env.CACHE_TTL * 1000, staleMaxAge: env.STALE_MAX_AGE * 1000, immichTimeoutMs: env.IMMICH_TIMEOUT_MS, diff --git a/lib/config/schema.ts b/lib/config/schema.ts index 8cf3077..e0314ba 100644 --- a/lib/config/schema.ts +++ b/lib/config/schema.ts @@ -1,3 +1,5 @@ +import type { AlbumSortMode } from '../albumSort'; + export interface SubpageSectionConfig { title: string; slug: string; // auto-derived from title, used as anchor @@ -29,15 +31,14 @@ export interface SubpageObjectValue { essayFile?: string; essayText?: string; enabled?: boolean; - albums?: Array< - string | Record - >; + // Both album lists reuse AlbumEntryObject rather than inlining the shape: + // the inline copies had already drifted (they never gained `heroImage`), and + // this path is live for map-style subpages. + albums?: Array>; sections?: Array<{ title: string; description?: string; - albums: Array< - string | Record - >; + albums: Array>; }>; } @@ -112,6 +113,10 @@ export interface AppConfig { albumDescriptions: Record; albumPasswords: Record; albumHeroImages: Record; + /** Per-album sort mode. Absent means `immich` — the album's own order. */ + albumSortModes: Record; + /** Pinned asset UUIDs per album for `sort: manual`, in display order. */ + albumManualOrders: Record; cacheTtl: number; /** How long (ms) an expired cache entry may still be served while Immich is unreachable. */ staleMaxAge: number; @@ -133,10 +138,18 @@ export interface AppConfig { } export interface AlbumEntryObject { - title: string; + /** Optional: an entry may carry only a sort mode and no title override. */ + title?: string; description?: string; password?: string; heroImage?: string; + /** + * Raw YAML shape — narrowed to `AlbumSortMode` in deriveGallery(), which is + * where an unknown value has to be rejected with a message a human can act on. + */ + sort?: string; + /** Pinned asset UUIDs for `sort: manual`. Everything else follows automatically. */ + assetOrder?: string[]; } export interface GalleryYaml { diff --git a/lib/immich.ts b/lib/immich.ts index cd4ab3b..ede8717 100644 --- a/lib/immich.ts +++ b/lib/immich.ts @@ -9,6 +9,7 @@ import { getConfig, slugify, type SubpageConfig } from './config'; import { cache } from './cache'; +import { compareByCaptureTime, sortAlbumAssets, DEFAULT_ALBUM_SORT } from './albumSort'; // ── Types ────────────────────────────────────────────────────── @@ -36,6 +37,8 @@ export interface ImmichAsset { localDateTime?: string; exifInfo?: ImmichExifInfo; isTrashed: boolean; + /** Only surfaced in the admin pickers. Optional: older Immich responses omit it. */ + isFavorite?: boolean; } export interface ImmichExifInfo { @@ -544,7 +547,61 @@ class ImmichClient { return assets; } + /** + * Every asset of an album, in the album's canonical Immich order, bypassing + * the allowlist. + * + * For the admin panel only. `getAlbum()` refuses albums that are not in the + * saved config, which is right for the public site but wrong for the page + * builder: an album just dragged in has not been saved yet, so the picker + * and the reorder editor would come up empty. Admin auth is the gate here. + */ + async getAlbumAssetsRaw(albumId: string): Promise { + const [album, assets] = await Promise.all([ + this.request(`/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 { + 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 { // Security: only serve configured albums if (!this.config.albums.includes(albumId)) { console.warn(`[Immich] Album ${albumId} is not in LIGHTBOX_ALBUMS`); @@ -593,25 +650,10 @@ class ImmichClient { // order. Metadata search happens to default to fileCreatedAt desc, but // that is not contractual — sort explicitly so 'asc' albums are right. // - // Mirrors Immich's own timeline query: primary key `localDateTime` - // (capture time in the photographer's local zone), tie-broken by - // `fileCreatedAt` (the same instant in UTC). The two only diverge for - // albums spanning time zones, and there local time is what the Immich - // UI shows. Sorting on `exifInfo.dateTimeOriginal` instead would be - // near-pointless: Immich already derives `fileCreatedAt` from that - // same EXIF chain, and it is null for assets without EXIF. - // - // Compare parsed instants, not strings — string order breaks on - // fractional seconds ('.' collates before '+'), which misorders bursts - // where only some frames carry sub-second precision. Unparseable dates - // fall back to 0 rather than NaN, which would make sort() incoherent. - const dir = album.order === 'asc' ? 1 : -1; - const ts = (value: string | undefined) => Date.parse(value ?? '') || 0; - album.assets.sort( - (a, b) => - dir * (ts(a.localDateTime) - ts(b.localDateTime)) || - dir * (ts(a.fileCreatedAt) - ts(b.fileCreatedAt)), - ); + // This is the *canonical* order, and it is what goes into the cache. + // A configured per-album sort is applied later, on the way out of + // getAlbum(); see withSort(). + album.assets.sort(compareByCaptureTime(album.order === 'asc' ? 1 : -1)); const name = this.config.albumOverrides[album.id] ?? album.albumName; const description = this.config.albumDescriptions[album.id] ?? album.description ?? ''; diff --git a/lib/tokens.ts b/lib/tokens.ts index 646419d..b196f4b 100644 --- a/lib/tokens.ts +++ b/lib/tokens.ts @@ -45,6 +45,10 @@ export function encodeAssetId(assetId: string): string { * Returns null if the token is invalid. */ export function decodeAssetId(token: string): string | null { + // 🛡️ SECURITY: Enforce maximum token length to prevent memory exhaustion DoS + // A v2 token for a 36-char UUID is ~110 chars. 256 is generous but safe. + if (token.length > 256) return null; + try { const key = getKey();