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
171 lines
6.2 KiB
TypeScript
171 lines
6.2 KiB
TypeScript
import * as idb from 'idb-keyval';
|
|
import { CacheData, Post } from '../types';
|
|
import { DirectoryHandle, ensureReadPermission, filesFromDirectory } from './directory-handle';
|
|
import { LocalArchiveFile } from './archive-files';
|
|
|
|
/**
|
|
* Persistent archive cache.
|
|
*
|
|
* Keys are namespaced so that listing archives does not require deserializing
|
|
* every thumbnail blob in the store: `archive:` entries are metadata, `thumb:`
|
|
* entries are image blobs, `handle:` entries are directory handles.
|
|
*/
|
|
const ARCHIVE_PREFIX = 'archive:';
|
|
const THUMB_PREFIX = 'thumb:';
|
|
const HANDLE_PREFIX = 'handle:';
|
|
|
|
export const archiveKey = (name: string) => `${ARCHIVE_PREFIX}${name}`;
|
|
export const handleKey = (name: string) => `${HANDLE_PREFIX}${name}`;
|
|
/** Thumbnails are scoped per archive; post IDs alone collide across archives. */
|
|
export const thumbKey = (archive: string, postId: string) => `${THUMB_PREFIX}${archive}:${postId}`;
|
|
|
|
export const getCachedArchive = (name: string): Promise<CacheData | undefined> =>
|
|
idb.get(archiveKey(name));
|
|
|
|
export const setCachedArchive = (data: CacheData) => idb.set(archiveKey(data.name), data);
|
|
|
|
/** Names of all cached archives, without loading their contents. */
|
|
export const listCachedArchiveNames = async (): Promise<string[]> =>
|
|
(await idb.keys())
|
|
.map(String)
|
|
.filter(k => k.startsWith(ARCHIVE_PREFIX))
|
|
.map(k => k.slice(ARCHIVE_PREFIX.length));
|
|
|
|
export const listCachedArchives = async (): Promise<CacheData[]> => {
|
|
const names = await listCachedArchiveNames();
|
|
const entries = await Promise.all(names.map(getCachedArchive));
|
|
return entries.filter((e): e is CacheData => Boolean(e));
|
|
};
|
|
|
|
/** Remove an archive along with its handle and every thumbnail it owns. */
|
|
export const deleteCachedArchive = async (name: string) => {
|
|
const thumbPrefix = `${THUMB_PREFIX}${name}:`;
|
|
const stale = (await idb.keys()).map(String).filter(k => k.startsWith(thumbPrefix));
|
|
await idb.delMany([archiveKey(name), handleKey(name), ...stale]);
|
|
};
|
|
|
|
export const saveDirectoryHandle = (name: string, handle: DirectoryHandle) =>
|
|
idb.set(handleKey(name), handle);
|
|
|
|
export const getDirectoryHandle = (name: string): Promise<DirectoryHandle | undefined> =>
|
|
idb.get(handleKey(name));
|
|
|
|
/**
|
|
* One-time migration from the flat key layout (archive name as a bare key,
|
|
* `thumb_<postId>` for thumbnails).
|
|
*
|
|
* Old local entries are dropped rather than migrated: their media URLs are
|
|
* dead blob: URLs, so restoring them would render an archive of broken images.
|
|
*/
|
|
export const migrateLegacyCache = async () => {
|
|
const keys = (await idb.keys()).map(String);
|
|
const legacyThumbs = keys.filter(k => k.startsWith('thumb_'));
|
|
const legacyArchives = keys.filter(
|
|
k => !k.startsWith(ARCHIVE_PREFIX) && !k.startsWith(THUMB_PREFIX) &&
|
|
!k.startsWith(HANDLE_PREFIX) && !k.startsWith('thumb_')
|
|
);
|
|
if (!legacyThumbs.length && !legacyArchives.length) return;
|
|
|
|
const drop: string[] = [...legacyThumbs];
|
|
for (const key of legacyArchives) {
|
|
const data = await idb.get(key);
|
|
drop.push(key);
|
|
if (data && typeof data === 'object' && 'posts' in data && !(data as CacheData).isLocal) {
|
|
// Server archives keep working: their URLs are plain HTTP paths.
|
|
await setCachedArchive({ ...(data as CacheData), name: key });
|
|
}
|
|
}
|
|
await idb.delMany(drop);
|
|
console.log(`[Cache] Migrated legacy cache: dropped ${drop.length} stale keys.`);
|
|
};
|
|
|
|
/**
|
|
* Rebuild a server archive's media URLs, which are stable HTTP paths.
|
|
*
|
|
* `path` is relative to the archives root and already carries the source
|
|
* directory (which may be a sidecar such as `story - user`), so it is not
|
|
* prefixed with the archive name. Entries cached before `path` existed fall
|
|
* back to their stored URL.
|
|
*/
|
|
const rehydrateRemote = (posts: Post[]): Post[] =>
|
|
posts.map(post => {
|
|
const media = post.media.map(m => ({
|
|
...m,
|
|
url: m.path ? `/archives/${encodeURI(m.path)}` : m.url,
|
|
}));
|
|
return { ...post, media, thumbnail: media[0]?.url ?? post.thumbnail };
|
|
});
|
|
|
|
/**
|
|
* Rebuild a local archive's media URLs from a live directory handle, minting
|
|
* fresh blob: URLs for the paths recorded at scan time.
|
|
*
|
|
* Returns null when the folder is no longer reachable (permission declined, or
|
|
* the handle no longer resolves), signalling the caller to re-prompt.
|
|
*/
|
|
const rehydrateLocal = async (
|
|
posts: Post[],
|
|
handle: DirectoryHandle,
|
|
onUrl: (url: string) => void,
|
|
): Promise<Post[] | null> => {
|
|
if (!(await ensureReadPermission(handle))) return null;
|
|
|
|
let files: LocalArchiveFile[];
|
|
try {
|
|
files = await filesFromDirectory(handle);
|
|
} catch (err) {
|
|
console.warn('[Cache] Directory handle no longer readable:', err);
|
|
return null;
|
|
}
|
|
|
|
const byPath = new Map(files.map(f => [f.webkitRelativePath, f]));
|
|
|
|
return posts.map(post => {
|
|
const media = post.media.map(m => {
|
|
const file = byPath.get(m.path);
|
|
if (!file) return { ...m, url: '' };
|
|
const url = file.createObjectUrl(m.type === 'video' ? 'video/mp4' : 'image/jpeg');
|
|
onUrl(url);
|
|
return { ...m, url };
|
|
});
|
|
return { ...post, media, thumbnail: media[0]?.url ?? '' };
|
|
});
|
|
};
|
|
|
|
export interface RestoredArchive {
|
|
posts: Post[];
|
|
stories: Post[];
|
|
highlights: Post[];
|
|
profileMetadata: CacheData['profileMetadata'];
|
|
}
|
|
|
|
/**
|
|
* Turn a cache entry back into displayable state.
|
|
*
|
|
* `onUrl` receives every blob: URL minted so the caller can revoke them later.
|
|
* Returns null if a local archive's folder can no longer be reached.
|
|
*/
|
|
export const restoreArchive = async (
|
|
data: CacheData,
|
|
onUrl: (url: string) => void,
|
|
): Promise<RestoredArchive | null> => {
|
|
if (!data.isLocal) {
|
|
return {
|
|
posts: rehydrateRemote(data.posts),
|
|
stories: rehydrateRemote(data.stories),
|
|
highlights: rehydrateRemote(data.highlights ?? []),
|
|
profileMetadata: data.profileMetadata,
|
|
};
|
|
}
|
|
|
|
const handle = await getDirectoryHandle(data.name);
|
|
if (!handle) return null;
|
|
|
|
const posts = await rehydrateLocal(data.posts, handle, onUrl);
|
|
if (!posts) return null;
|
|
const stories = (await rehydrateLocal(data.stories, handle, onUrl)) ?? [];
|
|
const highlights = (await rehydrateLocal(data.highlights ?? [], handle, onUrl)) ?? [];
|
|
|
|
return { posts, stories, highlights, profileMetadata: data.profileMetadata };
|
|
};
|