fix(anime): harden the extension bridge against untrusted repos and hangs

Addresses CodeRabbit review feedback on the Anime Browser:

- reject repository package/apk names that are not plain identifiers, and
  verify the install target resolves inside the extensions directory
- count mpv's %n% option escape in UTF-8 bytes, and escape backslashes in
  header values so a trailing one cannot eat the list separator
- key the bridge extension-id cache by APK content, so an in-place upgrade
  re-uploads instead of running the previous build
- bound every bridge, release-listing, and download request with a timeout
- enforce the APK size limit while streaming rather than after buffering
- read APK bytes on demand instead of holding a base64 copy per extension
  for the lifetime of the browser
- serialize preference mutations and write the file atomically
- handle the sidecar spawn error event, and wait for the child to exit in
  stop() before returning
- report a failed Anime Browser bootstrap instead of showing the starting
  banner forever
- keep the preferences panel's save confirmation and in-flight multi-select
  edits by re-rendering only on a structural schema change
This commit is contained in:
2026-07-31 17:57:01 -07:00
parent ec48f90cd4
commit a612d44535
19 changed files with 567 additions and 81 deletions
+48 -15
View File
@@ -1,4 +1,4 @@
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { readFile, writeFile, rename, rm, mkdir } from 'node:fs/promises';
import path from 'node:path';
import type { BridgePreference } from './types';
@@ -11,11 +11,24 @@ import type { BridgePreference } from './types';
export class PreferenceStore {
private readonly file: string;
private cache: Record<string, BridgePreference[]> | null = null;
/**
* Mutations run one at a time. Two concurrent load-modify-persist cycles
* starting on a cold cache would each read their own object, and the later
* write would drop the earlier one's edit.
*/
private queue: Promise<unknown> = Promise.resolve();
constructor(file: string) {
this.file = file;
}
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
const result = this.queue.then(operation, operation);
// Keep the chain alive after a rejection so one failure cannot wedge it.
this.queue = result.catch(() => undefined);
return result;
}
private async load(): Promise<Record<string, BridgePreference[]>> {
if (this.cache !== null) return this.cache;
try {
@@ -32,14 +45,18 @@ export class PreferenceStore {
}
async get(sourceId: string): Promise<BridgePreference[]> {
const all = await this.load();
return all[sourceId] ?? [];
return this.enqueue(async () => {
const all = await this.load();
return all[sourceId] ?? [];
});
}
async set(sourceId: string, preferences: BridgePreference[]): Promise<void> {
const all = await this.load();
all[sourceId] = preferences;
await this.persist(all);
await this.enqueue(async () => {
const all = await this.load();
all[sourceId] = preferences;
await this.persist(all);
});
}
/**
@@ -50,19 +67,35 @@ export class PreferenceStore {
* package name and this clears anything recorded under it.
*/
async clear(prefix: string): Promise<void> {
const all = await this.load();
let changed = false;
for (const key of Object.keys(all)) {
if (key === prefix || key.startsWith(`${prefix}:`)) {
delete all[key];
changed = true;
await this.enqueue(async () => {
const all = await this.load();
let changed = false;
for (const key of Object.keys(all)) {
if (key === prefix || key.startsWith(`${prefix}:`)) {
delete all[key];
changed = true;
}
}
}
if (changed) await this.persist(all);
if (changed) await this.persist(all);
});
}
/**
* Write through a temporary file and rename into place.
*
* A write interrupted partway would otherwise leave truncated JSON, and
* `load()` treats unparseable content as empty — which would quietly discard
* every saved credential.
*/
private async persist(all: Record<string, BridgePreference[]>): Promise<void> {
await mkdir(path.dirname(this.file), { recursive: true });
await writeFile(this.file, JSON.stringify(all, null, 2), { mode: 0o600 });
const temporary = `${this.file}.tmp`;
try {
await writeFile(temporary, JSON.stringify(all, null, 2), { mode: 0o600 });
await rename(temporary, this.file);
} catch (error) {
await rm(temporary, { force: true }).catch(() => undefined);
throw error;
}
}
}