Files
instaarchive-viewer/src/components/ArchiveDashboard.tsx
T
ergosteurandClaude Opus 5 1d86fa3583 fix: security, performance and correctness pass; add sidecar archive support
Security
- Fix path traversal in GET /api/archives/:name/files. Express decodes route
  params after segment matching, so `..%2f..%2fetc` escaped ARCHIVES_DIR and
  returned a recursive listing of arbitrary directories.
- Add CSP and baseline security headers; disable x-powered-by.
- Stop baking GEMINI_API_KEY into the client bundle (the SDK was unused).
- Run the container as `node` instead of root.

Performance
- Add a directory-mtime-keyed archive index, warmed in the background and
  persisted. Listing 110k files went from ~52s to ~0.1s; the largest archive
  (24k files) serves in ~0.3s. Per-file stat over CIFS costs ~1.4ms and does
  not parallelise, so it is now done once rather than per request.
- Build media URLs from the File directly instead of
  `new Blob([await file.arrayBuffer()])`, which read every media file fully
  into memory (a 20GB archive tried to become 20GB of resident blobs).
- Track and revoke object URLs; previously none were ever revoked.
- Give `requestThumbnail` a stable identity so a completed thumbnail stops
  re-running the effect in every mounted thumbnail.
- Namespace IndexedDB keys so listing archives no longer deserializes every
  cached thumbnail blob, and thumbnails no longer collide across archives.
- Serve real file sizes: RemoteArchiveFile was constructed with size 0, which
  silently disabled high-res thumbnailing for every server archive.

Correctness
- Local archives cached media as blob: URLs, which die with the document, so
  a cached local archive restored as an archive of broken images. Media now
  carries a stable path and is rehydrated from a persisted directory handle
  (File System Access API), falling back to re-prompting for the folder.
- Fix permalinks: the URL-writing effect erased ?a= on mount before the
  archive list arrived to consume it, so deep links never resolved.
- Make cache invalidation detect nested changes via a directory signature.
- Add an error boundary and tolerate unparseable dates, which previously
  threw a RangeError and blanked the app.
- Default video to muted so autoplay is not blocked by Safari/Firefox.

Features
- Fold sidecar directories into their base profile: `<user> - reels`,
  `story - <user>` and `story highlights - <user> - <title>` now appear as
  reels, the story ring and Instagram-style highlight circles rather than as
  separate archives.

Housekeeping
- Add @types/react; React was previously type-checked against its JavaScript
  source, so `npm run lint` gave almost no type safety on components.
- Vendor fonts and PWA icons locally; the app made third-party CDN requests
  despite advertising offline support and local-only processing.
- Drop unused better-sqlite3 (a native module that broke `npm install`).
- Add vitest with 36 tests over the filename and directory-naming rules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uBWhwV3wFQ5MBCcMHHem7
2026-08-14 01:59:19 -04:00

156 lines
7.2 KiB
TypeScript

