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
@@ -1,11 +1,32 @@
|
||||
export interface MediaFile {
|
||||
name: string;
|
||||
/**
|
||||
* Path relative to the archive root (matching webkitRelativePath for local
|
||||
* folders). Unlike `url`, this survives a page reload, so it is what the
|
||||
* cache persists and what URLs are rehydrated from.
|
||||
*/
|
||||
path: string;
|
||||
url: string;
|
||||
type: 'image' | 'video';
|
||||
index: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which sidecar directory a post came from. Archives store reels, stories and
|
||||
* each story highlight in directories alongside the base profile; the viewer
|
||||
* folds them into one profile and routes them by kind.
|
||||
*/
|
||||
export type SourceKind = 'posts' | 'reels' | 'stories' | 'highlight';
|
||||
|
||||
export interface ArchiveSource {
|
||||
kind: SourceKind;
|
||||
/** Directory name relative to the archives root. */
|
||||
dir: string;
|
||||
/** Highlight title, for kind === 'highlight'. */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
id: string;
|
||||
date: string;
|
||||
@@ -14,6 +35,10 @@ export interface Post {
|
||||
media: MediaFile[];
|
||||
thumbnail: string;
|
||||
isStory?: boolean;
|
||||
/** Defaults to 'posts' for archives without sidecar directories. */
|
||||
source?: SourceKind;
|
||||
/** Highlight this post belongs to, for source === 'highlight'. */
|
||||
highlightTitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,11 +52,73 @@ export interface ArchiveFile {
|
||||
arrayBuffer(): Promise<ArrayBuffer>;
|
||||
stream(): ReadableStream<Uint8Array>;
|
||||
url?: string;
|
||||
/**
|
||||
* A URL pointing at this file's contents. Local files mint a disk-backed
|
||||
* blob: URL (no data is read into memory); remote files return their HTTP URL.
|
||||
*/
|
||||
createObjectUrl(mimeHint?: string): string;
|
||||
/** True when createObjectUrl() returns a blob: URL that must be revoked. */
|
||||
readonly revocable: boolean;
|
||||
/** Which sidecar directory this file came from, when known. */
|
||||
source?: ArchiveSource;
|
||||
/** Last-modified time (ms). Used to date items whose filename has no date. */
|
||||
mtime?: number;
|
||||
}
|
||||
|
||||
export interface ServerArchive {
|
||||
name: string;
|
||||
thumbnail: string;
|
||||
path: string;
|
||||
/** Null until the server has indexed this profile. */
|
||||
fileCount: number | null;
|
||||
/**
|
||||
* Directory-mtime signature. Cheap for the server to compute and sufficient
|
||||
* to detect changes, unlike a file count that would require a full walk.
|
||||
*/
|
||||
signature?: string;
|
||||
/** Base profile plus any sidecar directories folded into it. */
|
||||
sources?: ArchiveSource[];
|
||||
}
|
||||
|
||||
/** One entry from GET /api/archives/:name/files. */
|
||||
export interface ServerArchiveFile {
|
||||
path: string;
|
||||
size: number;
|
||||
mtime: number;
|
||||
kind: SourceKind;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface ProfileMetadata {
|
||||
username: string;
|
||||
fullName: string;
|
||||
bio: string;
|
||||
followerCount: number;
|
||||
followingCount: number;
|
||||
externalUrl: string;
|
||||
profilePic: string | null;
|
||||
allProfilePics: string[];
|
||||
}
|
||||
|
||||
/** Shape of an archive entry persisted in IndexedDB. */
|
||||
export interface CacheData {
|
||||
name: string;
|
||||
isLocal: boolean;
|
||||
fileCount: number;
|
||||
/** Server archives: the signature this entry was built from. */
|
||||
signature?: string;
|
||||
posts: Post[];
|
||||
stories: Post[];
|
||||
/** Story-highlight items, grouped by `highlightTitle`. */
|
||||
highlights?: Post[];
|
||||
profileMetadata: ProfileMetadata;
|
||||
timestamp: number;
|
||||
/**
|
||||
* Local archives only: whether a FileSystemDirectoryHandle was stored
|
||||
* alongside this entry, meaning media URLs can be rehydrated without
|
||||
* re-prompting for the folder.
|
||||
*/
|
||||
hasDirectoryHandle?: boolean;
|
||||
/** Path of the profile picture, for rehydration (local archives). */
|
||||
profilePicPath?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user