Files
SubMiner/src/anime-bridge/extension-store.ts
T
sudacode b819ac28ff 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
2026-08-03 02:11:21 -07:00

146 lines
4.9 KiB
TypeScript

import { createReadStream } from 'node:fs';
import { readdir, readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { pipeline } from 'node:stream/promises';
import path from 'node:path';
import type { AnimeBridgeClient } from './bridge-client';
import type { BridgeSource } from './bridge-client';
import type { ExtensionLoadFailure, InstalledExtensionView } from '../types/anime-browser';
/**
* Anime extensions are Aniyomi APKs the user supplies. They are read from a
* directory rather than fetched from a hardcoded catalogue, so which sources
* exist is entirely the user's choice.
*/
export interface InstalledExtension {
/** Absolute path to the .apk. */
file: string;
/** File name without extension, used when the bridge reports no name. */
fallbackName: string;
/**
* SHA-256 of the APK. Identifies the build rather than the slot, so the
* bridge's extension-id cache misses after an in-place upgrade.
*/
sha256: string;
}
export interface ExtensionSource {
/** Stable id: the bridge source id, which selects it inside a factory APK. */
id: string;
name: string;
lang: string;
pkg: string;
file: string;
}
/**
* Discover every .apk in `directory`. A missing directory yields no extensions.
*
* Only a hash is kept, never the bytes: APKs run to several MB each and a
* base64 copy adds a third on top, so holding the whole set for the lifetime of
* the Anime Browser would cost far more than re-reading a file on the rare
* upload. Hashing streams, so peak memory stays flat regardless of APK size.
*/
export async function readInstalledExtensions(directory: string): Promise<InstalledExtension[]> {
let entries;
try {
entries = await readdir(directory, { withFileTypes: true });
} catch {
return [];
}
const extensions: InstalledExtension[] = [];
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.apk')) continue;
const file = path.join(directory, entry.name);
extensions.push({
file,
fallbackName: entry.name.replace(/\.apk$/i, ''),
sha256: await hashFile(file),
});
}
return extensions;
}
async function hashFile(file: string): Promise<string> {
const hash = createHash('sha256');
await pipeline(createReadStream(file), hash);
return hash.digest('hex');
}
/**
* Describe what is on disk, for the installed list in the Extensions tab.
*
* Built from the directory rather than from a repository catalogue: an APK
* dropped in by hand, or one whose repository the user has since removed, is
* still installed and must stay removable.
*/
export function toInstalledExtensionViews(
extensions: InstalledExtension[],
sources: ExtensionSource[],
loadFailures: ExtensionLoadFailure[],
): InstalledExtensionView[] {
return extensions.map((extension) => {
const provided = sources.filter((source) => source.file === extension.file);
const names = [...new Set(provided.map((source) => source.name))];
return {
pkg: extension.fallbackName,
name: names.length > 0 ? names.join(', ') : extension.fallbackName,
langs: [...new Set(provided.map((source) => source.lang))],
sourceCount: provided.length,
error: loadFailures.find((failure) => failure.pkg === extension.fallbackName)?.error ?? null,
};
});
}
/**
* The bridge payload for a specific source inside an extension.
*
* The APK is read on demand: after the first upload the bridge answers by
* extension id, so most calls never touch the file at all.
*/
export function toBridgeSource(extension: InstalledExtension, sourceId?: string): BridgeSource {
return {
fingerprint: extension.sha256,
loadApkBase64: async () => (await readFile(extension.file)).toString('base64'),
...(sourceId ? { sourceId } : {}),
};
}
/**
* Ask the bridge which sources each extension provides.
*
* An extension that fails to load is skipped rather than aborting the scan, so
* one broken APK cannot hide every working one. Failures are reported through
* `onError` for surfacing in the UI.
*/
export async function listExtensionSources(
client: AnimeBridgeClient,
extensions: InstalledExtension[],
onError?: (extension: InstalledExtension, error: unknown) => void,
): Promise<ExtensionSource[]> {
const sources: ExtensionSource[] = [];
for (const extension of extensions) {
try {
const descriptors = await client.listAnimeSources(toBridgeSource(extension));
for (const descriptor of descriptors) {
const id = descriptor.id === undefined ? null : String(descriptor.id);
if (id === null || id.length === 0) continue;
sources.push({
id,
name: descriptor.name?.trim() || extension.fallbackName,
lang: descriptor.lang ?? 'all',
pkg: extension.fallbackName,
file: extension.file,
});
}
} catch (error) {
onError?.(extension, error);
}
}
return sources;
}