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:
co-authored by
Claude Opus 5
parent
0ba7a0d9ad
commit
1d86fa3583
@@ -6,15 +6,15 @@ import {
|
||||
Trash2,
|
||||
Zap
|
||||
} from 'lucide-react';
|
||||
import { ServerArchive } from '../types';
|
||||
import { CacheData, ServerArchive } from '../types';
|
||||
|
||||
interface ArchiveDashboardProps {
|
||||
archives: ServerArchive[];
|
||||
localArchives?: any[];
|
||||
localArchives?: CacheData[];
|
||||
cachedArchives: Set<string>;
|
||||
onSelect: (archive: ServerArchive) => void;
|
||||
onLocalSelect: () => void;
|
||||
onLocalCacheSelect: (archive: any) => void;
|
||||
onLocalCacheSelect: (archive: CacheData) => void;
|
||||
onClearCache: (name: string) => void;
|
||||
isScanning: boolean;
|
||||
}
|
||||
@@ -85,7 +85,11 @@ export const ArchiveDashboard: React.FC<ArchiveDashboardProps> = ({
|
||||
</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} items</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>
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface Props { children: React.ReactNode }
|
||||
interface State { error: Error | null }
|
||||
|
||||
/**
|
||||
* Keeps one bad archive item from blanking the whole app.
|
||||
*
|
||||
* Post data is derived from filenames and arbitrary archive JSON, so a single
|
||||
* malformed record used to be able to throw during render and take the entire
|
||||
* tree down with it.
|
||||
*/
|
||||
export class ErrorBoundary extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
console.error('[ErrorBoundary] Render failed:', error, info.componentStack);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.error) return this.props.children;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-6">
|
||||
<div className="max-w-md w-full bg-white rounded-2xl shadow-sm border border-gray-100 p-8 space-y-4 text-center">
|
||||
<div className="w-12 h-12 mx-auto rounded-full bg-red-50 text-red-500 flex items-center justify-center">
|
||||
<AlertTriangle size={24} />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-black/80">Something went wrong</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
This archive could not be rendered. Reloading usually clears it; if it
|
||||
persists, clear the cached copy from the archive explorer.
|
||||
</p>
|
||||
<pre className="text-[11px] text-left text-gray-400 bg-gray-50 rounded-lg p-3 overflow-x-auto">
|
||||
{this.state.error.message}
|
||||
</pre>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-2 rounded-lg text-sm font-semibold transition-colors"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import { MediaFile } from '../types';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
export const MediaRenderer = ({ file, className, isFullView }: { file: MediaFile; className?: string; isFullView?: boolean }) => {
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
// Start muted so autoplay is not blocked by Safari/Firefox policy.
|
||||
const [isMuted, setIsMuted] = useState(true);
|
||||
const sizingClass = isFullView ? "w-full h-auto block" : "w-full h-full object-cover";
|
||||
const mediaStyle = { transform: 'translateZ(0)' };
|
||||
|
||||
|
||||
@@ -10,9 +10,8 @@ import {
|
||||
Bookmark
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { Post } from '../types';
|
||||
import { cn } from '../lib/utils';
|
||||
import { cn, formatDateSafe } from '../lib/utils';
|
||||
import { MediaRenderer } from './MediaRenderer';
|
||||
|
||||
interface PostModalProps {
|
||||
@@ -150,7 +149,7 @@ export const PostModal: React.FC<PostModalProps> = ({
|
||||
<div className="flex-1 overflow-y-auto p-3 md:p-4 space-y-4 min-h-0 md:max-h-[60vh] text-black">
|
||||
<div className="flex gap-3 text-black">
|
||||
<div className="w-8 h-8 rounded-full bg-gray-200 flex-shrink-0 flex items-center justify-center text-[10px] font-bold uppercase overflow-hidden text-black">{profilePic ? <img src={profilePic} alt="" className="w-full h-full object-cover" referrerPolicy="no-referrer" /> : <span className="text-black">{post.username[0]}</span>}</div>
|
||||
<div className="text-sm text-black"><span className="font-semibold mr-2 text-black">{post.username}</span><span className="whitespace-pre-wrap text-black">{post.caption}</span><div className="mt-2 text-xs text-gray-500 uppercase tracking-tight text-black">{format(parseISO(post.date), 'MMMM d, yyyy')}</div></div>
|
||||
<div className="text-sm text-black"><span className="font-semibold mr-2 text-black">{post.username}</span><span className="whitespace-pre-wrap text-black">{post.caption}</span><div className="mt-2 text-xs text-gray-500 uppercase tracking-tight text-black">{formatDateSafe(post.date, 'MMMM d, yyyy')}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 md:p-4 border-t border-gray-100 space-y-3 shrink-0 bg-white text-black">
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user