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
This commit is contained in:
ergosteur
2026-08-14 01:59:19 -04:00
co-authored by Claude Opus 5
parent 0ba7a0d9ad
commit 1d86fa3583
38 changed files with 2291 additions and 1638 deletions
+26 -17
View File
@@ -7,34 +7,39 @@ import {
X
} from 'lucide-react';
import { motion } from 'motion/react';
import { format, parseISO } from 'date-fns';
import { Post } from '../types';
import { cn } from '../lib/utils';
import { cn, formatDateSafe } from '../lib/utils';
interface StoryViewerProps {
stories: Post[];
onClose: () => void;
profilePic: string | null;
/** Highlight name, shown in place of the date when viewing a highlight. */
title?: string;
}
export const StoryViewer: React.FC<StoryViewerProps> = ({
stories,
export const StoryViewer: React.FC<StoryViewerProps> = ({
stories,
onClose,
profilePic
profilePic,
title
}) => {
const [currentStoryIndex, setCurrentStoryIndex] = useState(0);
const [progress, setProgress] = useState(0);
const [isMuted, setIsMuted] = useState(false);
// Start muted: Safari and Firefox refuse to autoplay audible media, which
// would stall the reel on its first video.
const [isMuted, setIsMuted] = useState(true);
const videoRef = useRef<HTMLVideoElement>(null);
const story = stories[currentStoryIndex];
const primary = story?.media?.[0];
useEffect(() => {
setProgress(0);
let duration = 5000;
const interval = 50;
const updateProgress = () => {
if (story.media[0].type === 'video' && videoRef.current) {
if (primary?.type === 'video' && videoRef.current) {
const currentTime = videoRef.current.currentTime;
const totalTime = videoRef.current.duration;
if (totalTime) {
@@ -54,7 +59,7 @@ export const StoryViewer: React.FC<StoryViewerProps> = ({
}, interval);
return () => clearInterval(timer);
}, [currentStoryIndex, story.media]);
}, [currentStoryIndex, primary]);
useEffect(() => {
if (progress >= 100) {
@@ -80,6 +85,9 @@ export const StoryViewer: React.FC<StoryViewerProps> = ({
}
};
// An empty or exhausted reel has nothing to show; bail before dereferencing.
if (!story || !primary) return null;
return (
<motion.div
initial={{ opacity: 0 }}
@@ -90,8 +98,8 @@ export const StoryViewer: React.FC<StoryViewerProps> = ({
>
<div className="absolute inset-0 z-0 text-white">
<img
src={story.media[0].url}
alt=""
src={primary.url}
alt=""
className="w-full h-full object-cover blur-3xl opacity-30"
/>
</div>
@@ -146,12 +154,13 @@ export const StoryViewer: React.FC<StoryViewerProps> = ({
</div>
<div className="flex items-center gap-2 text-white">
<span className="text-xs font-semibold">{story.username}</span>
<span className="text-[10px] opacity-60 font-medium">{format(parseISO(story.date), 'MMM d')}</span>
{title && <span className="text-[10px] opacity-80 font-medium truncate max-w-[120px]">{title}</span>}
<span className="text-[10px] opacity-60 font-medium">{formatDateSafe(story.date, 'MMM d')}</span>
</div>
</div>
<div className="flex items-center gap-1 text-white">
{story.media[0].type === 'video' && (
{primary.type === 'video' && (
<button
onClick={(e) => { e.stopPropagation(); setIsMuted(!isMuted); }}
className="p-2 hover:bg-white/10 rounded-full transition-colors text-white"
@@ -166,10 +175,10 @@ export const StoryViewer: React.FC<StoryViewerProps> = ({
</div>
<div className="w-full h-full flex items-center justify-center pointer-events-none text-white">
{story.media[0].type === 'video' ? (
{primary.type === 'video' ? (
<video
ref={videoRef}
src={story.media[0].url}
src={primary.url}
className="w-full h-full object-contain"
autoPlay
muted={isMuted}
@@ -179,8 +188,8 @@ export const StoryViewer: React.FC<StoryViewerProps> = ({
/>
) : (
<img
src={story.media[0].url}
alt=""
src={primary.url}
alt=""
className="w-full h-full object-contain"
referrerPolicy="no-referrer"
/>