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,99 @@
|
||||
import { SourceKind } from '../types';
|
||||
|
||||
/**
|
||||
* Filename parsing rules for the archive formats the viewer understands.
|
||||
*
|
||||
* Kept as pure functions so the riskiest part of the scanner — deriving post
|
||||
* identity, date and carousel order from a filename — can be tested directly.
|
||||
*/
|
||||
|
||||
/** Instagram export: `2023-04-12_user - Cq8LrxSJAJE - 2.jpg` */
|
||||
export const EXPORT_RE = /^(\d{4}-\d{2}-\d{2})_(.+?) - (.+?)(?: - (\d+))?(?: - (story))?\.(.+)$/;
|
||||
|
||||
/** Instaloader: `2024-01-01_12-00-00_UTC_2.jpg` */
|
||||
export const INSTALOADER_RE = /^(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}_UTC)(?:_(\d+))?(?:_(story))?\.(.+)$/;
|
||||
|
||||
/**
|
||||
* Story highlight: `user - C5dQPEYpd9W.mp4` — no date prefix.
|
||||
*
|
||||
* Loose enough to match ordinary filenames, so it is only applied to files the
|
||||
* server has already tagged as coming from a highlight directory.
|
||||
*/
|
||||
export const HIGHLIGHT_RE = /^(.+?) - ([A-Za-z0-9_-]+)\.(\w+)$/;
|
||||
|
||||
export interface ParsedFilename {
|
||||
postId: string;
|
||||
/** ISO date (YYYY-MM-DD), or '' when the filename carries none. */
|
||||
date: string;
|
||||
username: string;
|
||||
/** 1-based carousel position. */
|
||||
index: number;
|
||||
ext: string;
|
||||
isStory: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an archive filename into post identity.
|
||||
*
|
||||
* `kind` selects which patterns apply; `mtime` supplies a date for formats that
|
||||
* have none (highlights), so those items still sort and render sensibly.
|
||||
* Returns null when no pattern matches — e.g. a profile picture.
|
||||
*/
|
||||
export const parseArchiveFilename = (
|
||||
fileName: string,
|
||||
kind: SourceKind = 'posts',
|
||||
mtime?: number,
|
||||
): ParsedFilename | null => {
|
||||
const exp = EXPORT_RE.exec(fileName);
|
||||
if (exp) {
|
||||
const [, date, username, postId, indexStr, story, ext] = exp;
|
||||
return {
|
||||
postId,
|
||||
date,
|
||||
username,
|
||||
index: indexStr ? parseInt(indexStr, 10) : 1,
|
||||
ext,
|
||||
isStory: Boolean(story),
|
||||
};
|
||||
}
|
||||
|
||||
const ins = INSTALOADER_RE.exec(fileName);
|
||||
if (ins) {
|
||||
const [, postId, indexStr, story, ext] = ins;
|
||||
return {
|
||||
postId,
|
||||
date: postId.split('_')[0],
|
||||
username: '',
|
||||
index: indexStr ? parseInt(indexStr, 10) : 1,
|
||||
ext,
|
||||
isStory: Boolean(story),
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === 'highlight') {
|
||||
const hl = HIGHLIGHT_RE.exec(fileName);
|
||||
if (hl) {
|
||||
const [, username, shortcode, ext] = hl;
|
||||
return {
|
||||
postId: shortcode,
|
||||
date: mtime ? new Date(mtime).toISOString().split('T')[0] : '',
|
||||
username,
|
||||
index: 1,
|
||||
ext,
|
||||
isStory: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Namespace a post ID by its source directory.
|
||||
*
|
||||
* Base-profile IDs are left untouched so existing permalinks keep working;
|
||||
* sidecar IDs are prefixed so a shortcode appearing in both the profile and a
|
||||
* highlight stays two distinct posts.
|
||||
*/
|
||||
export const scopedPostId = (postId: string, kind: SourceKind, dir?: string): string =>
|
||||
kind === 'posts' ? postId : `${dir ?? kind}/${postId}`;
|
||||
Reference in New Issue
Block a user