Files
immich-folio/app/admin/components/AssetOrderEditor.tsx
Ralksta d4d8c8b42f 🛡️ 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>
2026-08-14 11:10:42 +02:00

278 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}