import React from 'react';
import {
FolderOpen,
Grid3X3,
Play,
Trash2,
Zap
} from 'lucide-react';
import { CacheData, ServerArchive } from '../types';
interface ArchiveDashboardProps {
archives: ServerArchive[];
localArchives?: CacheData[];
cachedArchives: Set<string>;
onSelect: (archive: ServerArchive) => void;
onLocalSelect: () => void;
onLocalCacheSelect: (archive: CacheData) => void;
onClearCache: (name: string) => void;
isScanning: boolean;
}
export const ArchiveDashboard: React.FC<ArchiveDashboardProps> = ({
archives,
localArchives = [],
cachedArchives,
onSelect,
onLocalSelect,
onLocalCacheSelect,
onClearCache,
isScanning
}) => {
return (
<div className="max-w-6xl mx-auto px-4 py-12 space-y-12">
<div className="text-center space-y-4">
<h2 className="text-4xl font-bold tracking-tight font-serif italic text-black/80">Archive Explorer</h2>
<p className="text-gray-500 max-w-xl mx-auto text-sm md:text-base leading-relaxed">
Browse hosted collections or open a local archive folder. All processing happens locally in your browser for maximum privacy.
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-8">
{/* Local Folder Card */}
<button
onClick={onLocalSelect}
disabled={isScanning}
className="aspect-[3/4] rounded-xl border-2 border-dashed border-gray-200 hover:border-blue-400 hover:bg-blue-50/50 transition-all flex flex-col items-center justify-center gap-4 group disabled:opacity-50 text-black"
>
<div className="w-12 h-12 rounded-full bg-gray-100 group-hover:bg-blue-100 flex items-center justify-center text-gray-400 group-hover:text-blue-500 transition-colors shadow-inner">
<FolderOpen size={24} />
</div>
<div className="text-center px-4">
<span className="font-bold text-sm block text-black/80">Open Local Folder</span>
<span className="text-[10px] text-gray-400 uppercase tracking-widest leading-tight block mt-1">Processed in Browser</span>
</div>
</button>
{/* Server Archives */}
{archives.map((archive) => {
const isCached = cachedArchives.has(archive.name);
return (
<div key={archive.path} className="relative group text-black">
<button
onClick={() => onSelect(archive)}
disabled={isScanning}
className="w-full aspect-[3/4] rounded-xl overflow-hidden bg-white shadow-sm border border-gray-100 hover:shadow-xl hover:scale-[1.02] transition-all flex flex-col text-left disabled:opacity-50"
>
<div className="flex-1 bg-gray-100 overflow-hidden relative">
{archive.thumbnail ? (
<img src={archive.thumbnail} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-gray-300">
<Grid3X3 size={48} strokeWidth={1} />
</div>
)}
<div className="absolute inset-0 bg-black/20 opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center">
<Play size={32} fill="white" className="text-white" />
</div>
{isCached && (
<div className="absolute top-2 left-2 bg-blue-500 text-white p-1 rounded-md shadow-lg flex items-center gap-1 text-[8px] font-bold uppercase tracking-wider z-10 pr-2 opacity-0 group-hover:opacity-100 transition-all">
<Zap size={10} fill="currentColor" />
Cached
</div>
)}
</div>
<div className="p-4 space-y-1">
<span className="font-bold text-sm block truncate uppercase tracking-tight text-black/80">{archive.name}</span>
<span className="text-[10px] text-gray-400 uppercase tracking-widest">
{archive.fileCount === null
? `${archive.sources?.length ?? 1} source${(archive.sources?.length ?? 1) === 1 ? '' : 's'}`
: `${archive.fileCount} items`}
</span>
</div>
</button>
{isCached && (
<button
onClick={(e) => {
e.stopPropagation();
onClearCache(archive.name);
}}
className="absolute top-2 right-2 p-2 bg-white/90 hover:bg-red-50 hover:text-red-500 text-gray-400 rounded-lg shadow-sm opacity-0 group-hover:opacity-100 transition-all z-20"
title="Clear Cache"
>
<Trash2 size={14} />
</button>
)}
</div>
);
})}
{/* Local Cached Archives */}
{localArchives.map((archive) => (
<div key={archive.name} className="relative group text-black">
<button
onClick={() => onLocalCacheSelect(archive)}
disabled={isScanning}
className="w-full aspect-[3/4] rounded-xl overflow-hidden bg-white shadow-sm border border-gray-100 hover:shadow-xl hover:scale-[1.02] transition-all flex flex-col text-left disabled:opacity-50"
>
<div className="flex-1 bg-gray-100 overflow-hidden relative text-black">
{archive.profileMetadata.profilePic ? (
<img src={archive.profileMetadata.profilePic} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-gray-300">
<Grid3X3 size={48} strokeWidth={1} />
</div>
)}
<div className="absolute inset-0 bg-black/20 opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center">
<FolderOpen size={32} fill="white" className="text-white" />
</div>
<div className="absolute bottom-2 left-2 bg-gray-800/80 text-white px-2 py-0.5 rounded text-[8px] font-bold uppercase tracking-widest backdrop-blur-sm">
Local Cache
</div>
</div>
<div className="p-4 space-y-1 text-black">
<span className="font-bold text-sm block truncate uppercase tracking-tight text-black/80">{archive.name}</span>
<span className="text-[10px] text-gray-400 uppercase tracking-widest">{archive.fileCount} indexed</span>
</div>
</button>
<button
onClick={(e) => {
e.stopPropagation();
onClearCache(archive.name);
}}
className="absolute top-2 right-2 p-2 bg-white/90 hover:bg-red-50 hover:text-red-500 text-gray-400 rounded-lg shadow-sm opacity-0 group-hover:opacity-100 transition-all z-20"
title="Clear Cache"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
</div>
);
};