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
71 lines
2.2 KiB
TypeScript
71 lines
2.2 KiB
TypeScript
import { ArchiveFile, ArchiveSource } from '../types';
|
|
|
|
export class LocalArchiveFile implements ArchiveFile {
|
|
/** 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.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 source?: ArchiveSource,
|
|
public mtime?: number
|
|
) {}
|
|
async text() {
|
|
const res = await fetch(this.url);
|
|
return res.text();
|
|
}
|
|
async arrayBuffer() {
|
|
const res = await fetch(this.url);
|
|
return res.arrayBuffer();
|
|
}
|
|
stream() {
|
|
const transform = new TransformStream();
|
|
fetch(this.url).then(res => {
|
|
if (res.body) res.body.pipeTo(transform.writable);
|
|
else transform.writable.getWriter().close();
|
|
});
|
|
return transform.readable;
|
|
}
|
|
|
|
createObjectUrl() {
|
|
return this.url;
|
|
}
|
|
}
|