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:
ergosteur
2026-08-14 01:59:19 -04:00
co-authored by Claude Opus 5
parent 0ba7a0d9ad
commit 1d86fa3583
38 changed files with 2291 additions and 1638 deletions
+38 -4
View File
@@ -1,21 +1,51 @@
import { ArchiveFile } from '../types';
import { ArchiveFile, ArchiveSource } from '../types';
export class LocalArchiveFile implements ArchiveFile {
constructor(private file: File) {}
/** Blob URLs minted here are revocable and must be released when done. */
readonly revocable = true;
/**
* @param explicitPath Set when the file came from the File System Access API,
* whose File objects carry an empty webkitRelativePath.
*/
constructor(private file: File, private explicitPath?: string) {}
get name() { return this.file.name; }
get webkitRelativePath() { return this.file.webkitRelativePath; }
get webkitRelativePath() { return this.explicitPath ?? this.file.webkitRelativePath; }
get size() { return this.file.size; }
text() { return this.file.text(); }
arrayBuffer() { return this.file.arrayBuffer(); }
stream() { return this.file.stream(); }
/**
* A blob: URL backed directly by the on-disk File.
*
* Deliberately does NOT go through arrayBuffer() — a File is already a Blob,
* so this hands the browser a disk-backed handle instead of pulling the whole
* file into memory. Doing otherwise means a 20GB archive tries to become 20GB
* of resident blobs.
*
* When the picker gave us no MIME type, slice() re-tags the blob with a hint.
* slice() is a zero-copy view, so this stays memory-free either way.
*/
createObjectUrl(mimeHint?: string) {
const source = this.file.type || !mimeHint
? this.file
: this.file.slice(0, this.file.size, mimeHint);
return URL.createObjectURL(source);
}
}
export class RemoteArchiveFile implements ArchiveFile {
/** Served over HTTP; there is no object URL to release. */
readonly revocable = false;
constructor(
public name: string,
public webkitRelativePath: string,
public size: number,
public url: string
public url: string,
public source?: ArchiveSource,
public mtime?: number
) {}
async text() {
const res = await fetch(this.url);
@@ -33,4 +63,8 @@ export class RemoteArchiveFile implements ArchiveFile {
});
return transform.readable;
}
createObjectUrl() {
return this.url;
}
}