mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-02 07:21:33 -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:
+18
-11
@@ -488,18 +488,25 @@ api.onBridgeState(renderBridgeState);
|
||||
|
||||
void (async () => {
|
||||
renderBridgeState({ stage: 'idle', progress: null, message: null });
|
||||
const state = await api.ensureBridge();
|
||||
renderBridgeState(state);
|
||||
try {
|
||||
const state = await api.ensureBridge();
|
||||
renderBridgeState(state);
|
||||
|
||||
const snapshot = await api.getSnapshot();
|
||||
renderSources(snapshot.sources, snapshot.selectedSourceId);
|
||||
const snapshot = await api.getSnapshot();
|
||||
renderSources(snapshot.sources, snapshot.selectedSourceId);
|
||||
|
||||
if (state.stage === 'ready' && snapshot.sources.length > 0) {
|
||||
searchInput.focus();
|
||||
await runSearch('');
|
||||
} else if (state.stage === 'ready') {
|
||||
setStatus(state.message ?? 'No extensions installed.', 'error');
|
||||
} else {
|
||||
setStatus(state.message ?? 'The extension bridge is not available.', 'error');
|
||||
if (state.stage === 'ready' && snapshot.sources.length > 0) {
|
||||
searchInput.focus();
|
||||
await runSearch('');
|
||||
} else if (state.stage === 'ready') {
|
||||
setStatus(state.message ?? 'No extensions installed.', 'error');
|
||||
} else {
|
||||
setStatus(state.message ?? 'The extension bridge is not available.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
// Without this the window keeps the "starting" banner up forever, with the
|
||||
// search box disabled and nothing saying why.
|
||||
renderBridgeState({ stage: 'failed', progress: null, message: describe(error) });
|
||||
setStatus(describe(error), 'error');
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -19,6 +19,29 @@ function isSecretKey(view: SourcePreferenceView): boolean {
|
||||
return /password|token|api[-_ ]?key|secret/i.test(`${view.key} ${view.title}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Structure of a schema, ignoring the values.
|
||||
*
|
||||
* A commit hands back the whole schema, but re-rendering on every save would
|
||||
* throw away the "Saved" note, move focus off the control that was just
|
||||
* committed, and rebuild a `multi` group out from under a second toggle that is
|
||||
* still in flight. Only a structural change is worth a rebuild — which is
|
||||
* exactly the case that matters, Jellyfin filling in its library picker after a
|
||||
* successful login.
|
||||
*/
|
||||
function schemaShape(views: SourcePreferenceView[]): string {
|
||||
return JSON.stringify(
|
||||
views.map((view) => [
|
||||
view.key,
|
||||
view.kind,
|
||||
view.title,
|
||||
view.summary,
|
||||
view.entries,
|
||||
view.entryValues,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function renderPreferenceField(
|
||||
container: HTMLElement,
|
||||
view: SourcePreferenceView,
|
||||
@@ -46,10 +69,14 @@ function renderPreferenceField(
|
||||
state.removeAttribute('data-tone');
|
||||
state.textContent = 'Saving…';
|
||||
try {
|
||||
const refreshed = await commit(view.key, value);
|
||||
// Commits are chained so a second toggle cannot land before the first
|
||||
// one's round trip finishes and overwrite it with a stale array.
|
||||
const refreshed = await enqueueCommit(container, () => commit(view.key, value));
|
||||
state.dataset.tone = 'ok';
|
||||
state.textContent = 'Saved';
|
||||
renderPreferences(container, refreshed, commit);
|
||||
if (schemaShape(refreshed) !== renderedShapes.get(container)) {
|
||||
renderPreferences(container, refreshed, commit);
|
||||
}
|
||||
} catch (error) {
|
||||
state.dataset.tone = 'error';
|
||||
state.textContent = describe(error);
|
||||
@@ -120,11 +147,33 @@ function renderPreferenceField(
|
||||
return field;
|
||||
}
|
||||
|
||||
/** Shape currently on screen, per container, so a save can skip a no-op rebuild. */
|
||||
const renderedShapes = new WeakMap<HTMLElement, string>();
|
||||
|
||||
/** Serializes commits per container; see the comment in `save`. */
|
||||
const commitQueues = new WeakMap<HTMLElement, Promise<unknown>>();
|
||||
|
||||
function enqueueCommit(
|
||||
container: HTMLElement,
|
||||
run: () => Promise<SourcePreferenceView[]>,
|
||||
): Promise<SourcePreferenceView[]> {
|
||||
const previous = commitQueues.get(container) ?? Promise.resolve();
|
||||
const result = previous.then(run, run);
|
||||
// Swallow here only so one failed commit does not wedge the queue; the
|
||||
// caller still sees the rejection.
|
||||
commitQueues.set(
|
||||
container,
|
||||
result.catch(() => undefined),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function renderPreferences(
|
||||
container: HTMLElement,
|
||||
views: SourcePreferenceView[],
|
||||
commit: PreferenceCommit,
|
||||
): void {
|
||||
renderedShapes.set(container, schemaShape(views));
|
||||
container.replaceChildren(...views.map((view) => renderPreferenceField(container, view, commit)));
|
||||
if (views.length === 0) {
|
||||
const empty = document.createElement('p');
|
||||
|
||||
Reference in New Issue
Block a user