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
72 lines
2.4 KiB
TypeScript
72 lines
2.4 KiB
TypeScript
/**
|
|
* Archive directory naming rules.
|
|
*
|
|
* Shared by the server (to fold sidecar directories into one profile) and the
|
|
* test suite. Kept free of Node built-ins so it can be imported from either.
|
|
*/
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Sidecar directories sit next to the profile directory they belong to:
|
|
*
|
|
* 4utumn07 -> posts (base)
|
|
* 4utumn07 - reels -> reels
|
|
* story - 4utumn07 -> stories
|
|
* story highlights - 4utumn07 - Sunstory -> highlight "Sunstory"
|
|
*
|
|
* Instagram usernames cannot contain spaces, so matching the username as a
|
|
* run of non-space characters reliably separates it from a highlight title
|
|
* (titles may themselves contain spaces, dashes and emoji).
|
|
*/
|
|
export const classifyDirectory = (dirName: string): { owner: string; source: ArchiveSource } => {
|
|
const highlight = /^story highlights - ([^ ]+) - (.+)$/.exec(dirName);
|
|
if (highlight) {
|
|
return { owner: highlight[1], source: { kind: 'highlight', dir: dirName, title: highlight[2] } };
|
|
}
|
|
|
|
const stories = /^story - ([^ ]+)$/.exec(dirName);
|
|
if (stories) {
|
|
return { owner: stories[1], source: { kind: 'stories', dir: dirName } };
|
|
}
|
|
|
|
const reels = /^([^ ]+) - reels$/.exec(dirName);
|
|
if (reels) {
|
|
return { owner: reels[1], source: { kind: 'reels', dir: dirName } };
|
|
}
|
|
|
|
return { owner: dirName, source: { kind: 'posts', dir: dirName } };
|
|
};
|
|
|
|
const RANK: Record<SourceKind, number> = { posts: 0, reels: 1, stories: 2, highlight: 3 };
|
|
|
|
/**
|
|
* Group the archive root's directories by profile.
|
|
*
|
|
* A sidecar whose owner has no base directory still forms a group of its own,
|
|
* so nothing becomes invisible just because the base profile is missing.
|
|
*/
|
|
export const groupArchiveDirectories = (dirNames: string[]): Map<string, ArchiveSource[]> => {
|
|
const groups = new Map<string, ArchiveSource[]>();
|
|
|
|
for (const dirName of dirNames) {
|
|
const { owner, source } = classifyDirectory(dirName);
|
|
if (!groups.has(owner)) groups.set(owner, []);
|
|
groups.get(owner)!.push(source);
|
|
}
|
|
|
|
for (const sources of groups.values()) {
|
|
sources.sort((a, b) => RANK[a.kind] - RANK[b.kind] || (a.title ?? '').localeCompare(b.title ?? ''));
|
|
}
|
|
|
|
return groups;
|
|
};
|