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
119 lines
4.2 KiB
TypeScript
119 lines
4.2 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { parseArchiveFilename, scopedPostId } from './archive-patterns';
|
|
|
|
describe('parseArchiveFilename — Instagram export format', () => {
|
|
it('parses a single-image post', () => {
|
|
expect(parseArchiveFilename('2023-04-19_4utumn07 - CrORBIcJJbM.mp4')).toEqual({
|
|
postId: 'CrORBIcJJbM',
|
|
date: '2023-04-19',
|
|
username: '4utumn07',
|
|
index: 1,
|
|
ext: 'mp4',
|
|
isStory: false,
|
|
});
|
|
});
|
|
|
|
it('parses a carousel slide index', () => {
|
|
const parsed = parseArchiveFilename('2023-04-12_4utumn07 - Cq8LrxSJAJE - 3.jpg');
|
|
expect(parsed).toMatchObject({ postId: 'Cq8LrxSJAJE', index: 3, ext: 'jpg' });
|
|
});
|
|
|
|
it('groups a carousel under one post id', () => {
|
|
const ids = ['1', '2', '3'].map(
|
|
n => parseArchiveFilename(`2023-04-12_user - Cq8LrxSJAJE - ${n}.jpg`)!.postId,
|
|
);
|
|
expect(new Set(ids).size).toBe(1);
|
|
});
|
|
|
|
it('parses caption sidecar files', () => {
|
|
expect(parseArchiveFilename('2023-04-12_4utumn07 - Cq8LrxSJAJE.txt')).toMatchObject({
|
|
postId: 'Cq8LrxSJAJE',
|
|
ext: 'txt',
|
|
});
|
|
});
|
|
|
|
it('flags an explicit story suffix', () => {
|
|
expect(parseArchiveFilename('2023-04-12_user - ABC - story.jpg')?.isStory).toBe(true);
|
|
});
|
|
|
|
it('parses the story sidecar layout (date_user - N - shortcode)', () => {
|
|
// Files in `story - <user>` carry a per-day ordinal before the shortcode.
|
|
const parsed = parseArchiveFilename('2025-10-26_4utumn07 - 2 - DQRuDx9iW5Q.jpg', 'stories');
|
|
expect(parsed).toMatchObject({ date: '2025-10-26', username: '4utumn07', ext: 'jpg' });
|
|
expect(parsed!.postId).toContain('DQRuDx9iW5Q');
|
|
});
|
|
|
|
it('gives each story item a distinct id', () => {
|
|
const a = parseArchiveFilename('2026-08-13_u - 1 - Db-UTJcCUUr.mp4', 'stories')!.postId;
|
|
const b = parseArchiveFilename('2026-08-13_u - 2 - Db-oNJ1CWQ4.mp4', 'stories')!.postId;
|
|
expect(a).not.toBe(b);
|
|
});
|
|
});
|
|
|
|
describe('parseArchiveFilename — Instaloader format', () => {
|
|
it('parses a timestamped filename', () => {
|
|
expect(parseArchiveFilename('2024-01-01_12-00-00_UTC.jpg')).toMatchObject({
|
|
postId: '2024-01-01_12-00-00_UTC',
|
|
date: '2024-01-01',
|
|
index: 1,
|
|
});
|
|
});
|
|
|
|
it('parses the carousel suffix', () => {
|
|
expect(parseArchiveFilename('2024-01-01_12-00-00_UTC_2.jpg')?.index).toBe(2);
|
|
});
|
|
|
|
it('flags the story suffix', () => {
|
|
expect(parseArchiveFilename('2024-01-01_12-00-00_UTC_story.jpg')?.isStory).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('parseArchiveFilename — story highlights', () => {
|
|
it('parses the dateless highlight layout', () => {
|
|
expect(parseArchiveFilename('4utumn07 - C5dQPEYpd9W.mp4', 'highlight')).toMatchObject({
|
|
postId: 'C5dQPEYpd9W',
|
|
username: '4utumn07',
|
|
ext: 'mp4',
|
|
isStory: false,
|
|
});
|
|
});
|
|
|
|
it('dates a highlight from mtime when the filename has none', () => {
|
|
const mtime = Date.UTC(2024, 4, 17, 12, 0, 0);
|
|
expect(parseArchiveFilename('user - ABC.jpg', 'highlight', mtime)?.date).toBe('2024-05-17');
|
|
});
|
|
|
|
it('leaves the date empty when no mtime is available', () => {
|
|
expect(parseArchiveFilename('user - ABC.jpg', 'highlight')?.date).toBe('');
|
|
});
|
|
|
|
it('does not apply the loose highlight pattern outside highlight directories', () => {
|
|
// Would otherwise swallow ordinary "a - b.jpg" filenames.
|
|
expect(parseArchiveFilename('user - ABC.jpg', 'posts')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('parseArchiveFilename — non-matching files', () => {
|
|
it.each(['4utumn07.jpg', 'profile_pic.jpg', 'README.md', 'no-separator.png'])(
|
|
'returns null for %s',
|
|
name => expect(parseArchiveFilename(name)).toBeNull(),
|
|
);
|
|
});
|
|
|
|
describe('scopedPostId', () => {
|
|
it('leaves base-profile ids untouched so permalinks keep working', () => {
|
|
expect(scopedPostId('Cq8LrxSJAJE', 'posts')).toBe('Cq8LrxSJAJE');
|
|
});
|
|
|
|
it('namespaces sidecar ids by directory', () => {
|
|
expect(scopedPostId('C5dQ', 'highlight', 'story highlights - u - Sunstory'))
|
|
.toBe('story highlights - u - Sunstory/C5dQ');
|
|
});
|
|
|
|
it('keeps the same shortcode distinct across sources', () => {
|
|
const inPosts = scopedPostId('ABC', 'posts');
|
|
const inHighlight = scopedPostId('ABC', 'highlight', 'story highlights - u - H');
|
|
expect(inPosts).not.toBe(inHighlight);
|
|
});
|
|
});
|