mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-16 13:55:51 -07:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user