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
@@ -0,0 +1,86 @@
|
||||
import { LocalArchiveFile } from './archive-files';
|
||||
|
||||
/**
|
||||
* File System Access API helpers.
|
||||
*
|
||||
* A `blob:` URL dies with the document, so a cached local archive whose media
|
||||
* URLs are blob: URLs is worthless after a reload. A FileSystemDirectoryHandle,
|
||||
* by contrast, is structured-cloneable and survives in IndexedDB — so we can
|
||||
* re-open the same folder on a return visit and mint fresh URLs from it.
|
||||
*
|
||||
* Only Chromium implements showDirectoryPicker today; callers must handle the
|
||||
* unsupported case by falling back to the <input webkitdirectory> flow.
|
||||
*/
|
||||
|
||||
// Minimal typings — TS's lib.dom does not ship these in the configured version.
|
||||
type PermissionState = 'granted' | 'denied' | 'prompt';
|
||||
interface FileSystemHandlePermissionDescriptor { mode?: 'read' | 'readwrite' }
|
||||
export interface DirectoryHandle {
|
||||
name: string;
|
||||
kind: 'directory';
|
||||
values(): AsyncIterableIterator<DirectoryHandle | FileHandle>;
|
||||
queryPermission?(d?: FileSystemHandlePermissionDescriptor): Promise<PermissionState>;
|
||||
requestPermission?(d?: FileSystemHandlePermissionDescriptor): Promise<PermissionState>;
|
||||
}
|
||||
interface FileHandle {
|
||||
name: string;
|
||||
kind: 'file';
|
||||
getFile(): Promise<File>;
|
||||
}
|
||||
|
||||
export const isDirectoryPickerSupported = () =>
|
||||
typeof window !== 'undefined' && 'showDirectoryPicker' in window;
|
||||
|
||||
export const pickDirectory = async (): Promise<DirectoryHandle | null> => {
|
||||
if (!isDirectoryPickerSupported()) return null;
|
||||
try {
|
||||
return await (window as any).showDirectoryPicker({ mode: 'read' });
|
||||
} catch (err) {
|
||||
// AbortError simply means the user dismissed the picker.
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Confirm we may still read this handle. Returns false when the user declines
|
||||
* or the grant has lapsed, in which case the caller should re-prompt.
|
||||
*
|
||||
* `requestPermission` must be called from a user gesture, so only call this
|
||||
* while handling a click.
|
||||
*/
|
||||
export const ensureReadPermission = async (handle: DirectoryHandle): Promise<boolean> => {
|
||||
try {
|
||||
if (!handle.queryPermission) return true;
|
||||
if ((await handle.queryPermission({ mode: 'read' })) === 'granted') return true;
|
||||
if (!handle.requestPermission) return false;
|
||||
return (await handle.requestPermission({ mode: 'read' })) === 'granted';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Recursively collect every file in the directory.
|
||||
*
|
||||
* Paths are prefixed with the root directory's name so they line up with the
|
||||
* `webkitRelativePath` values produced by <input webkitdirectory>, keeping
|
||||
* cached media paths valid regardless of which picker created them.
|
||||
*/
|
||||
export const filesFromDirectory = async (handle: DirectoryHandle): Promise<LocalArchiveFile[]> => {
|
||||
const out: LocalArchiveFile[] = [];
|
||||
|
||||
const walk = async (dir: DirectoryHandle, prefix: string) => {
|
||||
for await (const entry of dir.values()) {
|
||||
const entryPath = `${prefix}/${entry.name}`;
|
||||
if (entry.kind === 'directory') {
|
||||
await walk(entry as DirectoryHandle, entryPath);
|
||||
} else {
|
||||
const file = await (entry as FileHandle).getFile();
|
||||
out.push(new LocalArchiveFile(file, entryPath));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await walk(handle, handle.name);
|
||||
return out;
|
||||
};
|
||||
Reference in New Issue
Block a user