fix: strengthen live recovery and support diagnostics

Apply account and key changes to active queues without a restart and isolate provider attempt cancellation so fallback accounts remain usable. Preserve pause ownership, bound persisted HTTP 416 recovery, reconcile resets with authoritative state, and stabilize package ordering and live update cadence. Correlate rotation, conversion, resume, disk, queue-control, clipboard, and support-export events while redacting sensitive data at every persistent boundary and again in generated bundles. Release as v2.0.31 with updated English documentation and regression coverage.
This commit is contained in:
Sucukdeluxe
2026-08-13 11:36:51 +02:00
parent 88399c5dd0
commit 25ebc55f4f
44 changed files with 5223 additions and 959 deletions
+25
View File
@@ -2,6 +2,31 @@
All notable changes to Multi-Debrid Downloader are documented in this file. All notable changes to Multi-Debrid Downloader are documented in this file.
## [2.0.31] - 2026-08-13
### Account rotation and lifecycle
- Applied active account and Debrid-Link key changes to running queues without requiring an application restart.
- Isolated every provider attempt with its own cancellation and timeout boundary so a stalled account cannot block the next enabled account.
- Preserved explicit Pause and Stop ownership while account settings, resets, or provider operations are changing concurrently.
- Prevented late provider failures from surfacing as unhandled process rejections after an operation was already canceled.
### Download recovery and queue state
- Persisted bounded HTTP 416 recovery state and clean-restart decisions across application restarts.
- Kept locked partial files pending until Windows confirms removal instead of starting a conflicting replacement request.
- Reconciled resets with the authoritative queue snapshot and preserved known availability information.
- Stabilized package ordering while active items start, finish, reset, or refresh in the background.
- Coalesced all running state updates, including forced refreshes, into the shared 500 ms interface cadence.
### Support diagnostics
- Correlated account rotation, link conversion, download recovery, disk waiting, Pause, Stop, Reset, and export events with anonymous attempt identifiers.
- Added bounded disk-wait entry and resolution history to support bundles.
- Recorded support bundle selection, cancellation, build, write, success, and failure phases without storing destination paths or filenames.
- Added native clipboard operation diagnostics with byte counts and errors while never recording copied content.
- Redacted credentials, account identities, URLs, hostnames, local paths, package names, and filenames at every persistent log boundary and again when building the support archive.
## [2.0.30] - 2026-08-13 ## [2.0.30] - 2026-08-13
### Rotation diagnostics ### Rotation diagnostics
+4 -1
View File
@@ -79,6 +79,7 @@ The Downloads workspace is optimized for large queues:
- Manage several accounts or API keys for the same provider. - Manage several accounts or API keys for the same provider.
- Enable or disable individual accounts without deleting their saved data. - Enable or disable individual accounts without deleting their saved data.
- Apply account changes to active queues without restarting the application and continue with the next usable account when an attempt fails.
- Check account status, remaining traffic, username, expiry, and access type. - Check account status, remaining traffic, username, expiry, and access type.
- Configure primary, secondary, and tertiary provider fallback. - Configure primary, secondary, and tertiary provider fallback.
- Route individual hosters through a specific provider. - Route individual hosters through a specific provider.
@@ -193,7 +194,9 @@ tests Unit and integration tests
Configuration, credentials, queue state, history, and logs are stored locally in Electron's `userData` directory. Secrets are not included in public source or release archives. Provider credentials are only sent to the configured provider endpoints required for account and download operations. Configuration, credentials, queue state, history, and logs are stored locally in Electron's `userData` directory. Secrets are not included in public source or release archives. Provider credentials are only sent to the configured provider endpoints required for account and download operations.
The application can generate support diagnostics and expose an optional authenticated local debug API. Remote access is disabled by default. Do not expose diagnostic endpoints publicly without a firewall, VPN, or reverse proxy, and always use a strong unique token. The application can generate a bounded support bundle that correlates account rotation, link conversion, download recovery, disk waiting, queue controls, and export phases with anonymous identifiers. Credentials, copied content, URLs, hostnames, local paths, package names, and filenames are redacted before diagnostic data is persisted and again when the archive is built.
An optional authenticated local debug API is also available. Remote access is disabled by default. Do not expose diagnostic endpoints publicly without a firewall, VPN, or reverse proxy, and always use a strong unique token.
## Updates and changelog ## Updates and changelog
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "2.0.30", "version": "2.0.31",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "2.0.30", "version": "2.0.31",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"adm-zip": "0.6.0", "adm-zip": "0.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "2.0.30", "version": "2.0.31",
"description": "Desktop downloader", "description": "Desktop downloader",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",
+81 -50
View File
@@ -1,28 +1,45 @@
import fs from "node:fs"; import fs from "node:fs";
import { logTimestamp } from "./log-timestamp"; import { logTimestamp } from "./log-timestamp";
import path from "node:path"; import path from "node:path";
import { AsyncLocalStorage } from "node:async_hooks"; import { AsyncLocalStorage } from "node:async_hooks";
import type { RotationEvent } from "../shared/types"; import type { RotationEvent } from "../shared/types";
import { sanitizeDiagnosticAccountLabel, sanitizeDiagnosticFields, sanitizeDiagnosticText } from "./diagnostic-sanitizer";
export type RotationItemSink = (event: RotationEvent) => void; export interface RotationCorrelationContext {
const rotationItemContext = new AsyncLocalStorage<RotationItemSink>(); attemptId?: string;
itemId?: string;
export function runWithRotationItemSink<T>(sink: RotationItemSink, fn: () => Promise<T>): Promise<T> { packageId?: string;
return rotationItemContext.run(sink, fn); }
}
export type CorrelatedRotationEvent = RotationEvent & RotationCorrelationContext;
export type RotationItemSink = (event: CorrelatedRotationEvent) => void;
interface RotationItemContext extends RotationCorrelationContext {
sink: RotationItemSink;
}
const rotationItemContext = new AsyncLocalStorage<RotationItemContext>();
export function runWithRotationItemSink<T>(
sink: RotationItemSink,
fn: () => Promise<T>,
correlation: RotationCorrelationContext = {}
): Promise<T> {
return rotationItemContext.run({ ...correlation, sink }, fn);
}
type RotationLevel = "INFO" | "WARN" | "ERROR"; type RotationLevel = "INFO" | "WARN" | "ERROR";
const ROTATION_EVENT_RING_MAX = 60; const ROTATION_EVENT_RING_MAX = 60;
const rotationEventRing: RotationEvent[] = []; const rotationEventRing: CorrelatedRotationEvent[] = [];
let rotationEventSeq = 0; let rotationEventSeq = 0;
let rotationEventListener: ((event: RotationEvent) => void) | null = null; let rotationEventListener: ((event: CorrelatedRotationEvent) => void) | null = null;
export function setRotationEventListener(listener: ((event: RotationEvent) => void) | null): void { export function setRotationEventListener(listener: ((event: CorrelatedRotationEvent) => void) | null): void {
rotationEventListener = listener; rotationEventListener = listener;
} }
export function getRecentRotationEvents(limit = ROTATION_EVENT_RING_MAX): RotationEvent[] { export function getRecentRotationEvents(limit = ROTATION_EVENT_RING_MAX): CorrelatedRotationEvent[] {
const slice = rotationEventRing.slice(-limit); const slice = rotationEventRing.slice(-limit);
slice.reverse(); slice.reverse();
return slice; return slice;
@@ -35,25 +52,28 @@ function pushRotationEvent(
event: string, event: string,
fields?: Record<string, unknown>, fields?: Record<string, unknown>,
at = Date.now() at = Date.now()
): void { ): CorrelatedRotationEvent {
rotationEventSeq += 1; rotationEventSeq += 1;
const entry: RotationEvent = { const context = rotationItemContext.getStore();
id: `rot_${at}_${rotationEventSeq}`, const entry: CorrelatedRotationEvent = {
at, id: `rot_${at}_${rotationEventSeq}`,
level, at,
level,
provider, provider,
accountLabel, accountLabel,
event, event,
reason: fields && fields.reason != null ? String(fields.reason) : undefined, reason: fields && fields.reason != null ? String(fields.reason) : undefined,
category: fields && fields.category != null ? String(fields.category) : undefined, category: fields && fields.category != null ? String(fields.category) : undefined,
cooldownSec: fields && fields.cooldownSec != null ? Number(fields.cooldownSec) || 0 : undefined, cooldownSec: fields && fields.cooldownSec != null ? Number(fields.cooldownSec) || 0 : undefined,
next: fields && fields.next != null ? String(fields.next) : undefined next: fields && fields.next != null ? String(fields.next) : undefined,
}; attemptId: context?.attemptId ? sanitizeDiagnosticText(context.attemptId) : undefined,
itemId: context?.itemId ? sanitizeDiagnosticText(context.itemId) : undefined,
const itemSink = rotationItemContext.getStore(); packageId: context?.packageId ? sanitizeDiagnosticText(context.packageId) : undefined
if (itemSink) { };
try {
itemSink(entry); if (context) {
try {
context.sink(entry);
} catch { } catch {
} }
} }
@@ -67,11 +87,12 @@ function pushRotationEvent(
} }
if (rotationEventListener) { if (rotationEventListener) {
try { try {
rotationEventListener(uiEntry); rotationEventListener(uiEntry);
} catch { } catch {
} }
} }
} return entry;
}
const ROTATION_LOG_MAX_FILE_BYTES = Number(process.env.RD_ACCOUNT_ROTATION_LOG_MAX_BYTES || 5 * 1024 * 1024); const ROTATION_LOG_MAX_FILE_BYTES = Number(process.env.RD_ACCOUNT_ROTATION_LOG_MAX_BYTES || 5 * 1024 * 1024);
const ROTATION_LOG_RETENTION_DAYS = Number(process.env.RD_ACCOUNT_ROTATION_LOG_RETENTION_DAYS || 14); const ROTATION_LOG_RETENTION_DAYS = Number(process.env.RD_ACCOUNT_ROTATION_LOG_RETENTION_DAYS || 14);
@@ -82,14 +103,14 @@ function sanitizeFieldValue(value: unknown): string {
if (value === undefined || value === null) { if (value === undefined || value === null) {
return ""; return "";
} }
if (typeof value === "string") { if (typeof value === "string") {
return value.replace(/\r?\n/g, "\\n"); return sanitizeDiagnosticText(value);
} }
if (typeof value === "number" || typeof value === "boolean") { if (typeof value === "number" || typeof value === "boolean") {
return String(value); return String(value);
} }
try { try {
return JSON.stringify(value).replace(/\r?\n/g, "\\n"); return sanitizeDiagnosticText(JSON.stringify(value));
} catch { } catch {
return String(value); return String(value);
} }
@@ -155,24 +176,34 @@ export function initAccountRotationLog(baseDir: string): void {
} }
} }
export function logAccountRotation( export function logAccountRotation(
level: RotationLevel, level: RotationLevel,
provider: string, provider: string,
accountLabel: string, accountLabel: string,
event: string, event: string,
fields?: Record<string, unknown> fields?: Record<string, unknown>
): void { ): void {
pushRotationEvent(level, provider, accountLabel, event, fields); const safeProvider = sanitizeDiagnosticText(provider);
if (!rotationLogPath) { const safeAccountLabel = sanitizeDiagnosticAccountLabel(accountLabel);
return; const safeEvent = sanitizeDiagnosticText(event);
const safeFields = sanitizeDiagnosticFields(fields);
const entry = pushRotationEvent(level, safeProvider, safeAccountLabel, safeEvent, safeFields);
if (!rotationLogPath) {
return;
} }
try { try {
rotateIfNeeded(rotationLogPath); rotateIfNeeded(rotationLogPath);
if (!fs.existsSync(rotationLogPath)) { if (!fs.existsSync(rotationLogPath)) {
fs.writeFileSync(rotationLogPath, "", "utf8"); fs.writeFileSync(rotationLogPath, "", "utf8");
} }
const head = `${logTimestamp()} [${level}] ${provider} | ${accountLabel} | ${event}`; const head = `${logTimestamp()} [${level}] ${safeProvider} | ${safeAccountLabel} | ${safeEvent}`;
fs.appendFileSync(rotationLogPath, `${head}${formatFields(fields)}\n`, "utf8"); const logFields = {
...safeFields,
...(entry.attemptId ? { attemptId: entry.attemptId } : {}),
...(entry.itemId ? { itemId: entry.itemId } : {}),
...(entry.packageId ? { packageId: entry.packageId } : {})
};
fs.appendFileSync(rotationLogPath, `${head}${formatFields(logFields)}\n`, "utf8");
} catch { } catch {
} }
} }
+127 -59
View File
@@ -62,7 +62,8 @@ import { buildLinkExportSelection, serializeLinkExportText } from "./link-export
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log"; import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log";
import { getDesktopRenameLogPath, initDesktopRenameLogAt, shutdownDesktopRenameLog } from "./desktop-rename-log"; import { getDesktopRenameLogPath, initDesktopRenameLogAt, shutdownDesktopRenameLog } from "./desktop-rename-log";
import { buildAccountSummary, diffAccountSummary } from "./support-data"; import { buildAccountSummary, diffAccountSummary } from "./support-data";
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle"; import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
import type { SupportBundleExportLifecycleEvent } from "./support-bundle";
import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log"; import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log";
import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types"; import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
import { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "./online-backup"; import { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "./online-backup";
@@ -190,21 +191,14 @@ export class AppController {
this.settings = this.manager.getSettings(); this.settings = this.manager.getSettings();
this.checkMemoryPressure(); this.checkMemoryPressure();
}, 60_000); }, 60_000);
this.runtimeStatsTimer.unref?.(); this.runtimeStatsTimer.unref?.();
if (this.settings.autoResumeOnStart) { if (this.settings.autoResumeOnStart) {
const snapshot = this.manager.getSnapshot(); void this.manager.waitForStartupRecovery().then(() => {
const hasPending = Object.values(snapshot.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait"); this.prepareAutoResume();
if (hasPending && this.hasAnyProviderToken(this.settings)) { }).catch((err) => logger.warn(`Auto-Resume Startup-Recovery Fehler: ${String(err)}`));
if (this.onStateHandler) { }
this.beginAutoResume(); }
} else {
this.autoResumePending = true;
logger.info("Auto-Resume beim Start vorgemerkt");
}
}
}
}
// Early-warning for OOM on a long-running process. Measured against the V8 // Early-warning for OOM on a long-running process. Measured against the V8
// heap_size_limit (the real ceiling at which the process is killed), NOT against // heap_size_limit (the real ceiling at which the process is killed), NOT against
@@ -234,19 +228,34 @@ export class AppController {
} }
} }
private hasAnyProviderToken(settings: AppSettings): boolean { private prepareAutoResume(): void {
return Boolean( const snapshot = this.manager.getSnapshot();
settings.token.trim() const items = Object.values(snapshot.session.items);
|| settings.realDebridUseWebLogin const pendingCount = items.filter((item) => item.status === "queued" || item.status === "reconnect_wait").length;
|| (settings.megaLogin.trim() && settings.megaPassword.trim()) if (pendingCount === 0) {
|| settings.bestToken.trim() this.audit("INFO", "Auto-Resume übersprungen", {
|| settings.bestDebridUseWebLogin reason: "no_pending",
|| settings.allDebridUseWebLogin itemCount: items.length
|| settings.allDebridToken.trim() });
|| (settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim()) return;
|| settings.oneFichierApiKey.trim() }
); if (!snapshot.canStart) {
} this.audit("WARN", "Auto-Resume übersprungen", {
reason: "cannot_start",
pendingCount,
running: snapshot.session.running,
paused: snapshot.session.paused,
configuredAccounts: snapshot.accounts.length
});
return;
}
if (this.onStateHandler) {
this.beginAutoResume();
return;
}
this.autoResumePending = true;
logger.info("Auto-Resume beim Start vorgemerkt");
}
public get onState(): ((snapshot: UiSnapshot) => void) | null { public get onState(): ((snapshot: UiSnapshot) => void) | null {
return this.onStateHandler; return this.onStateHandler;
@@ -708,16 +717,41 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
await this.manager.startItems(itemIds); await this.manager.startItems(itemIds);
} }
public stop(): void { public stop(): void {
this.audit("INFO", "Session-Stopp ausgelöst"); const before = this.manager.getSnapshot();
this.manager.stop(); const startedAt = Date.now();
} this.audit("INFO", "Session-Stopp angefordert", this.sessionControlFields(before));
this.manager.stop();
public togglePause(): boolean { this.audit("INFO", "Session-Stopp angewendet", {
const paused = this.manager.togglePause(); ...this.sessionControlFields(this.manager.getSnapshot()),
this.audit("INFO", "Pause umgeschaltet", { paused }); durationMs: Date.now() - startedAt
return paused; });
} }
public togglePause(): boolean {
const before = this.manager.getSnapshot();
const startedAt = Date.now();
this.audit("INFO", "Pause angefordert", this.sessionControlFields(before));
const paused = this.manager.togglePause();
this.audit("INFO", "Pause angewendet", {
...this.sessionControlFields(this.manager.getSnapshot()),
paused,
durationMs: Date.now() - startedAt
});
return paused;
}
private sessionControlFields(snapshot: UiSnapshot): Record<string, unknown> {
const items = Object.values(snapshot.session.items);
return {
running: snapshot.session.running,
paused: snapshot.session.paused,
packageCount: Object.keys(snapshot.session.packages).length,
itemCount: items.length,
activeItemCount: items.filter((item) => item.status === "validating" || item.status === "downloading").length,
queuedItemCount: items.filter((item) => item.status === "queued" || item.status === "reconnect_wait").length
};
}
public retryExtraction(packageId: string): void { public retryExtraction(packageId: string): void {
this.audit("INFO", "Extraktion manuell wiederholt", { packageId }); this.audit("INFO", "Extraktion manuell wiederholt", { packageId });
@@ -729,9 +763,20 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
this.manager.extractNow(packageId); this.manager.extractNow(packageId);
} }
public resetPackage(packageId: string): void { public async resetPackage(packageId: string): Promise<void> {
this.audit("INFO", "Paket zurückgesetzt", { packageId }); const startedAt = Date.now();
this.manager.resetPackage(packageId); this.audit("INFO", "Paket-Reset angefordert", { packageId });
try {
await this.manager.resetPackage(packageId);
this.audit("INFO", "Paket-Reset abgeschlossen", { packageId, durationMs: Date.now() - startedAt });
} catch (error) {
this.audit("ERROR", "Paket-Reset fehlgeschlagen", {
packageId,
durationMs: Date.now() - startedAt,
error: error instanceof Error ? error.message : String(error)
});
throw error;
}
} }
public cancelPackage(packageId: string): void { public cancelPackage(packageId: string): void {
@@ -761,7 +806,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
public exportPackageSelection(packageIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } { public exportPackageSelection(packageIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } {
const selection = buildLinkExportSelection(this.manager.getSnapshot(), packageIds, []); const selection = buildLinkExportSelection(this.manager.getSnapshot(), packageIds, []);
this.audit("INFO", "Paket-Auswahl exportiert", { this.audit("INFO", "Paket-Auswahl für Export vorbereitet", {
packageCount: selection.packageCount, packageCount: selection.packageCount,
linkCount: selection.linkCount, linkCount: selection.linkCount,
packageIds packageIds
@@ -776,7 +821,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
public exportItemSelection(itemIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } { public exportItemSelection(itemIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } {
const selection = buildLinkExportSelection(this.manager.getSnapshot(), [], itemIds); const selection = buildLinkExportSelection(this.manager.getSnapshot(), [], itemIds);
this.audit("INFO", "Item-Auswahl exportiert", { this.audit("INFO", "Item-Auswahl für Export vorbereitet", {
packageCount: selection.packageCount, packageCount: selection.packageCount,
linkCount: selection.linkCount, linkCount: selection.linkCount,
itemIds itemIds
@@ -870,24 +915,31 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
}; };
} }
public recordSupportBundleExported(filePath: string, bytes: number): void { public recordSupportBundleExportSelected(): void {
const snapshot = this.manager.getSnapshot(); const snapshot = this.manager.getSnapshot();
const items = Object.values(snapshot.session.items);
const fields = { const fields = {
fileName: path.basename(filePath), phase: "selected",
bytes, running: snapshot.session.running,
paused: snapshot.session.paused,
packageCount: Object.keys(snapshot.session.packages).length, packageCount: Object.keys(snapshot.session.packages).length,
itemCount: Object.keys(snapshot.session.items).length itemCount: items.length,
activeItemCount: items.filter((item) => item.status === "validating" || item.status === "downloading").length
}; };
this.audit("INFO", "Support-Bundle exportiert", fields); this.audit("INFO", "Support-Bundle-Ziel ausgewählt", fields);
logTraceEvent("INFO", "support", "Support-Bundle exportiert", fields);
} }
public recordSupportBundleExportFailed(error: unknown): void { public recordSupportBundleExportLifecycle(event: SupportBundleExportLifecycleEvent): void {
const fields = { const messages: Record<SupportBundleExportLifecycleEvent["phase"], string> = {
error: error instanceof Error ? error.message : String(error) busy: "Support-Bundle-Export bereits aktiv",
cancel: "Support-Bundle-Export abgebrochen",
build: "Support-Bundle aufgebaut",
write: "Support-Bundle geschrieben",
success: "Support-Bundle-Export abgeschlossen",
failure: "Support-Bundle-Export fehlgeschlagen"
}; };
this.audit("ERROR", "Support-Bundle-Export fehlgeschlagen", fields); const level = event.phase === "failure" ? "ERROR" : event.phase === "busy" ? "WARN" : "INFO";
logTraceEvent("ERROR", "support", "Support-Bundle-Export fehlgeschlagen", fields); this.audit(level, messages[event.phase], { ...event });
} }
public getSupportBundleDefaultFileName(): string { public getSupportBundleDefaultFileName(): string {
@@ -1113,10 +1165,26 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
this.manager.skipItems(itemIds); this.manager.skipItems(itemIds);
} }
public resetItems(itemIds: string[]): void { public async resetItems(itemIds: string[]): Promise<void> {
this.audit("INFO", "Items zurückgesetzt", { itemIds }); const startedAt = Date.now();
this.manager.resetItems(itemIds); this.audit("INFO", "Item-Reset angefordert", { itemIds, itemCount: itemIds.length });
} try {
await this.manager.resetItems(itemIds);
this.audit("INFO", "Item-Reset abgeschlossen", {
itemIds,
itemCount: itemIds.length,
durationMs: Date.now() - startedAt
});
} catch (error) {
this.audit("ERROR", "Item-Reset fehlgeschlagen", {
itemIds,
itemCount: itemIds.length,
durationMs: Date.now() - startedAt,
error: error instanceof Error ? error.message : String(error)
});
throw error;
}
}
public removeHistoryEntry(entryId: string): void { public removeHistoryEntry(entryId: string): void {
this.audit("INFO", "Verlaufseintrag entfernt", { entryId }); this.audit("INFO", "Verlaufseintrag entfernt", { entryId });
+13 -11
View File
@@ -1,6 +1,7 @@
import fs from "node:fs"; import fs from "node:fs";
import { logTimestamp } from "./log-timestamp"; import { logTimestamp } from "./log-timestamp";
import path from "node:path"; import path from "node:path";
import { sanitizeDiagnosticFields, sanitizeDiagnosticText } from "./diagnostic-sanitizer";
type AuditLevel = "INFO" | "WARN" | "ERROR"; type AuditLevel = "INFO" | "WARN" | "ERROR";
@@ -26,11 +27,12 @@ function sanitizeFieldValue(value: unknown): string {
} }
} }
function formatFields(fields?: Record<string, unknown>): string { function formatFields(fields?: Record<string, unknown>): string {
if (!fields) { const safeFields = sanitizeDiagnosticFields(fields);
return ""; if (!safeFields) {
} return "";
const parts = Object.entries(fields) }
const parts = Object.entries(safeFields)
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "") .filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`); .map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
return parts.length > 0 ? ` | ${parts.join(" | ")}` : ""; return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
@@ -91,9 +93,9 @@ export function logAuditEvent(level: AuditLevel, message: string, fields?: Recor
if (!fs.existsSync(auditLogPath)) { if (!fs.existsSync(auditLogPath)) {
fs.writeFileSync(auditLogPath, "", "utf8"); fs.writeFileSync(auditLogPath, "", "utf8");
} }
fs.appendFileSync( fs.appendFileSync(
auditLogPath, auditLogPath,
`${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`, `${logTimestamp()} [${level}] ${sanitizeDiagnosticText(message)}${formatFields(fields)}\n`,
"utf8" "utf8"
); );
} catch { } catch {
+30
View File
@@ -0,0 +1,30 @@
import { clipboard } from "electron";
import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security";
import { logger } from "./logger";
type ClipboardIpcEvent = {
senderFrame?: { url: string } | null;
sender?: { getURL?: () => string };
};
const CLIPBOARD_TEXT_MAX_BYTES = 16 * 1024 * 1024;
export function writeClipboardTextFromIpc(
event: ClipboardIpcEvent,
text: unknown,
trustedOptions: TrustedIpcOptions
): true {
assertTrustedIpcSender(event, trustedOptions);
if (typeof text !== "string" || Buffer.byteLength(text, "utf8") > CLIPBOARD_TEXT_MAX_BYTES) {
throw new Error("Ungültiger Zwischenablageinhalt");
}
const bytes = Buffer.byteLength(text, "utf8");
try {
clipboard.writeText(text);
} catch (error) {
logger.warn(`Zwischenablage-Schreiben fehlgeschlagen: ${String(error)}`);
throw error;
}
logger.info(`Zwischenablage geschrieben: bytes=${bytes}`);
return true;
}
+88 -55
View File
@@ -1,7 +1,8 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { AsyncLocalStorage } from "node:async_hooks"; import { AsyncLocalStorage } from "node:async_hooks";
import { logTimestamp } from "./log-timestamp"; import { logTimestamp } from "./log-timestamp";
import { formatDiagnosticLink, sanitizeDiagnosticAccountLabel, sanitizeDiagnosticFields, sanitizeDiagnosticText } from "./diagnostic-sanitizer";
export interface ConversionPhase { export interface ConversionPhase {
atMs: number; atMs: number;
@@ -15,37 +16,59 @@ export interface ConversionPhase {
detail?: string; detail?: string;
} }
export interface ConversionTrace { export interface ConversionTrace {
startedAt: number; startedAt: number;
itemId: string; attemptId?: string;
itemName: string; itemId: string;
packageId?: string;
itemName: string;
link: string; link: string;
providerOrder: string; providerOrder: string;
notes: Record<string, string | number>; notes: Record<string, string | number>;
phases: ConversionPhase[]; phases: ConversionPhase[];
} }
const conversionContext = new AsyncLocalStorage<ConversionTrace>(); const conversionContext = new AsyncLocalStorage<ConversionTrace>();
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function sanitizeConversionText(value: unknown, itemName = ""): string {
let safeValue = sanitizeDiagnosticText(value)
.replace(/\b(?:[a-z0-9-]+\.)+[a-z]{2,63}#[a-f0-9]{10}\b/gi, "<redacted-link>");
const safeItemName = sanitizeDiagnosticText(itemName).trim();
if (safeItemName) {
safeValue = safeValue.replace(new RegExp(escapeRegExp(safeItemName), "gi"), "<redacted-item>");
}
return safeValue;
}
export function traceConversionPhase(phase: Omit<ConversionPhase, "atMs">): void {
const trace = conversionContext.getStore();
if (!trace) {
return;
}
trace.phases.push({
...phase,
phase: sanitizeDiagnosticText(phase.phase),
provider: phase.provider ? sanitizeDiagnosticText(phase.provider) : undefined,
account: phase.account ? sanitizeDiagnosticAccountLabel(phase.account) : undefined,
tokenState: phase.tokenState ? sanitizeDiagnosticText(phase.tokenState) : undefined,
outcome: phase.outcome ? sanitizeDiagnosticText(phase.outcome) : undefined,
detail: phase.detail ? sanitizeDiagnosticText(phase.detail) : undefined,
atMs: Date.now() - trace.startedAt
});
}
function shortLink(link: string): string { export function traceConversionNote(key: string, value: string | number): void {
const raw = String(link || "").trim(); const trace = conversionContext.getStore();
return raw.length > 90 ? `${raw.slice(0, 90)}` : raw; if (!trace) {
} return;
}
export function traceConversionPhase(phase: Omit<ConversionPhase, "atMs">): void { const safeKey = sanitizeDiagnosticText(key);
const trace = conversionContext.getStore(); const safeValue = sanitizeDiagnosticFields({ [key]: value })?.[key];
if (!trace) { trace.notes[safeKey] = typeof safeValue === "number" ? safeValue : sanitizeDiagnosticText(safeValue);
return;
}
trace.phases.push({ ...phase, atMs: Date.now() - trace.startedAt });
}
export function traceConversionNote(key: string, value: string | number): void {
const trace = conversionContext.getStore();
if (!trace) {
return;
}
trace.notes[key] = value;
} }
export function hasActiveConversionTrace(): boolean { export function hasActiveConversionTrace(): boolean {
@@ -57,23 +80,31 @@ export function formatConversionBlock(
outcome: string, outcome: string,
detail: string, detail: string,
totalMs: number totalMs: number
): string { ): string {
const noteParts = Object.entries(trace.notes) const safeNotes = sanitizeDiagnosticFields(trace.notes) || {};
.map(([key, value]) => `${key}=${value}`) const noteParts = Object.entries(safeNotes)
.join(" "); .map(([key, value]) => `${sanitizeConversionText(key, trace.itemName)}=${sanitizeConversionText(value, trace.itemName)}`)
const header = `${logTimestamp()} [CONV] item=${trace.itemName || trace.itemId} | order=${trace.providerOrder || "?"}` .join(" ");
+ ` | result=${outcome}${detail ? ` (${detail})` : ""} | total=${totalMs}ms${noteParts ? ` | ${noteParts}` : ""}` const safeOrder = sanitizeConversionText(trace.providerOrder || "?", trace.itemName);
+ ` | link=${shortLink(trace.link)}`; const safeOutcome = sanitizeConversionText(outcome, trace.itemName);
const lines = trace.phases.map((p) => { const safeDetail = sanitizeConversionText(detail, trace.itemName);
const parts: string[] = []; const correlation = [
if (p.provider) parts.push(`provider=${p.provider}`); trace.attemptId ? `attemptId=${sanitizeConversionText(trace.attemptId)}` : "",
if (p.account) parts.push(`account=${p.account}`); `itemId=${sanitizeConversionText(trace.itemId)}`,
if (p.tokenState) parts.push(`token=${p.tokenState}`); trace.packageId ? `packageId=${sanitizeConversionText(trace.packageId)}` : ""
if (typeof p.queueWaitMs === "number") parts.push(`queueWaitMs=${p.queueWaitMs}`); ].filter(Boolean).join(" | ");
if (typeof p.workMs === "number") parts.push(`workMs=${p.workMs}`); const header = `${logTimestamp()} [CONV] ${correlation} | order=${safeOrder}`
if (p.outcome) parts.push(`outcome=${p.outcome}`); + ` | result=${safeOutcome}${safeDetail ? ` (${safeDetail})` : ""} | total=${totalMs}ms${noteParts ? ` | ${noteParts}` : ""}`;
if (p.detail) parts.push(`detail=${String(p.detail).replace(/\r?\n/g, "\\n")}`); const lines = trace.phases.map((p) => {
return ` +${p.atMs}ms ${p.phase}${parts.length ? ` | ${parts.join(" | ")}` : ""}`; const parts: string[] = [];
if (p.provider) parts.push(`provider=${sanitizeConversionText(p.provider, trace.itemName)}`);
if (p.account) parts.push(`account=${sanitizeDiagnosticAccountLabel(p.account)}`);
if (p.tokenState) parts.push(`token=${sanitizeConversionText(p.tokenState, trace.itemName)}`);
if (typeof p.queueWaitMs === "number") parts.push(`queueWaitMs=${p.queueWaitMs}`);
if (typeof p.workMs === "number") parts.push(`workMs=${p.workMs}`);
if (p.outcome) parts.push(`outcome=${sanitizeConversionText(p.outcome, trace.itemName)}`);
if (p.detail) parts.push(`detail=${sanitizeConversionText(p.detail, trace.itemName)}`);
return ` +${p.atMs}ms ${sanitizeConversionText(p.phase, trace.itemName)}${parts.length ? ` | ${parts.join(" | ")}` : ""}`;
}); });
return [header, ...lines].join("\n"); return [header, ...lines].join("\n");
} }
@@ -161,16 +192,18 @@ function writeConversionBlock(block: string): void {
} }
} }
export async function runWithConversionTrace<T>( export async function runWithConversionTrace<T>(
meta: { itemId: string; itemName: string; link: string; providerOrder: string }, meta: { attemptId?: string; itemId: string; packageId?: string; itemName: string; link: string; providerOrder: string },
fn: () => Promise<T> fn: () => Promise<T>
): Promise<T> { ): Promise<T> {
const trace: ConversionTrace = { const trace: ConversionTrace = {
startedAt: Date.now(), startedAt: Date.now(),
itemId: meta.itemId, attemptId: meta.attemptId ? sanitizeDiagnosticText(meta.attemptId) : undefined,
itemName: meta.itemName, itemId: sanitizeDiagnosticText(meta.itemId),
link: meta.link, packageId: meta.packageId ? sanitizeDiagnosticText(meta.packageId) : undefined,
providerOrder: meta.providerOrder, itemName: sanitizeDiagnosticText(meta.itemName),
link: formatDiagnosticLink(meta.link),
providerOrder: sanitizeDiagnosticText(meta.providerOrder),
notes: {}, notes: {},
phases: [] phases: []
}; };
@@ -179,9 +212,9 @@ export async function runWithConversionTrace<T>(
try { try {
const result = await conversionContext.run(trace, fn); const result = await conversionContext.run(trace, fn);
return result; return result;
} catch (error) { } catch (error) {
outcome = "FAIL"; outcome = "FAIL";
detail = String((error as { message?: string })?.message || error || "").replace(/^Error:\s*/i, "").slice(0, 160); detail = sanitizeDiagnosticText(String((error as { message?: string })?.message || error || "").replace(/^Error:\s*/i, "").slice(0, 160));
throw error; throw error;
} finally { } finally {
const totalMs = Date.now() - trace.startedAt; const totalMs = Date.now() - trace.startedAt;
+122 -68
View File
@@ -6,9 +6,10 @@ import { isDebridLinkApiKeyDailyLimitReached, isMegaDebridAccountDisabled, isMeg
import { isMegaDebridResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors"; import { isMegaDebridResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors";
import { APP_VERSION, REQUEST_RETRIES } from "./constants"; import { APP_VERSION, REQUEST_RETRIES } from "./constants";
import { logger } from "./logger"; import { logger } from "./logger";
import { logAccountRotation } from "./account-rotation-log"; import { logAccountRotation } from "./account-rotation-log";
import { traceConversionPhase } from "./conversion-trace"; import { traceConversionPhase } from "./conversion-trace";
import { RealDebridClient, UnrestrictedLink } from "./realdebrid"; import { sanitizeDiagnosticText, type DiagnosticRedactions } from "./diagnostic-sanitizer";
import { RealDebridClient, UnrestrictedLink } from "./realdebrid";
import { MEGA_DEBRID_NO_SERVER_RE } from "./mega-web-fallback"; import { MEGA_DEBRID_NO_SERVER_RE } from "./mega-web-fallback";
import { isMegaFileUrl, resolveMegaFilename } from "./mega-public-api"; import { isMegaFileUrl, resolveMegaFilename } from "./mega-public-api";
import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils"; import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils";
@@ -23,8 +24,12 @@ const ALL_DEBRID_API_BASE_V41 = "https://api.alldebrid.com/v4.1";
const MEGA_DEBRID_API_BASE = "https://www.mega-debrid.eu/api.php"; const MEGA_DEBRID_API_BASE = "https://www.mega-debrid.eu/api.php";
const ONEFICHIER_API_BASE = "https://api.1fichier.com/v1"; const ONEFICHIER_API_BASE = "https://api.1fichier.com/v1";
const ONEFICHIER_URL_RE = /^https?:\/\/(?:www\.)?(?:1fichier\.com|alterupload\.com|cjoint\.net|desfichiers\.com|dfichiers\.com|megadl\.fr|mesfichiers\.org|piecejointe\.net|pjointe\.com|tenvoi\.com|dl4free\.com)\/\?([a-z0-9]{5,20})$/i; const ONEFICHIER_URL_RE = /^https?:\/\/(?:www\.)?(?:1fichier\.com|alterupload\.com|cjoint\.net|desfichiers\.com|dfichiers\.com|megadl\.fr|mesfichiers\.org|piecejointe\.net|pjointe\.com|tenvoi\.com|dl4free\.com)\/\?([a-z0-9]{5,20})$/i;
function sanitizeProviderErrorText(error: unknown, redactions: DiagnosticRedactions = {}): string {
return sanitizeDiagnosticText(compactErrorText(error).replace(/^Error:\s*/i, ""), redactions);
}
const DEBRID_LINK_API_BASE = "https://debrid-link.com/api/v2"; const DEBRID_LINK_API_BASE = "https://debrid-link.com/api/v2";
const DEBRID_LINK_KEY_QUOTA_ERRORS = new Set(["maxLink", "maxData"]); const DEBRID_LINK_KEY_QUOTA_ERRORS = new Set(["maxLink", "maxData"]);
@@ -67,7 +72,7 @@ export function resetDebridLinkRuntimeStateForTests(): void {
debridLinkKeyHostCooldownDetails.clear(); debridLinkKeyHostCooldownDetails.clear();
} }
export function pruneDebridLinkRuntimeStateForKeys(activeKeyIds: Set<string>): void { export function pruneDebridLinkRuntimeStateForKeys(activeKeyIds: Set<string>): void {
for (const keyId of debridLinkKeyCooldowns.keys()) { for (const keyId of debridLinkKeyCooldowns.keys()) {
if (!activeKeyIds.has(keyId)) { if (!activeKeyIds.has(keyId)) {
debridLinkKeyCooldowns.delete(keyId); debridLinkKeyCooldowns.delete(keyId);
@@ -333,6 +338,22 @@ export function clearMegaDebridEmptyResponseStreak(accountId: string): void {
megaDebridEmptyResponseStreaks.delete(accountId); megaDebridEmptyResponseStreaks.delete(accountId);
} }
function clearDebridLinkRuntimeStateForKeys(keyIds: Set<string>): void {
for (const keyId of keyIds) {
debridLinkKeyCooldowns.delete(keyId);
debridLinkKeyCooldownDetails.delete(keyId);
debridLinkKeyRuntimeStatuses.delete(keyId);
}
for (const stateKey of [...debridLinkKeyHostCooldowns.keys()]) {
const separator = stateKey.indexOf("|");
const keyId = separator >= 0 ? stateKey.slice(0, separator) : stateKey;
if (keyIds.has(keyId)) {
debridLinkKeyHostCooldowns.delete(stateKey);
debridLinkKeyHostCooldownDetails.delete(stateKey);
}
}
}
export function getMegaDebridAccountAttemptTimeoutMs(): number { export function getMegaDebridAccountAttemptTimeoutMs(): number {
const fromEnv = Number(process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS ?? NaN); const fromEnv = Number(process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS ?? NaN);
return Number.isFinite(fromEnv) && fromEnv >= 10 && fromEnv <= 10 * 60 * 1000 return Number.isFinite(fromEnv) && fromEnv >= 10 && fromEnv <= 10 * 60 * 1000
@@ -642,14 +663,18 @@ function hasMegaDebridCredentials(settings: AppSettings): boolean {
return parseMegaDebridAccounts(mergeMegaDebridCredentialPools(settings.megaDebridApiCredentials || "", settings.megaDebridWebCredentials || "") || settings.megaCredentials || "").length > 0; return parseMegaDebridAccounts(mergeMegaDebridCredentialPools(settings.megaDebridApiCredentials || "", settings.megaDebridWebCredentials || "") || settings.megaCredentials || "").length > 0;
} }
function isMegaDebridModeEnabled(settings: AppSettings, mode: "api" | "web"): boolean { function isMegaDebridModeEnabled(settings: AppSettings, mode: "api" | "web"): boolean {
if (mode === "api") { const hasDedicatedPoolCredentials = Boolean(
return settings.megaDebridApiEnabled String(settings.megaDebridApiCredentials || "").trim()
|| (hasMegaDebridCredentials(settings) && !settings.megaDebridApiEnabled && !settings.megaDebridWebEnabled && settings.megaDebridPreferApi); || String(settings.megaDebridWebCredentials || "").trim()
} );
return settings.megaDebridWebEnabled if (mode === "api") {
|| (hasMegaDebridCredentials(settings) && !settings.megaDebridApiEnabled && !settings.megaDebridWebEnabled && !settings.megaDebridPreferApi); return settings.megaDebridApiEnabled
} || (!hasDedicatedPoolCredentials && hasMegaDebridCredentials(settings) && !settings.megaDebridApiEnabled && !settings.megaDebridWebEnabled && settings.megaDebridPreferApi);
}
return settings.megaDebridWebEnabled
|| (!hasDedicatedPoolCredentials && hasMegaDebridCredentials(settings) && !settings.megaDebridApiEnabled && !settings.megaDebridWebEnabled && !settings.megaDebridPreferApi);
}
function resolveMegaDebridProvider(settings: AppSettings, provider: DebridProvider): DebridProvider { function resolveMegaDebridProvider(settings: AppSettings, provider: DebridProvider): DebridProvider {
if (provider !== "megadebrid") { if (provider !== "megadebrid") {
@@ -768,6 +793,7 @@ function waitForPromiseWithSignal<T>(promise: Promise<T>, signal?: AbortSignal):
return promise; return promise;
} }
if (signal.aborted) { if (signal.aborted) {
void promise.catch(() => {});
return Promise.reject(new Error("aborted:debrid")); return Promise.reject(new Error("aborted:debrid"));
} }
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
@@ -1076,12 +1102,13 @@ async function requestDebridLinkPayloadWithKey(
body: payloadBody, body: payloadBody,
signal: withTimeoutSignal(signal, API_TIMEOUT_MS) signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
}); });
const responseText = await response.text(); const responseText = await response.text();
const payload = parseJsonSafe(responseText); const payload = parseJsonSafe(responseText);
if (!payload) { if (!payload) {
const description = looksLikeHtmlResponse(response.headers.get("content-type") || "", responseText) const rawDescription = looksLikeHtmlResponse(response.headers.get("content-type") || "", responseText)
? `Debrid-Link lieferte HTML statt JSON (HTTP ${response.status})` ? `Debrid-Link lieferte HTML statt JSON (HTTP ${response.status})`
: compactErrorText(responseText) || `Debrid-Link lieferte kein JSON (HTTP ${response.status})`; : compactErrorText(responseText) || `Debrid-Link lieferte kein JSON (HTTP ${response.status})`;
const description = sanitizeDiagnosticText(rawDescription, { secretValues: [apiKey.token] });
const error = new DebridLinkApiError( const error = new DebridLinkApiError(
response.status, response.status,
"requestError", "requestError",
@@ -1096,11 +1123,15 @@ async function requestDebridLinkPayloadWithKey(
throw error; throw error;
} }
if (!response.ok || !parseDebridLinkSuccess(payload)) { if (!response.ok || !parseDebridLinkSuccess(payload)) {
const error = new DebridLinkApiError( const description = sanitizeDiagnosticText(
response.status, parseDebridLinkErrorDescription(payload) || `HTTP ${response.status}`,
parseDebridLinkErrorCode(payload) || `HTTP ${response.status}`, { secretValues: [apiKey.token] }
parseDebridLinkErrorDescription(payload) || `HTTP ${response.status}`, );
const error = new DebridLinkApiError(
response.status,
parseDebridLinkErrorCode(payload) || `HTTP ${response.status}`,
description,
parseRetryAfterMs(response.headers.get("retry-after")), parseRetryAfterMs(response.headers.get("retry-after")),
payload payload
); );
@@ -1116,9 +1147,9 @@ async function requestDebridLinkPayloadWithKey(
if (error instanceof DebridLinkApiError) { if (error instanceof DebridLinkApiError) {
throw error; throw error;
} }
lastTransportError = compactErrorText(error); lastTransportError = sanitizeProviderErrorText(error, { secretValues: [apiKey.token] });
if (signal?.aborted || (/aborted/i.test(lastTransportError) && !/timeout/i.test(lastTransportError))) { if (signal?.aborted || (/aborted/i.test(lastTransportError) && !/timeout/i.test(lastTransportError))) {
throw error; throw new Error(lastTransportError);
} }
if (attempt >= maxAttempts || !isRetryableErrorText(lastTransportError)) { if (attempt >= maxAttempts || !isRetryableErrorText(lastTransportError)) {
throw new Error(lastTransportError || "Debrid-Link Request fehlgeschlagen"); throw new Error(lastTransportError || "Debrid-Link Request fehlgeschlagen");
@@ -1955,7 +1986,11 @@ class MegaDebridClient {
if (payload && String(payload.response_code || "").toLowerCase().includes("token")) { if (payload && String(payload.response_code || "").toLowerCase().includes("token")) {
MegaDebridClient.invalidateCredentialIfCurrent(cacheKey, generation); MegaDebridClient.invalidateCredentialIfCurrent(cacheKey, generation);
} }
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: `response_code=${payload?.response_code || "?"} ${String(payload?.response_text || "").slice(0, 80)}`.trim() }); const detail = sanitizeDiagnosticText(`response_code=${payload?.response_code || "?"} ${String(payload?.response_text || "").slice(0, 80)}`.trim(), {
accountValues: [this.login],
secretValues: [this.password]
});
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail });
return null; return null;
} }
const token = String(payload.token || "").trim(); const token = String(payload.token || "").trim();
@@ -2002,7 +2037,10 @@ class MegaDebridClient {
if (tokenInvalidated) { if (tokenInvalidated) {
this.clearTokenCache(); this.clearTokenCache();
} }
const errorText = String(payload?.response_text || "").trim(); const errorText = sanitizeDiagnosticText(String(payload?.response_text || "").trim(), {
accountValues: [this.login],
secretValues: [this.password, token]
});
traceConversionPhase({ phase: "api-getlink", provider: "megadebrid-api", workMs: Date.now() - getLinkStartedAt, outcome: "error", detail: `response_code=${payload?.response_code || "?"}${tokenInvalidated ? " (token-cache-geleert)" : ""} ${errorText}`.trim() }); traceConversionPhase({ phase: "api-getlink", provider: "megadebrid-api", workMs: Date.now() - getLinkStartedAt, outcome: "error", detail: `response_code=${payload?.response_code || "?"}${tokenInvalidated ? " (token-cache-geleert)" : ""} ${errorText}`.trim() });
if (errorText) { if (errorText) {
throw new Error(`Mega-Debrid API: ${errorText}`); throw new Error(`Mega-Debrid API: ${errorText}`);
@@ -2144,8 +2182,8 @@ class MegaDebridClient {
const entry = orderedEntries[orderPos]; const entry = orderedEntries[orderPos];
const account = entry.account; const account = entry.account;
const idx = entry.idx; const idx = entry.idx;
const accountLabel = ` (${account.label}/${totalAccounts}, ${account.maskedLogin})`; const accountLabel = ` (${account.label}/${totalAccounts})`;
const rotationLabel = `${account.label}/${totalAccounts} (${account.maskedLogin})`; const rotationLabel = `${account.label}/${totalAccounts}`;
if (isMegaDebridAccountDisabled(settings, account.id, mode)) { if (isMegaDebridAccountDisabled(settings, account.id, mode)) {
logger.info(`Mega-Debrid${accountLabel}: uebersprungen (manuell deaktiviert), pruefe naechsten Account`); logger.info(`Mega-Debrid${accountLabel}: uebersprungen (manuell deaktiviert), pruefe naechsten Account`);
@@ -2195,8 +2233,8 @@ class MegaDebridClient {
: accountAttemptTimeoutSignal; : accountAttemptTimeoutSignal;
try { try {
const client = new MegaDebridClient(account.login, account.password, mode, allowApiFallback, megaWebUnrestrict); const client = new MegaDebridClient(account.login, account.password, mode, allowApiFallback, megaWebUnrestrict);
const result = await client.unrestrictLink(link, accountAttemptSignal); const result = await waitForPromiseWithSignal(client.unrestrictLink(link, accountAttemptSignal), accountAttemptSignal);
clearMegaDebridAccountCooldownState(cooldownKey); clearMegaDebridAccountCooldownState(cooldownKey);
clearMegaDebridEmptyResponseStreak(cooldownKey); clearMegaDebridEmptyResponseStreak(cooldownKey);
const elapsedMs = Date.now() - testStartedAt; const elapsedMs = Date.now() - testStartedAt;
traceConversionPhase({ phase: "mega-account", provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web", account: rotationLabel, workMs: elapsedMs, outcome: "ok" }); traceConversionPhase({ phase: "mega-account", provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web", account: rotationLabel, workMs: elapsedMs, outcome: "ok" });
@@ -2221,9 +2259,10 @@ class MegaDebridClient {
}; };
} catch (error) { } catch (error) {
const elapsedMs = Date.now() - testStartedAt; const elapsedMs = Date.now() - testStartedAt;
const abortText = compactErrorText(error).replace(/^Error:\s*/i, ""); const redactions = { accountValues: [account.login], secretValues: [account.password] };
const abortText = sanitizeProviderErrorText(error, redactions);
if (signal?.aborted) { if (signal?.aborted) {
throw error; throw new Error(abortText);
} }
// Timeout/abort on THIS account (the shared unrestrict timeout fired). The // Timeout/abort on THIS account (the shared unrestrict timeout fired). The
// account-wide cooldown exists ONLY to make the retry rotate to another // account-wide cooldown exists ONLY to make the retry rotate to another
@@ -2278,7 +2317,7 @@ class MegaDebridClient {
} }
throw new Error(`Mega-Debrid${accountLabel}: ${abortText}`); throw new Error(`Mega-Debrid${accountLabel}: ${abortText}`);
} }
const failure = MegaDebridClient.classifyAccountFailure(error); const failure = MegaDebridClient.classifyAccountFailure(error, redactions);
traceConversionPhase({ traceConversionPhase({
phase: "mega-account", phase: "mega-account",
provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web", provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web",
@@ -2326,7 +2365,7 @@ class MegaDebridClient {
for (let nextPos = orderPos + 1; nextPos < orderedEntries.length; nextPos += 1) { for (let nextPos = orderPos + 1; nextPos < orderedEntries.length; nextPos += 1) {
const nextAcc = orderedEntries[nextPos].account; const nextAcc = orderedEntries[nextPos].account;
if (!isMegaDebridAccountDisabled(settings, nextAcc.id, mode) && !isMegaDebridAccountDailyLimitReached(settings, nextAcc.id) && !getMegaDebridAccountCooldownState(`${nextAcc.id}:${mode}`)) { if (!isMegaDebridAccountDisabled(settings, nextAcc.id, mode) && !isMegaDebridAccountDailyLimitReached(settings, nextAcc.id) && !getMegaDebridAccountCooldownState(`${nextAcc.id}:${mode}`)) {
nextLabel = `${nextAcc.label}/${totalAccounts} (${nextAcc.maskedLogin})`; nextLabel = `${nextAcc.label}/${totalAccounts}`;
break; break;
} }
} }
@@ -2363,10 +2402,11 @@ class MegaDebridClient {
throw new Error(failures.join(" | ") || "Mega-Debrid: Kein aktiver Account verfuegbar"); throw new Error(failures.join(" | ") || "Mega-Debrid: Kein aktiver Account verfuegbar");
} }
static classifyAccountFailure( static classifyAccountFailure(
error: unknown error: unknown,
): { fatal: boolean; cooldownMs: number; message: string; category: MegaDebridCooldownCategory; limitSignal?: boolean } { redactions: DiagnosticRedactions = {}
const errorText = compactErrorText(error).replace(/^Error:\s*/i, ""); ): { fatal: boolean; cooldownMs: number; message: string; category: MegaDebridCooldownCategory; limitSignal?: boolean } {
const errorText = sanitizeProviderErrorText(error, redactions);
if (/aborted/i.test(errorText) && !/timeout/i.test(errorText)) { if (/aborted/i.test(errorText) && !/timeout/i.test(errorText)) {
return { fatal: true, cooldownMs: 0, message: errorText, category: "temporary" }; return { fatal: true, cooldownMs: 0, message: errorText, category: "temporary" };
@@ -2909,8 +2949,8 @@ class DebridLinkClient {
for (let keyIdx = 0; keyIdx < this.apiKeys.length; keyIdx += 1) { for (let keyIdx = 0; keyIdx < this.apiKeys.length; keyIdx += 1) {
const apiKey = this.apiKeys[keyIdx]; const apiKey = this.apiKeys[keyIdx];
const keyLabel = ` (${apiKey.label}/${totalKeys}, ${apiKey.masked})`; const keyLabel = ` (${apiKey.label}/${totalKeys})`;
const rotationLabel = `${apiKey.label}/${totalKeys} (${apiKey.masked})`; const rotationLabel = `${apiKey.label}/${totalKeys}`;
if (isDebridLinkApiKeyDisabled(settings, apiKey.id)) { if (isDebridLinkApiKeyDisabled(settings, apiKey.id)) {
logger.info(`Debrid-Link${keyLabel}: uebersprungen (manuell deaktiviert), pruefe naechsten Key`); logger.info(`Debrid-Link${keyLabel}: uebersprungen (manuell deaktiviert), pruefe naechsten Key`);
logAccountRotation("INFO", providerName, rotationLabel, "SKIP_DISABLED", { reason: "manually disabled" }); logAccountRotation("INFO", providerName, rotationLabel, "SKIP_DISABLED", { reason: "manually disabled" });
@@ -2978,7 +3018,7 @@ class DebridLinkClient {
} catch (error) { } catch (error) {
const failure = await this.classifyKeyFailure(error, apiKey, link, signal); const failure = await this.classifyKeyFailure(error, apiKey, link, signal);
const elapsedMs = Date.now() - testStartedAt; const elapsedMs = Date.now() - testStartedAt;
const abortText = compactErrorText(error).replace(/^Error:\s*/i, ""); const abortText = sanitizeProviderErrorText(error, { secretValues: [apiKey.token] });
if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) { if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) {
const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs(); const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs();
if (ranLongEnough) { if (ranLongEnough) {
@@ -3059,7 +3099,7 @@ class DebridLinkClient {
for (let nextIdx = keyIdx + 1; nextIdx < this.apiKeys.length; nextIdx += 1) { for (let nextIdx = keyIdx + 1; nextIdx < this.apiKeys.length; nextIdx += 1) {
const nextKey = this.apiKeys[nextIdx]; const nextKey = this.apiKeys[nextIdx];
if (!isDebridLinkApiKeyDisabled(settings, nextKey.id) && !isDebridLinkApiKeyDailyLimitReached(settings, nextKey.id) && !getDebridLinkKeyCooldownState(nextKey.id)) { if (!isDebridLinkApiKeyDisabled(settings, nextKey.id) && !isDebridLinkApiKeyDailyLimitReached(settings, nextKey.id) && !getDebridLinkKeyCooldownState(nextKey.id)) {
nextLabel = `${nextKey.label}/${totalKeys} (${nextKey.masked})`; nextLabel = `${nextKey.label}/${totalKeys}`;
break; break;
} }
} }
@@ -3226,22 +3266,24 @@ class DebridLinkClient {
} }
} }
private async classifyKeyFailure( private async classifyKeyFailure(
error: unknown, error: unknown,
apiKey: ReturnType<typeof parseDebridLinkApiKeys>[number], apiKey: ReturnType<typeof parseDebridLinkApiKeys>[number],
link: string, link: string,
signal?: AbortSignal signal?: AbortSignal
): Promise<{ fatal: boolean; cooldownMs: number; message: string; category?: DebridLinkCooldownCategory; providerWide?: boolean; hostOnly?: boolean; hoster?: string }> { ): Promise<{ fatal: boolean; cooldownMs: number; message: string; category?: DebridLinkCooldownCategory; providerWide?: boolean; hostOnly?: boolean; hoster?: string }> {
const errorText = compactErrorText(error).replace(/^Error:\s*/i, ""); const redactions = { secretValues: [apiKey.token] };
if (error instanceof DebridLinkApiError) { const errorText = sanitizeProviderErrorText(error, redactions);
const code = String(error.code || "").trim() || `HTTP ${error.status}`; if (error instanceof DebridLinkApiError) {
const description = error.message || code; const code = String(error.code || "").trim() || `HTTP ${error.status}`;
const safeCode = sanitizeDiagnosticText(code, redactions);
const description = sanitizeDiagnosticText(error.message || code, redactions);
if (DEBRID_LINK_INVALID_TOKEN_ERRORS.has(code)) { if (DEBRID_LINK_INVALID_TOKEN_ERRORS.has(code)) {
return { return {
fatal: false, fatal: false,
cooldownMs: DEBRID_LINK_INVALID_KEY_COOLDOWN_MS, cooldownMs: DEBRID_LINK_INVALID_KEY_COOLDOWN_MS,
message: `ungueltiger oder deaktivierter API-Key (${code}: ${description})`, message: `ungueltiger oder deaktivierter API-Key (${safeCode}: ${description})`,
category: "invalid" category: "invalid"
}; };
} }
@@ -3249,7 +3291,7 @@ class DebridLinkClient {
return { return {
fatal: false, fatal: false,
cooldownMs: error.retryAfterMs || DEBRID_LINK_RATE_LIMIT_COOLDOWN_MS, cooldownMs: error.retryAfterMs || DEBRID_LINK_RATE_LIMIT_COOLDOWN_MS,
message: `API-Rate-Limit erreicht (${code}: ${description})`, message: `API-Rate-Limit erreicht (${safeCode}: ${description})`,
category: "rate_limit" category: "rate_limit"
}; };
} }
@@ -3260,7 +3302,7 @@ class DebridLinkClient {
return { return {
fatal: false, fatal: false,
cooldownMs, cooldownMs,
message: `Quota erreicht fuer ${hosterLabel} (${code}: ${description})`, message: `Quota erreicht fuer ${hosterLabel} (${safeCode}: ${description})`,
category: "quota", category: "quota",
hostOnly: true, hostOnly: true,
hoster: hosterRaw hoster: hosterRaw
@@ -3271,7 +3313,7 @@ class DebridLinkClient {
return { return {
fatal: false, fatal: false,
cooldownMs, cooldownMs,
message: `Quota erreicht (${code}: ${description})`, message: `Quota erreicht (${safeCode}: ${description})`,
category: "quota" category: "quota"
}; };
} }
@@ -3279,7 +3321,7 @@ class DebridLinkClient {
return { return {
fatal: false, fatal: false,
cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS, cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS,
message: `Link kann aktuell nicht generiert werden (${code}: ${description})`, message: `Link kann aktuell nicht generiert werden (${safeCode}: ${description})`,
category: "temporary", category: "temporary",
providerWide: true providerWide: true
}; };
@@ -3288,7 +3330,7 @@ class DebridLinkClient {
return { return {
fatal: false, fatal: false,
cooldownMs: 0, cooldownMs: 0,
message: `Key kann Link aktuell nicht verarbeiten (${code}: ${description})`, message: `Key kann Link aktuell nicht verarbeiten (${safeCode}: ${description})`,
category: "skip" category: "skip"
}; };
} }
@@ -3304,7 +3346,7 @@ class DebridLinkClient {
return { return {
fatal: false, fatal: false,
cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS, cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS,
message: `temporärer API-Fehler (${code}: ${description})` message: `temporärer API-Fehler (${safeCode}: ${description})`
}; };
} }
return { return {
@@ -3744,13 +3786,25 @@ export class DebridService {
this.options = options; this.options = options;
} }
public setSettings(next: AppSettings): void { public setSettings(next: AppSettings): void {
const prev = this.settings; const prev = this.settings;
this.settings = cloneSettings(next); this.settings = cloneSettings(next);
if (prev.debridLinkApiKeys !== next.debridLinkApiKeys) { const previousDebridLinkDisabled = new Set(prev.debridLinkDisabledKeyIds || []);
this.cachedDebridLinkClient = null; const nextDebridLinkDisabled = new Set(next.debridLinkDisabledKeyIds || []);
this.cachedDebridLinkKey = ""; const changedDebridLinkKeys = new Set<string>();
for (const keyId of new Set([...previousDebridLinkDisabled, ...nextDebridLinkDisabled])) {
if (previousDebridLinkDisabled.has(keyId) !== nextDebridLinkDisabled.has(keyId)) {
changedDebridLinkKeys.add(keyId);
}
}
if (changedDebridLinkKeys.size > 0) {
clearDebridLinkRuntimeStateForKeys(changedDebridLinkKeys);
}
if (prev.debridLinkApiKeys !== next.debridLinkApiKeys || changedDebridLinkKeys.size > 0) {
this.cachedDebridLinkClient = null;
this.cachedDebridLinkKey = "";
} }
if (prev.linkSnappyLogin !== next.linkSnappyLogin || prev.linkSnappyPassword !== next.linkSnappyPassword) { if (prev.linkSnappyLogin !== next.linkSnappyLogin || prev.linkSnappyPassword !== next.linkSnappyPassword) {
this.cachedLinkSnappyClient = null; this.cachedLinkSnappyClient = null;
+227
View File
@@ -0,0 +1,227 @@
import crypto from "node:crypto";
export interface DiagnosticRedactions {
accountValues?: readonly string[];
secretValues?: readonly string[];
}
const REDACTED = "<redacted>";
const REDACTED_ACCOUNT = "<redacted-account>";
const REDACTED_PATH = "<redacted-path>";
const DIAGNOSTIC_LINK_RE = /^[a-z0-9.-]+#[a-f0-9]{10}$/i;
const SECRET_FIELD_NAMES = new Set([
"password",
"passwords",
"passwd",
"pwd",
"token",
"tokens",
"apitoken",
"accesstoken",
"refreshtoken",
"apikey",
"apikeys",
"secret",
"clientsecret",
"auth",
"authorization",
"proxyauthorization",
"cookie",
"cookies",
"sessioncookie",
"setcookie",
"session",
"credential",
"credentials"
]);
const ACCOUNT_FIELD_NAMES = new Set(["login", "username", "user", "email", "accountlogin", "accountemail"]);
const LINK_FIELD_NAMES = new Set([
"link",
"url",
"sourceurl",
"sourcelink",
"directurl",
"downloadurl",
"resumeurl",
"redirecturl",
"targeturl",
"directlink"
]);
const PATH_FIELD_NAMES = new Set([
"path",
"filepath",
"localpath",
"targetpath",
"outputpath",
"extractpath",
"directory",
"dir",
"outputdir",
"extractdir",
"downloadpath"
]);
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function sensitiveVariants(value: string): string[] {
const trimmed = value.trim();
if (!trimmed) {
return [];
}
const variants = new Set<string>([trimmed]);
variants.add(encodeURIComponent(trimmed));
const jsonEscaped = JSON.stringify(trimmed).slice(1, -1);
if (jsonEscaped) {
variants.add(jsonEscaped);
}
return [...variants].filter(Boolean).sort((left, right) => right.length - left.length);
}
function replaceSensitiveValue(source: string, value: string, replacement: string, caseInsensitive: boolean): string {
let output = source;
for (const variant of sensitiveVariants(value)) {
const escaped = escapeRegExp(variant);
const flags = caseInsensitive ? "gi" : "g";
if (variant.length >= 4) {
output = output.replace(new RegExp(escaped, flags), () => replacement);
continue;
}
output = output.replace(
new RegExp(`(^|[^A-Za-z0-9])${escaped}(?=$|[^A-Za-z0-9])`, flags),
(_match, prefix: string) => `${prefix}${replacement}`
);
}
return output;
}
export function formatDiagnosticLink(value: unknown): string {
const raw = String(value || "").trim();
if (DIAGNOSTIC_LINK_RE.test(raw)) {
return raw.toLowerCase();
}
let host = "unknown";
try {
const parsed = new URL(raw);
host = parsed.protocol === "file:"
? "local"
: parsed.hostname.trim().toLowerCase() || "unknown";
} catch {
}
const fingerprint = crypto.createHash("sha256").update(raw).digest("hex").slice(0, 10);
return `${host}#${fingerprint}`;
}
export function sanitizeDiagnosticAccountLabel(value: unknown): string {
const raw = String(value || "");
const match = raw.match(/\b(Account|Key)\s+(\d+)(?:\/(\d+))?/i);
if (!match) {
const shorthand = raw.match(/(?:^|[^A-Za-z0-9])(\d+)\/(\d+)(?=$|[^A-Za-z0-9])/);
const kind = /\bkey\b/i.test(raw) ? "Key" : "Account";
return shorthand ? `${kind} ${shorthand[1]}/${shorthand[2]}` : kind;
}
const kind = match[1].toLowerCase() === "key" ? "Key" : "Account";
return `${kind} ${match[2]}${match[3] ? `/${match[3]}` : ""}`;
}
export function sanitizeDiagnosticText(value: unknown, redactions: DiagnosticRedactions = {}): string {
let output = String(value ?? "").replace(/\0/g, "");
output = output.replace(/\b(?:https?|file):\/\/[^\s"'<>]+/gi, (url) => formatDiagnosticLink(url));
output = output.replace(/\b[A-Z]:[\\/][^|,;\r\n"'<>]*/gi, REDACTED_PATH);
output = output.replace(/\\\\[^\\/\s"'<>|]+[\\/][^|,;\r\n"'<>]*/g, REDACTED_PATH);
output = output.replace(/(?<![A-Za-z0-9.:])\/(?:[^/\s"'<>|]+\/)+[^/\s"'<>|]*/g, REDACTED_PATH);
output = output.replace(/\b((?:Account|Key)\s+\d+(?:\/\d+)?)\s*\([^)\r\n]*\)/gi, "$1");
output = output.replace(/\b(Authorization|Proxy-Authorization)\s*[:=]\s*[^\r\n|]+/gi, "$1: <redacted>");
output = output.replace(/\b(Cookie|Set-Cookie)\s*[:=]\s*[^\r\n|]+/gi, "$1: <redacted>");
output = output.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/-]+=*/gi, "$1 <redacted>");
output = output.replace(
/(["']?)\b(password|passwd|pwd|token|access[_-]?token|refresh[_-]?token|api[_ -]?key|apikey|secret|client[_-]?secret|auth|session)\b\1(\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;|]+)/gi,
"$1$2$1$3<redacted>"
);
output = output.replace(
/(["']?)\b(login|username|user|email)\b\1(\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;|]+)/gi,
"$1$2$1$3<redacted-account>"
);
output = output.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, REDACTED_ACCOUNT);
output = output.replace(
/(^|[^A-Za-z0-9._%+@-])([A-Za-z0-9._%+@-]*\*+[A-Za-z0-9._%+@-]*)(?=$|[^A-Za-z0-9._%+@-])/g,
`$1${REDACTED_ACCOUNT}`
);
for (const accountValue of redactions.accountValues || []) {
output = replaceSensitiveValue(output, accountValue, REDACTED_ACCOUNT, true);
}
for (const secretValue of redactions.secretValues || []) {
output = replaceSensitiveValue(output, secretValue, REDACTED, false);
}
return output.replace(/\r?\n/g, "\\n");
}
function normalizeFieldName(key: string): string {
return key.replace(/[\s_-]+/g, "").toLowerCase();
}
function sanitizeDiagnosticFieldValue(
key: string,
value: unknown,
redactions: DiagnosticRedactions,
seen: WeakSet<object>
): unknown {
const normalizedKey = normalizeFieldName(key);
if (value === undefined || value === null) {
return value;
}
if (SECRET_FIELD_NAMES.has(normalizedKey)) {
return REDACTED;
}
if (ACCOUNT_FIELD_NAMES.has(normalizedKey)) {
return REDACTED_ACCOUNT;
}
if (LINK_FIELD_NAMES.has(normalizedKey)) {
return formatDiagnosticLink(value);
}
if (PATH_FIELD_NAMES.has(normalizedKey) || normalizedKey.endsWith("path") || normalizedKey.endsWith("dir")) {
return REDACTED_PATH;
}
if (["account", "accountlabel"].includes(normalizedKey)) {
return sanitizeDiagnosticAccountLabel(value);
}
if (typeof value === "number" || typeof value === "boolean") {
return value;
}
if (typeof value === "string") {
return sanitizeDiagnosticText(value, redactions);
}
if (typeof value !== "object") {
return sanitizeDiagnosticText(value, redactions);
}
if (seen.has(value)) {
return REDACTED;
}
seen.add(value);
if (Array.isArray(value)) {
return value.map((entry) => sanitizeDiagnosticFieldValue("", entry, redactions, seen));
}
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length === 0) {
return sanitizeDiagnosticText(value, redactions);
}
return Object.fromEntries(entries.map(([nestedKey, nestedValue]) => [
sanitizeDiagnosticText(nestedKey, redactions),
sanitizeDiagnosticFieldValue(nestedKey, nestedValue, redactions, seen)
]));
}
export function sanitizeDiagnosticFields(
fields?: Record<string, unknown>,
redactions: DiagnosticRedactions = {}
): Record<string, unknown> | undefined {
if (!fields) {
return undefined;
}
const seen = new WeakSet<object>();
return Object.fromEntries(Object.entries(fields).map(([key, value]) => [
sanitizeDiagnosticText(key, redactions),
sanitizeDiagnosticFieldValue(key, value, redactions, seen)
]));
}
File diff suppressed because it is too large Load Diff
+37 -26
View File
@@ -1,7 +1,8 @@
import fs from "node:fs"; import fs from "node:fs";
import { logTimestamp } from "./log-timestamp"; import { logTimestamp } from "./log-timestamp";
import path from "node:path"; import path from "node:path";
import crypto from "node:crypto"; import crypto from "node:crypto";
import { sanitizeDiagnosticFields, sanitizeDiagnosticText } from "./diagnostic-sanitizer";
const ITEM_LOG_FLUSH_INTERVAL_MS = 200; const ITEM_LOG_FLUSH_INTERVAL_MS = 200;
const ITEM_LOG_RETENTION_DAYS = 30; const ITEM_LOG_RETENTION_DAYS = 30;
@@ -53,11 +54,12 @@ function sanitizeFieldValue(value: unknown): string {
} }
} }
function formatFields(fields?: Record<string, unknown>): string { function formatFields(fields?: Record<string, unknown>): string {
if (!fields) { const safeFields = sanitizeDiagnosticFields(fields);
return ""; if (!safeFields) {
} return "";
const parts = Object.entries(fields) }
const parts = Object.entries(safeFields)
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "") .filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`); .map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
return parts.length > 0 ? ` | ${parts.join(" | ")}` : ""; return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
@@ -163,11 +165,16 @@ export function ensureItemLog(meta: ItemLogMeta): string | null {
fs.writeFileSync(logPath, "", "utf8"); fs.writeFileSync(logPath, "", "utf8");
} }
if (!initializedThisProcess.has(normalizedItemId)) { if (!initializedThisProcess.has(normalizedItemId)) {
initializedThisProcess.add(normalizedItemId); initializedThisProcess.add(normalizedItemId);
const startedAt = logTimestamp(); const startedAt = logTimestamp();
fs.appendFileSync( const headerFields = sanitizeDiagnosticFields({
logPath, itemId: String(meta.itemId || ""),
`=== Item-Log Start: ${startedAt} | itemId=${sanitizeFieldValue(String(meta.itemId || ""))} | logKey=${normalizedItemId} | fileName=${sanitizeFieldValue(meta.fileName)} ===\n`, logKey: normalizedItemId,
fileName: meta.fileName
}) || {};
fs.appendFileSync(
logPath,
`=== Item-Log Start: ${startedAt} | itemId=${sanitizeFieldValue(headerFields.itemId)} | logKey=${sanitizeFieldValue(headerFields.logKey)} | fileName=${sanitizeFieldValue(headerFields.fileName)} ===\n`,
"utf8" "utf8"
); );
fs.appendFileSync( fs.appendFileSync(
@@ -194,27 +201,31 @@ export function logItemEvent(
fields?: Record<string, unknown> fields?: Record<string, unknown>
): void { ): void {
const logPath = getItemLogFilePath(itemId); const logPath = getItemLogFilePath(itemId);
if (!logPath) { if (!logPath) {
return; return;
} }
const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`; const line = `${logTimestamp()} [${level}] ${sanitizeDiagnosticText(message)}${formatFields(fields)}\n`;
appendLine(itemId, line); appendLine(itemId, line);
} }
export function getItemLogPath(itemId: string): string | null { export function getItemLogPath(itemId: string): string | null {
const logPath = getItemLogFilePath(itemId); const logPath = getItemLogFilePath(itemId);
if (!logPath) { if (!logPath) {
return null; return null;
} }
return fs.existsSync(logPath) ? logPath : null; return fs.existsSync(logPath) ? logPath : null;
} }
export function shutdownItemLogs(): void { export function flushItemLogs(): void {
if (flushTimer) { if (flushTimer) {
clearTimeout(flushTimer); clearTimeout(flushTimer);
flushTimer = null; flushTimer = null;
} }
flushPending(); flushPending();
}
export function shutdownItemLogs(): void {
flushItemLogs();
for (const itemId of knownLogPaths.keys()) { for (const itemId of knownLogPaths.keys()) {
const logPath = getItemLogFilePathFromNormalized(itemId); const logPath = getItemLogFilePathFromNormalized(itemId);
if (!logPath) { if (!logPath) {
+61 -49
View File
@@ -1,7 +1,8 @@
import fs from "node:fs"; import fs from "node:fs";
import { logTimestamp } from "./log-timestamp"; import { logTimestamp } from "./log-timestamp";
import { recordRecentError } from "./error-ring"; import { recordRecentError } from "./error-ring";
import path from "node:path"; import path from "node:path";
import { sanitizeDiagnosticText } from "./diagnostic-sanitizer";
export function isDebugFlagEnabled(value: string | undefined): boolean { export function isDebugFlagEnabled(value: string | undefined): boolean {
if (!value) { if (!value) {
@@ -33,7 +34,7 @@ let legacyLogListener: LogListener | null = null;
let pendingLines: string[] = []; let pendingLines: string[] = [];
let pendingChars = 0; let pendingChars = 0;
let flushTimer: NodeJS.Timeout | null = null; let flushTimer: NodeJS.Timeout | null = null;
let flushInFlight = false; let flushInFlight: Promise<void> | null = null;
let exitHookAttached = false; let exitHookAttached = false;
export function setLogListener(listener: LogListener | null): void { export function setLogListener(listener: LogListener | null): void {
@@ -70,6 +71,17 @@ export function flushLoggerSync(): void {
} }
flushSyncPending(); flushSyncPending();
} }
export async function flushLogger(): Promise<void> {
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
if (flushInFlight) {
await flushInFlight;
}
flushLoggerSync();
}
function appendLine(filePath: string, line: string): { ok: boolean; errorText: string } { function appendLine(filePath: string, line: string): { ok: boolean; errorText: string } {
try { try {
@@ -185,23 +197,13 @@ async function rotateIfNeededAsync(filePath: string): Promise<void> {
} }
} }
async function flushAsync(): Promise<void> { async function performAsyncFlush(): Promise<void> {
if (flushInFlight || pendingLines.length === 0) { const linesSnapshot = pendingLines;
return;
}
flushInFlight = true;
// Move (not copy) the pending lines out and take ownership. A concurrent write()
// during the await below pushes new lines AND can trim the 1MB cap from the FRONT
// of pendingLines; the old count-based removal (pendingLines.slice(snapshot.length))
// then sliced off the wrong lines and dropped unwritten ones. Resetting the buffer
// here means await-time writes queue independently and nothing desyncs.
const linesSnapshot = pendingLines;
pendingLines = []; pendingLines = [];
pendingChars = 0; pendingChars = 0;
const chunk = linesSnapshot.join(""); const chunk = linesSnapshot.join("");
try { try {
await rotateIfNeededAsync(logFilePath); await rotateIfNeededAsync(logFilePath);
const primary = await appendChunk(logFilePath, chunk); const primary = await appendChunk(logFilePath, chunk);
let wroteAny = primary.ok; let wroteAny = primary.ok;
@@ -212,30 +214,39 @@ async function flushAsync(): Promise<void> {
if (!fallback.ok) { if (!fallback.ok) {
writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`); writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`);
} }
} else if (!primary.ok) { } else if (!primary.ok) {
writeStderr(`LOGGER write failed: ${primary.errorText}\n`); writeStderr(`LOGGER write failed: ${primary.errorText}\n`);
} }
if (!wroteAny) { if (!wroteAny) {
// Write failed: requeue the unwritten lines AHEAD of anything that arrived pendingLines = linesSnapshot.concat(pendingLines);
// during the await (preserve order), then re-apply the buffer cap so a pendingChars += chunk.length;
// persistent write failure cannot grow the buffer without bound. while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
pendingLines = linesSnapshot.concat(pendingLines); const removed = pendingLines.shift();
pendingChars += chunk.length; if (!removed) {
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) { break;
const removed = pendingLines.shift(); }
if (!removed) { pendingChars = Math.max(0, pendingChars - removed.length);
break; }
} }
pendingChars = Math.max(0, pendingChars - removed.length); } finally {
} flushInFlight = null;
} if (pendingLines.length > 0) {
} finally { scheduleFlush();
flushInFlight = false; }
if (pendingLines.length > 0) { }
scheduleFlush(); }
}
} function flushAsync(): Promise<void> {
} if (flushInFlight) {
return flushInFlight;
}
if (pendingLines.length === 0) {
return Promise.resolve();
}
const operation = performAsyncFlush();
flushInFlight = operation;
return operation;
}
function ensureExitHook(): void { function ensureExitHook(): void {
if (exitHookAttached) { if (exitHookAttached) {
@@ -246,17 +257,18 @@ function ensureExitHook(): void {
process.once("exit", flushSyncPending); process.once("exit", flushSyncPending);
} }
function write(level: "DEBUG" | "INFO" | "WARN" | "ERROR", message: string): void { function write(level: "DEBUG" | "INFO" | "WARN" | "ERROR", message: string): void {
ensureExitHook(); ensureExitHook();
const ts = logTimestamp(); const ts = logTimestamp();
const line = `${ts} [${level}] ${message}\n`; const safeMessage = sanitizeDiagnosticText(message);
const line = `${ts} [${level}] ${safeMessage}\n`;
pendingLines.push(line); pendingLines.push(line);
pendingChars += line.length; pendingChars += line.length;
// Single chokepoint: every WARN/ERROR also lands in the in-memory ring so // Single chokepoint: every WARN/ERROR also lands in the in-memory ring so
// "what failed recently" is answerable even after the file rotates. // "what failed recently" is answerable even after the file rotates.
if (level === "ERROR" || level === "WARN") { if (level === "ERROR" || level === "WARN") {
recordRecentError(level, message, ts); recordRecentError(level, safeMessage, ts);
} }
for (const listener of logListeners) { for (const listener of logListeners) {
+6 -9
View File
@@ -22,6 +22,7 @@ import { validateRendererSettingsUpdate } from "./renderer-settings";
import { applyMainWindowSecurity, createMainWindowWebPreferences, MAIN_WINDOW_EXTERNAL_HOSTS, openAllowedExternalUrl } from "./browser-security"; import { applyMainWindowSecurity, createMainWindowWebPreferences, MAIN_WINDOW_EXTERNAL_HOSTS, openAllowedExternalUrl } from "./browser-security";
import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security"; import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security";
import { createSupportBundleExportRunner, writeSupportBundleAtomically } from "./support-bundle"; import { createSupportBundleExportRunner, writeSupportBundleAtomically } from "./support-bundle";
import { writeClipboardTextFromIpc } from "./clipboard-ipc";
function validateString(value: unknown, name: string): string { function validateString(value: unknown, name: string): string {
if (typeof value !== "string") { if (typeof value !== "string") {
@@ -612,13 +613,9 @@ function registerIpcHandlers(): void {
updateClipboardWatcher(); updateClipboardWatcher();
return next; return next;
}); });
handleTrusted(IPC_CHANNELS.WRITE_CLIPBOARD_TEXT, (_event: IpcMainInvokeEvent, text: unknown) => { ipcMain.handle(IPC_CHANNELS.WRITE_CLIPBOARD_TEXT, (event, text: unknown) => (
if (typeof text !== "string" || text.length > 16 * 1024 * 1024) { writeClipboardTextFromIpc(event, text, getTrustedIpcOptions())
throw new Error("Ungültiger Zwischenablageinhalt"); ));
}
clipboard.writeText(text);
return true;
});
handleTrusted(IPC_CHANNELS.PICK_FOLDER, async () => { handleTrusted(IPC_CHANNELS.PICK_FOLDER, async () => {
const options = { const options = {
properties: ["openDirectory", "createDirectory"] as Array<"openDirectory" | "createDirectory"> properties: ["openDirectory", "createDirectory"] as Array<"openDirectory" | "createDirectory">
@@ -684,10 +681,10 @@ function registerIpcHandlers(): void {
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
return result.canceled || !result.filePath ? null : result.filePath; return result.canceled || !result.filePath ? null : result.filePath;
}, },
onStart: () => controller.recordSupportBundleExportSelected(),
build: async () => (await controller.exportSupportBundle()).buffer, build: async () => (await controller.exportSupportBundle()).buffer,
write: writeSupportBundleAtomically, write: writeSupportBundleAtomically,
onSuccess: ({ filePath, bytes }) => controller.recordSupportBundleExported(filePath, bytes), onLifecycle: (event) => controller.recordSupportBundleExportLifecycle(event)
onFailure: (error) => controller.recordSupportBundleExportFailed(error)
}); });
handleTrusted(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, () => runSupportBundleExport()); handleTrusted(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, () => runSupportBundleExport());
+36 -25
View File
@@ -1,7 +1,8 @@
import fs from "node:fs"; import fs from "node:fs";
import { logTimestamp } from "./log-timestamp"; import { logTimestamp } from "./log-timestamp";
import path from "node:path"; import path from "node:path";
import crypto from "node:crypto"; import crypto from "node:crypto";
import { sanitizeDiagnosticFields, sanitizeDiagnosticText } from "./diagnostic-sanitizer";
const PACKAGE_LOG_FLUSH_INTERVAL_MS = 200; const PACKAGE_LOG_FLUSH_INTERVAL_MS = 200;
const PACKAGE_LOG_RETENTION_DAYS = 30; const PACKAGE_LOG_RETENTION_DAYS = 30;
@@ -52,11 +53,12 @@ function sanitizeFieldValue(value: unknown): string {
} }
} }
function formatFields(fields?: Record<string, unknown>): string { function formatFields(fields?: Record<string, unknown>): string {
if (!fields) { const safeFields = sanitizeDiagnosticFields(fields);
return ""; if (!safeFields) {
} return "";
const parts = Object.entries(fields) }
const parts = Object.entries(safeFields)
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "") .filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`); .map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
return parts.length > 0 ? ` | ${parts.join(" | ")}` : ""; return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
@@ -163,10 +165,15 @@ export function ensurePackageLog(meta: PackageLogMeta): string | null {
} }
if (!initializedThisProcess.has(normalizedPackageId)) { if (!initializedThisProcess.has(normalizedPackageId)) {
initializedThisProcess.add(normalizedPackageId); initializedThisProcess.add(normalizedPackageId);
const startedAt = logTimestamp(); const startedAt = logTimestamp();
fs.appendFileSync( const headerFields = sanitizeDiagnosticFields({
logPath, packageId: String(meta.packageId || ""),
`=== Paket-Log Start: ${startedAt} | packageId=${sanitizeFieldValue(String(meta.packageId || ""))} | logKey=${normalizedPackageId} | name=${sanitizeFieldValue(meta.name)} ===\n`, logKey: normalizedPackageId,
name: meta.name
}) || {};
fs.appendFileSync(
logPath,
`=== Paket-Log Start: ${startedAt} | packageId=${sanitizeFieldValue(headerFields.packageId)} | logKey=${sanitizeFieldValue(headerFields.logKey)} | name=${sanitizeFieldValue(headerFields.name)} ===\n`,
"utf8" "utf8"
); );
fs.appendFileSync( fs.appendFileSync(
@@ -192,27 +199,31 @@ export function logPackageEvent(
fields?: Record<string, unknown> fields?: Record<string, unknown>
): void { ): void {
const logPath = getPackageLogFilePath(packageId); const logPath = getPackageLogFilePath(packageId);
if (!logPath) { if (!logPath) {
return; return;
} }
const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`; const line = `${logTimestamp()} [${level}] ${sanitizeDiagnosticText(message)}${formatFields(fields)}\n`;
appendLine(packageId, line); appendLine(packageId, line);
} }
export function getPackageLogPath(packageId: string): string | null { export function getPackageLogPath(packageId: string): string | null {
const logPath = getPackageLogFilePath(packageId); const logPath = getPackageLogFilePath(packageId);
if (!logPath) { if (!logPath) {
return null; return null;
} }
return fs.existsSync(logPath) ? logPath : null; return fs.existsSync(logPath) ? logPath : null;
} }
export function shutdownPackageLogs(): void { export function flushPackageLogs(): void {
if (flushTimer) { if (flushTimer) {
clearTimeout(flushTimer); clearTimeout(flushTimer);
flushTimer = null; flushTimer = null;
} }
flushPending(); flushPending();
}
export function shutdownPackageLogs(): void {
flushPackageLogs();
for (const packageId of knownLogPaths.keys()) { for (const packageId of knownLogPaths.keys()) {
const logPath = getPackageLogFilePathFromNormalized(packageId); const logPath = getPackageLogFilePathFromNormalized(packageId);
if (!logPath) { if (!logPath) {
+14 -10
View File
@@ -97,20 +97,24 @@ export function initSessionLog(baseDir: string): void {
void cleanupOldSessionLogs(sessionLogsDir, 7); void cleanupOldSessionLogs(sessionLogsDir, 7);
} }
export function getSessionLogPath(): string | null { export function getSessionLogPath(): string | null {
return sessionLogPath; return sessionLogPath;
} }
export function shutdownSessionLog(): void { export function flushSessionLog(): void {
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
flushPending();
}
export function shutdownSessionLog(): void {
if (!sessionLogPath) { if (!sessionLogPath) {
return; return;
} }
if (flushTimer) { flushSessionLog();
clearTimeout(flushTimer);
flushTimer = null;
}
flushPending();
const isoTimestamp = logTimestamp(); const isoTimestamp = logTimestamp();
try { try {
+1
View File
@@ -788,6 +788,7 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
resumeLinkRenewalFailures: clampNumber(item.resumeLinkRenewalFailures, legacyResumeFailureCount, 0, 1_000_000) || undefined, resumeLinkRenewalFailures: clampNumber(item.resumeLinkRenewalFailures, legacyResumeFailureCount, 0, 1_000_000) || undefined,
resumeHardResetUsed: Boolean(item.resumeHardResetUsed) || undefined, resumeHardResetUsed: Boolean(item.resumeHardResetUsed) || undefined,
resumeResetPending: Boolean(item.resumeResetPending) || undefined, resumeResetPending: Boolean(item.resumeResetPending) || undefined,
http416FreshRestarts: clampNumber(item.http416FreshRestarts, 0, 0, 100) || undefined,
onlineStatus: VALID_ONLINE_STATUSES.has(onlineStatusRaw) ? onlineStatusRaw as "online" | "offline" | "checking" : undefined, onlineStatus: VALID_ONLINE_STATUSES.has(onlineStatusRaw) ? onlineStatusRaw as "online" | "offline" | "checking" : undefined,
createdAt: clampNumber(item.createdAt, now, 0, Number.MAX_SAFE_INTEGER), createdAt: clampNumber(item.createdAt, now, 0, Number.MAX_SAFE_INTEGER),
updatedAt: clampNumber(item.updatedAt, now, 0, Number.MAX_SAFE_INTEGER) updatedAt: clampNumber(item.updatedAt, now, 0, Number.MAX_SAFE_INTEGER)
+369 -34
View File
@@ -7,17 +7,25 @@ import { getAccountRotationLogPath } from "./account-rotation-log";
import { getConversionLogPath } from "./conversion-trace"; import { getConversionLogPath } from "./conversion-trace";
import { getAuditLogPath } from "./audit-log"; import { getAuditLogPath } from "./audit-log";
import { getDebugSetupCheck } from "./debug-setup"; import { getDebugSetupCheck } from "./debug-setup";
import { getLogFilePath } from "./logger"; import { flushLogger, getLogFilePath } from "./logger";
import { getRecentErrors } from "./error-ring"; import { getRecentErrors } from "./error-ring";
import { getRenameLogPath } from "./rename-log"; import { getRenameLogPath } from "./rename-log";
import { getDesktopRenameLogPath } from "./desktop-rename-log"; import { getDesktopRenameLogPath } from "./desktop-rename-log";
import { getSessionLogPath } from "./session-log"; import { flushSessionLog, getSessionLogPath } from "./session-log";
import { createStoragePaths, loadSettings } from "./storage"; import { createStoragePaths, loadSettings } from "./storage";
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload } from "./support-data"; import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload } from "./support-data";
import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log"; import { flushTraceLog, getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log";
import { flushPackageLogs, getPackageLogPath as getPersistedPackageLogPath } from "./package-log";
import { flushItemLogs, getItemLogPath as getPersistedItemLogPath } from "./item-log";
import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics"; import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics";
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
import { maskMegaDebridLogin } from "../shared/mega-debrid-accounts"; import {
getMegaDebridAccountsForMode,
getMegaDebridDisabledAccountIdsForMode,
maskMegaDebridLogin,
type MegaDebridAccountMode
} from "../shared/mega-debrid-accounts";
import { getProviderRuntimeSnapshot, type ProviderRuntimeCooldown, type ProviderRuntimeSnapshot } from "./debrid";
import type { DownloadManager } from "./download-manager"; import type { DownloadManager } from "./download-manager";
import type { DownloadItem, HistoryEntry, PackageEntry, SessionState } from "../shared/types"; import type { DownloadItem, HistoryEntry, PackageEntry, SessionState } from "../shared/types";
@@ -104,13 +112,14 @@ function redactSupportText(value: string, sensitiveValues: ReadonlySet<string>):
return String.fromCharCode(0xf8ff - offset); return String.fromCharCode(0xf8ff - offset);
}; };
const urlMarker = findMarker(raw, 0); const urlMarker = findMarker(raw, 0);
let output = raw.replace(/\b(?:https?|file):\/\/[^\s"'<>]+/gi, urlMarker); let output = raw.replace(/\b(?:https?|file):(?:\\?\/){2}[^\s"'<>]+/gi, urlMarker);
const pathMarker = findMarker(output, 1); const pathMarker = findMarker(output, 1);
output = output.replace(/\b[A-Z]:[\\/][^\r\n|"<>]+/gi, pathMarker); output = output.replace(/\b[A-Z]:[\\/][^\r\n|"<>]+/gi, pathMarker);
output = output.replace(/\\\\[^\r\n|"<>]+/g, pathMarker); output = output.replace(/\\\\[^\r\n|"<>]+/g, pathMarker);
output = output.replace(/\/(?:home|Users|var|tmp)\/[^\r\n|"<>]+/g, pathMarker); output = output.replace(/\/(?:home|Users|var|tmp)\/[^\r\n|"<>]+/g, pathMarker);
output = output.replace(/\b(?:authorization|proxy-authorization)\s*[:=]\s*[^\r\n]+/gi, "Authorization: <redacted>"); output = output.replace(/\b(?:authorization|proxy-authorization)\s*[:=]\s*[^\r\n]+/gi, "Authorization: <redacted>");
output = output.replace(/\b(?:set-cookie|cookie)\s*[:=]\s*[^\r\n]+/gi, "Cookie: <redacted>"); output = output.replace(/\b(?:set-cookie|cookie)\s*[:=]\s*[^\r\n]+/gi, "Cookie: <redacted>");
output = output.replace(/\b((?:Account|Key)\s+\d+(?:\/\d+)?)\s*,\s*[^)\r\n]+(?=\))/gi, "$1, <redacted-account>");
output = output.replace(/\b((?:Account|Key)\s+\d+(?:\/\d+)?)\s*\([^)\r\n]*\)/gi, "$1 (<redacted-account>)"); output = output.replace(/\b((?:Account|Key)\s+\d+(?:\/\d+)?)\s*\([^)\r\n]*\)/gi, "$1 (<redacted-account>)");
output = output.replace(/(["']?(?:authorization|proxy-authorization|set-cookie|cookie|password|passwd|pwd|token|access[_-]?token|refresh[_-]?token|api[_ -]?key|secret|client[_-]?secret|auth|login|username|user)["']?\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')/gi, "$1\"<redacted>\""); output = output.replace(/(["']?(?:authorization|proxy-authorization|set-cookie|cookie|password|passwd|pwd|token|access[_-]?token|refresh[_-]?token|api[_ -]?key|secret|client[_-]?secret|auth|login|username|user)["']?\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')/gi, "$1\"<redacted>\"");
output = output.replace(/\b(password|passwd|pwd|token|access[_-]?token|refresh[_-]?token|api[_ -]?key|secret|client[_-]?secret|auth|login|username|user)\b(\s*[:=]\s*)[^\s,;|]+/gi, "$1$2<redacted>"); output = output.replace(/\b(password|passwd|pwd|token|access[_-]?token|refresh[_-]?token|api[_ -]?key|secret|client[_-]?secret|auth|login|username|user)\b(\s*[:=]\s*)[^\s,;|]+/gi, "$1$2<redacted>");
@@ -151,10 +160,20 @@ function redactSupportValue(value: unknown, sensitiveValues: ReadonlySet<string>
return value; return value;
} }
function sanitizeArchivePath(zipPath: string, sensitiveValues: ReadonlySet<string>): string { function sanitizeArchivePath(zipPath: string, sensitiveValues: ReadonlySet<string>, redactFileName: boolean): string {
return redactSupportText(zipPath, sensitiveValues) const parts = zipPath.split("/");
.split("/") return parts
.map((part) => part.replace(/[<>:"\\|?*\x00-\x1f]/g, "_").replace(/\.+$/g, "_") || "entry") .map((part, index) => {
const redacted = redactFileName && index === parts.length - 1
? redactSupportText(part, sensitiveValues)
: part;
const extension = path.posix.extname(redacted);
const name = extension ? redacted.slice(0, -extension.length) : redacted;
const safeName = name.replace(/[<>:"\\|?*\x00-\x1f]/g, "_").replace(/\.+$/g, "_") || "entry";
const safeExtension = extension.replace(/[<>:"\\|?*\x00-\x1f]/g, "_");
const uniqueness = redacted === part ? "" : `-${randomUUID().slice(0, 12)}`;
return `${safeName}${uniqueness}${safeExtension}`;
})
.join("/"); .join("/");
} }
@@ -211,7 +230,8 @@ async function addTextFileIfExists(
sensitiveValues: ReadonlySet<string>, sensitiveValues: ReadonlySet<string>,
budget: TextBudget, budget: TextBudget,
maxFileBytes: number, maxFileBytes: number,
maxAgeMs?: number maxAgeMs?: number,
redactArchiveFileName = false
): Promise<boolean> { ): Promise<boolean> {
if (!sourcePath || budget.remainingBytes <= 0) { if (!sourcePath || budget.remainingBytes <= 0) {
return false; return false;
@@ -231,7 +251,7 @@ async function addTextFileIfExists(
buffer = Buffer.from(buffer.subarray(buffer.length - allowedBytes).toString("utf8"), "utf8"); buffer = Buffer.from(buffer.subarray(buffer.length - allowedBytes).toString("utf8"), "utf8");
} }
await yieldToEventLoop(); await yieldToEventLoop();
zip.addFile(sanitizeArchivePath(zipPath, sensitiveValues), buffer); zip.addFile(sanitizeArchivePath(zipPath, sensitiveValues, redactArchiveFileName), buffer);
includedSourcePaths.add(sourcePathKey); includedSourcePaths.add(sourcePathKey);
budget.remainingBytes = Math.max(0, budget.remainingBytes - buffer.length); budget.remainingBytes = Math.max(0, budget.remainingBytes - buffer.length);
return true; return true;
@@ -250,6 +270,9 @@ async function addRecentDirectoryFiles(
sensitiveValues: ReadonlySet<string>, sensitiveValues: ReadonlySet<string>,
budget: TextBudget budget: TextBudget
): Promise<number> { ): Promise<number> {
if (maxFiles <= 0 || budget.remainingBytes <= 0) {
return 0;
}
const candidates: Array<{ name: string; fullPath: string; mtimeMs: number }> = []; const candidates: Array<{ name: string; fullPath: string; mtimeMs: number }> = [];
let directory; let directory;
try { try {
@@ -292,7 +315,45 @@ async function addRecentDirectoryFiles(
includedSourcePaths, includedSourcePaths,
sensitiveValues, sensitiveValues,
budget, budget,
MAX_TEXT_FILE_BYTES MAX_TEXT_FILE_BYTES,
undefined,
true
)) {
added += 1;
}
}
return added;
}
async function addRelevantLogFiles<T extends { id: string }>(
zip: AdmZip,
entries: readonly T[],
resolveSourcePath: (id: string) => string | null,
zipRoot: string,
maxFiles: number,
includedSourcePaths: Set<string>,
sensitiveValues: ReadonlySet<string>,
budget: TextBudget
): Promise<number> {
let added = 0;
for (const entry of entries.slice(0, maxFiles)) {
if (budget.remainingBytes <= 0) {
break;
}
const sourcePath = resolveSourcePath(entry.id);
if (!sourcePath) {
continue;
}
if (await addTextFileIfExists(
zip,
sourcePath,
path.posix.join(zipRoot, path.basename(sourcePath)),
includedSourcePaths,
sensitiveValues,
budget,
MAX_TEXT_FILE_BYTES,
undefined,
true
)) { )) {
added += 1; added += 1;
} }
@@ -304,12 +365,38 @@ function isActiveStatus(status: unknown): boolean {
return !new Set(["completed", "failed", "cancelled", "extracted", "deleted"]).has(String(status || "")); return !new Set(["completed", "failed", "cancelled", "extracted", "deleted"]).has(String(status || ""));
} }
function createPackageDto(entry: PackageEntry): Record<string, unknown> { function getBundleAliasExtension(value: string): string {
const extension = path.extname(String(value || "")).toLowerCase();
return /^\.[a-z0-9]{1,10}$/.test(extension) ? extension : "";
}
function createBundleAlias(prefix: "package" | "item" | "history", index: number, sourceName = ""): string {
const extension = getBundleAliasExtension(sourceName);
return `${prefix}-${String(index + 1).padStart(3, "0")}${extension}`;
}
function createPackageDto(
entry: PackageEntry,
name: string,
items: Readonly<Record<string, DownloadItem>>
): Record<string, unknown> {
const currentItems = entry.itemIds.map((id) => items[id]).filter((item): item is DownloadItem => Boolean(item));
const downloadedBytes = Math.max(0, Number(entry.cleanedDownloadedBytes || 0))
+ currentItems.reduce((sum, item) => sum + Math.max(0, Number(item.downloadedBytes || 0)), 0);
const currentKnownTotals = currentItems
.map((item) => item.totalBytes)
.filter((value): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0);
const hasKnownTotal = typeof entry.cleanedTotalBytes === "number" || currentKnownTotals.length > 0;
const totalBytes = hasKnownTotal
? Math.max(0, Number(entry.cleanedTotalBytes || 0)) + currentKnownTotals.reduce((sum, value) => sum + value, 0)
: null;
return { return {
id: entry.id, id: entry.id,
name: entry.name, name,
status: entry.status, status: entry.status,
itemCount: entry.itemIds.length, itemCount: entry.itemIds.length,
downloadedBytes,
totalBytes,
cancelled: entry.cancelled, cancelled: entry.cancelled,
enabled: entry.enabled, enabled: entry.enabled,
priority: entry.priority, priority: entry.priority,
@@ -331,7 +418,7 @@ function getSourceHost(value: string): string {
} }
} }
function createItemDto(entry: DownloadItem): Record<string, unknown> { function createItemDto(entry: DownloadItem, fileName: string): Record<string, unknown> {
return { return {
id: entry.id, id: entry.id,
packageId: entry.packageId, packageId: entry.packageId,
@@ -345,12 +432,16 @@ function createItemDto(entry: DownloadItem): Record<string, unknown> {
downloadedBytes: entry.downloadedBytes, downloadedBytes: entry.downloadedBytes,
totalBytes: entry.totalBytes, totalBytes: entry.totalBytes,
progressPercent: entry.progressPercent, progressPercent: entry.progressPercent,
fileName: entry.fileName, fileName,
targetPath: entry.targetPath ? "<local-path>" : "", targetPath: entry.targetPath ? "<local-path>" : "",
resumable: entry.resumable, resumable: entry.resumable,
attempts: entry.attempts, attempts: entry.attempts,
lastError: entry.lastError, lastError: entry.lastError,
fullStatus: entry.fullStatus, fullStatus: entry.fullStatus,
resumeLinkRenewalFailures: entry.resumeLinkRenewalFailures,
resumeHardResetUsed: entry.resumeHardResetUsed,
resumeResetPending: entry.resumeResetPending,
http416FreshRestarts: entry.http416FreshRestarts,
createdAt: entry.createdAt, createdAt: entry.createdAt,
updatedAt: entry.updatedAt, updatedAt: entry.updatedAt,
onlineStatus: entry.onlineStatus onlineStatus: entry.onlineStatus
@@ -377,10 +468,10 @@ function createSessionDto(session: SessionState): Record<string, unknown> {
}; };
} }
function createHistoryDto(entry: HistoryEntry): Record<string, unknown> { function createHistoryDto(entry: HistoryEntry, name: string): Record<string, unknown> {
return { return {
id: entry.id, id: entry.id,
name: entry.name, name,
status: entry.status, status: entry.status,
provider: entry.provider, provider: entry.provider,
fileCount: entry.fileCount, fileCount: entry.fileCount,
@@ -403,7 +494,11 @@ async function loadBoundedHistory(filePath: string): Promise<{ total: number | n
if (!Array.isArray(parsed)) { if (!Array.isArray(parsed)) {
return { total: 0, entries: [], omitted: 0 }; return { total: 0, entries: [], omitted: 0 };
} }
const entries = parsed.slice(0, MAX_HISTORY_ENTRIES).map((entry) => createHistoryDto(entry as HistoryEntry)); const entries = parsed.slice(0, MAX_HISTORY_ENTRIES)
.map((entry, index) => createHistoryDto(
entry as HistoryEntry,
createBundleAlias("history", index, String((entry as HistoryEntry).name || ""))
));
return { total: parsed.length, entries, omitted: Math.max(0, parsed.length - entries.length) }; return { total: parsed.length, entries, omitted: Math.max(0, parsed.length - entries.length) };
} catch { } catch {
return { total: 0, entries: [], omitted: 0 }; return { total: 0, entries: [], omitted: 0 };
@@ -436,20 +531,64 @@ interface SupportBundleExportSuccess {
bytes: number; bytes: number;
} }
export type SupportBundleExportPhase = "busy" | "cancel" | "build" | "write" | "success" | "failure";
export interface SupportBundleExportLifecycleEvent {
phase: SupportBundleExportPhase;
durationMs: number;
totalDurationMs: number;
bytes?: number;
failedPhase?: "choose" | "build" | "write";
code?: string;
}
export class SupportBundleExportError extends Error {
public readonly phase: "choose" | "build" | "write";
public readonly durationMs: number;
public readonly code?: string;
public constructor(phase: "choose" | "build" | "write", durationMs: number, code?: string) {
super(`Support-Bundle-Export fehlgeschlagen (${phase}${code ? `, ${code}` : ""}).`);
this.name = "SupportBundleExportError";
this.phase = phase;
this.durationMs = durationMs;
this.code = code;
}
}
interface SupportBundleExportRunnerOptions { interface SupportBundleExportRunnerOptions {
chooseFile: () => Promise<string | null>; chooseFile: () => Promise<string | null>;
build: () => Promise<Buffer>; build: () => Promise<Buffer>;
write: (filePath: string, buffer: Buffer) => Promise<void>; write: (filePath: string, buffer: Buffer) => Promise<void>;
now?: () => number;
onStart?: (result: { filePath: string }) => Promise<void> | void;
onSuccess?: (result: SupportBundleExportSuccess) => Promise<void> | void; onSuccess?: (result: SupportBundleExportSuccess) => Promise<void> | void;
onFailure?: (error: unknown) => Promise<void> | void; onFailure?: (error: SupportBundleExportError) => Promise<void> | void;
onLifecycle?: (event: SupportBundleExportLifecycleEvent) => Promise<void> | void;
}
function getExportErrorCode(error: unknown): string | undefined {
const code = String((error as NodeJS.ErrnoException | null)?.code || "").trim().toUpperCase();
return /^[A-Z0-9_]{1,32}$/.test(code) ? code : undefined;
} }
export function createSupportBundleExportRunner( export function createSupportBundleExportRunner(
options: SupportBundleExportRunnerOptions options: SupportBundleExportRunnerOptions
): () => Promise<SupportBundleExportResult> { ): () => Promise<SupportBundleExportResult> {
let active = false; let active = false;
const now = options.now || Date.now;
const emitLifecycle = async (event: SupportBundleExportLifecycleEvent): Promise<void> => {
if (!options.onLifecycle) {
return;
}
try {
await options.onLifecycle(event);
} catch {
}
};
return async () => { return async () => {
if (active) { if (active) {
await emitLifecycle({ phase: "busy", durationMs: 0, totalDurationMs: 0 });
return { return {
saved: false, saved: false,
busy: true, busy: true,
@@ -457,28 +596,77 @@ export function createSupportBundleExportRunner(
}; };
} }
active = true; active = true;
const startedAt = now();
let phase: "choose" | "build" | "write" = "choose";
let phaseStartedAt = startedAt;
try { try {
const filePath = await options.chooseFile(); const filePath = await options.chooseFile();
if (!filePath) { if (!filePath) {
const finishedAt = now();
await emitLifecycle({
phase: "cancel",
durationMs: Math.max(0, finishedAt - phaseStartedAt),
totalDurationMs: Math.max(0, finishedAt - startedAt)
});
return { saved: false, busy: false }; return { saved: false, busy: false };
} }
if (options.onStart) {
try {
await options.onStart({ filePath });
} catch {
}
}
phase = "build";
phaseStartedAt = now();
const buffer = await options.build(); const buffer = await options.build();
let finishedAt = now();
await emitLifecycle({
phase: "build",
durationMs: Math.max(0, finishedAt - phaseStartedAt),
totalDurationMs: Math.max(0, finishedAt - startedAt),
bytes: buffer.length
});
phase = "write";
phaseStartedAt = now();
await options.write(filePath, buffer); await options.write(filePath, buffer);
finishedAt = now();
await emitLifecycle({
phase: "write",
durationMs: Math.max(0, finishedAt - phaseStartedAt),
totalDurationMs: Math.max(0, finishedAt - startedAt),
bytes: buffer.length
});
if (options.onSuccess) { if (options.onSuccess) {
try { try {
await options.onSuccess({ filePath, bytes: buffer.length }); await options.onSuccess({ filePath, bytes: buffer.length });
} catch { } catch {
} }
} }
await emitLifecycle({
phase: "success",
durationMs: Math.max(0, finishedAt - startedAt),
totalDurationMs: Math.max(0, finishedAt - startedAt),
bytes: buffer.length
});
return { saved: true, busy: false, filePath }; return { saved: true, busy: false, filePath };
} catch (error) { } catch (error) {
const failedAt = now();
const code = getExportErrorCode(error);
const safeError = new SupportBundleExportError(phase, Math.max(0, failedAt - startedAt), code);
await emitLifecycle({
phase: "failure",
failedPhase: phase,
durationMs: Math.max(0, failedAt - phaseStartedAt),
totalDurationMs: Math.max(0, failedAt - startedAt),
...(code ? { code } : {})
});
if (options.onFailure) { if (options.onFailure) {
try { try {
await options.onFailure(error); await options.onFailure(safeError);
} catch { } catch {
} }
} }
throw error; throw safeError;
} finally { } finally {
active = false; active = false;
} }
@@ -531,7 +719,7 @@ function createDeferredHostDiagnostics(reason: string): unknown {
}; };
} }
function resolveHostDiagnostics(mode: HostDiagnosticsMode): unknown { function resolveHostDiagnostics(mode: HostDiagnosticsMode): unknown {
if (mode === "none") { if (mode === "none") {
return createDeferredHostDiagnostics("Host-Diagnose wurde fuer diesen Bundle-Export deaktiviert."); return createDeferredHostDiagnostics("Host-Diagnose wurde fuer diesen Bundle-Export deaktiviert.");
} }
@@ -542,30 +730,139 @@ function resolveHostDiagnostics(mode: HostDiagnosticsMode): unknown {
} }
return createDeferredHostDiagnostics("Host-Diagnose wurde uebersprungen, um den Export nicht zu blockieren. Fuer eine Voll-Diagnose /host/diagnostics nutzen."); return createDeferredHostDiagnostics("Host-Diagnose wurde uebersprungen, um den Export nicht zu blockieren. Fuer eine Voll-Diagnose /host/diagnostics nutzen.");
} }
return getWindowsHostDiagnostics(); return getWindowsHostDiagnostics();
} }
function createCooldownDto(cooldown: ProviderRuntimeCooldown | null): Record<string, unknown> | null {
if (!cooldown) {
return null;
}
return {
category: cooldown.category,
remainingMs: Math.max(0, cooldown.remainingMs),
untilRestart: cooldown.untilRestart === true
};
}
function createMegaDebridPoolRuntime(
settings: ReturnType<typeof loadSettings>,
runtime: ProviderRuntimeSnapshot,
mode: MegaDebridAccountMode
): Record<string, unknown> {
const accounts = getMegaDebridAccountsForMode(settings, mode);
const disabledIds = new Set(getMegaDebridDisabledAccountIdsForMode(settings, mode));
const enabled = mode === "api" ? settings.megaDebridApiEnabled : settings.megaDebridWebEnabled;
const runtimeByKey = new Map(runtime.megaDebrid.accounts.map((entry) => [entry.key, entry]));
const runtimeAccounts = accounts.flatMap((account, index) => {
const state = runtimeByKey.get(`${account.id}:${mode}`);
if (!state || (!state.cooldown && state.inFlight <= 0 && state.emptyResponseStreak <= 0)) {
return [];
}
return [{
account: `Account ${index + 1}/${accounts.length}`,
inFlight: state.inFlight,
emptyResponseStreak: state.emptyResponseStreak,
cooldown: createCooldownDto(state.cooldown)
}];
});
const configuredKeys = new Set(accounts.map((account) => `${account.id}:${mode}`));
return {
enabled,
configuredCount: accounts.length,
activeCount: enabled ? accounts.filter((account) => !disabledIds.has(account.id)).length : 0,
disabledCount: accounts.filter((account) => disabledIds.has(account.id)).length,
inFlight: accounts.reduce((sum, account) => sum + (runtimeByKey.get(`${account.id}:${mode}`)?.inFlight || 0), 0),
accounts: runtimeAccounts,
unmappedRuntimeEntryCount: runtime.megaDebrid.accounts
.filter((entry) => entry.key.endsWith(`:${mode}`) && !configuredKeys.has(entry.key)).length
};
}
function createProviderRuntimeDto(settings: ReturnType<typeof loadSettings>): Record<string, unknown> {
const runtime = getProviderRuntimeSnapshot();
const debridKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys);
const disabledDebridKeys = new Set(settings.debridLinkDisabledKeyIds || []);
const debridRuntimeById = new Map(runtime.debridLink.keys.map((entry) => [entry.keyId, entry]));
const configuredDebridIds = new Set(debridKeys.map((entry) => entry.id));
const debridRuntimeKeys = debridKeys.flatMap((entry, index) => {
const state = debridRuntimeById.get(entry.id);
if (!state || (!state.cooldown && !state.runtimeStatus)) {
return [];
}
return [{
account: `Key ${index + 1}/${debridKeys.length}`,
cooldown: createCooldownDto(state.cooldown),
runtimeState: state.runtimeStatus?.state || null,
runtimeUpdatedAt: state.runtimeStatus?.updatedAt || null
}];
});
const hostCooldowns = runtime.debridLink.hostCooldowns.flatMap((entry) => {
const separator = entry.key.indexOf("|");
const keyId = separator >= 0 ? entry.key.slice(0, separator) : entry.key;
const host = separator >= 0 ? entry.key.slice(separator + 1) : "";
const index = debridKeys.findIndex((candidate) => candidate.id === keyId);
if (index < 0) {
return [];
}
return [{
account: `Key ${index + 1}/${debridKeys.length}`,
host,
cooldown: createCooldownDto(entry.cooldown)
}];
});
return {
capturedAtMs: runtime.capturedAtMs,
megaDebrid: {
rotationCursor: runtime.megaDebrid.rotationCursor,
stickyCount: runtime.megaDebrid.stickyCount,
pools: {
api: createMegaDebridPoolRuntime(settings, runtime, "api"),
web: createMegaDebridPoolRuntime(settings, runtime, "web")
}
},
debridLink: {
configuredCount: debridKeys.length,
activeCount: debridKeys.filter((entry) => !disabledDebridKeys.has(entry.id)).length,
disabledCount: debridKeys.filter((entry) => disabledDebridKeys.has(entry.id)).length,
keys: debridRuntimeKeys,
hostCooldowns,
unmappedRuntimeEntryCount: runtime.debridLink.keys.filter((entry) => !configuredDebridIds.has(entry.keyId)).length,
unmappedHostCooldownCount: runtime.debridLink.hostCooldowns.filter((entry) => {
const separator = entry.key.indexOf("|");
const keyId = separator >= 0 ? entry.key.slice(0, separator) : entry.key;
return !configuredDebridIds.has(keyId);
}).length
}
};
}
export async function buildSupportBundle(manager: DownloadManager, baseDir: string, options: BuildSupportBundleOptions = {}): Promise<Buffer> { export async function buildSupportBundle(manager: DownloadManager, baseDir: string, options: BuildSupportBundleOptions = {}): Promise<Buffer> {
const zip = new AdmZip(); const zip = new AdmZip();
const includedSourcePaths = new Set<string>(); const includedSourcePaths = new Set<string>();
const textBudget: TextBudget = { remainingBytes: MAX_TOTAL_TEXT_BYTES }; const textBudget: TextBudget = { remainingBytes: MAX_TOTAL_TEXT_BYTES };
const hostDiagnosticsMode = options.hostDiagnosticsMode || "full"; const hostDiagnosticsMode = options.hostDiagnosticsMode || "full";
const debugSetupMode = options.debugSetupMode || "full";
const generatedAt = new Date().toISOString();
const storagePaths = createStoragePaths(baseDir); const storagePaths = createStoragePaths(baseDir);
const settings = loadSettings(storagePaths); const settings = loadSettings(storagePaths);
const sensitiveValues = collectSensitiveValues(settings); const sensitiveValues = collectSensitiveValues(settings);
const snapshot = manager.getSnapshot(); const snapshot = manager.getSnapshot();
const packageEntries = Object.values(snapshot.session.packages); const packageEntries = Object.values(snapshot.session.packages);
const itemEntries = Object.values(snapshot.session.items); const itemEntries = Object.values(snapshot.session.items);
const selectedPackages = selectRelevantEntries(packageEntries, MAX_PACKAGE_DTOS).map(createPackageDto); const selectedPackageEntries = selectRelevantEntries(packageEntries, MAX_PACKAGE_DTOS);
const selectedItems = selectRelevantEntries(itemEntries, MAX_ITEM_DTOS).map(createItemDto); const selectedItemEntries = selectRelevantEntries(itemEntries, MAX_ITEM_DTOS);
const selectedPackages = selectedPackageEntries
.map((entry, index) => createPackageDto(entry, createBundleAlias("package", index, entry.name), snapshot.session.items));
const selectedItems = selectedItemEntries
.map((entry, index) => createItemDto(entry, createBundleAlias("item", index, entry.fileName)));
const history = await loadBoundedHistory(storagePaths.historyFile); const history = await loadBoundedHistory(storagePaths.historyFile);
const debugSetup = options.debugSetupMode === "deferred" const debugSetup = debugSetupMode === "deferred"
? { status: "deferred", generatedAt: new Date().toISOString(), reason: "Tiefer Setup-Scan wurde beim interaktiven Export ausgelassen." } ? { status: "deferred", generatedAt: new Date().toISOString(), reason: "Tiefer Setup-Scan wurde beim interaktiven Export ausgelassen." }
: getDebugSetupCheck(baseDir); : getDebugSetupCheck(baseDir);
await addJson(zip, "overview/meta.json", { await addJson(zip, "overview/meta.json", {
appVersion: APP_VERSION, appVersion: APP_VERSION,
generatedAt: new Date().toISOString(), generatedAt,
runtimeBaseDir: "<local-path>", runtimeBaseDir: "<local-path>",
packageCount: packageEntries.length, packageCount: packageEntries.length,
itemCount: itemEntries.length, itemCount: itemEntries.length,
@@ -574,7 +871,8 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
itemDtos: MAX_ITEM_DTOS, itemDtos: MAX_ITEM_DTOS,
textBytes: MAX_TOTAL_TEXT_BYTES, textBytes: MAX_TOTAL_TEXT_BYTES,
textFileBytes: MAX_TEXT_FILE_BYTES, textFileBytes: MAX_TEXT_FILE_BYTES,
logWindowHours: SUPPORT_BUNDLE_LOG_WINDOW_MS / 60 / 60 / 1000 directoryLogDiscoveryWindowHours: SUPPORT_BUNDLE_LOG_WINDOW_MS / 60 / 60 / 1000,
currentAndRelevantLogsIgnoreAgeFilter: true
} }
}, sensitiveValues); }, sensitiveValues);
await addJson(zip, "overview/status.json", createSessionDto(snapshot.session), sensitiveValues); await addJson(zip, "overview/status.json", createSessionDto(snapshot.session), sensitiveValues);
@@ -589,7 +887,6 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
} }
}, sensitiveValues); }, sensitiveValues);
await addJson(zip, "overview/debug-setup.json", debugSetup, sensitiveValues); await addJson(zip, "overview/debug-setup.json", debugSetup, sensitiveValues);
await addJson(zip, "overview/self-check.json", debugSetup, sensitiveValues);
await addJson(zip, "overview/history.json", history, sensitiveValues); await addJson(zip, "overview/history.json", history, sensitiveValues);
await addJson(zip, "overview/packages.json", { await addJson(zip, "overview/packages.json", {
count: packageEntries.length, count: packageEntries.length,
@@ -603,6 +900,17 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
omitted: Math.max(0, itemEntries.length - selectedItems.length), omitted: Math.max(0, itemEntries.length - selectedItems.length),
items: selectedItems items: selectedItems
}, sensitiveValues); }, sensitiveValues);
await addJson(zip, "overview/runtime-diagnostics.json", {
bundleBuild: {
state: "building",
startedAt: generatedAt,
hostDiagnosticsMode,
debugSetupMode
},
rotationEvents: (snapshot.rotationEvents || []).slice(0, 60),
diskWaitEvents: (snapshot.diskWaitEvents || []).slice(-60),
providerRuntime: createProviderRuntimeDto(settings)
}, sensitiveValues);
await addJson(zip, "overview/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode), sensitiveValues); await addJson(zip, "overview/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode), sensitiveValues);
await addJson(zip, "overview/trace-config.json", getTraceConfig(), sensitiveValues); await addJson(zip, "overview/trace-config.json", getTraceConfig(), sensitiveValues);
const recentErrors = getRecentErrors().slice(-100); const recentErrors = getRecentErrors().slice(-100);
@@ -642,6 +950,33 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
await addRuntimeFile(path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt"); await addRuntimeFile(path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt");
await addRuntimeFile(getTraceConfigPath(), "runtime/trace_config.json"); await addRuntimeFile(getTraceConfigPath(), "runtime/trace_config.json");
await flushLogger();
flushSessionLog();
flushPackageLogs();
flushItemLogs();
flushTraceLog();
const relevantPackageLogCount = await addRelevantLogFiles(
zip,
selectedPackageEntries,
getPersistedPackageLogPath,
"logs/package-logs",
MAX_PACKAGE_LOG_FILES,
includedSourcePaths,
sensitiveValues,
textBudget
);
const relevantItemLogCount = await addRelevantLogFiles(
zip,
selectedItemEntries,
getPersistedItemLogPath,
"logs/item-logs",
MAX_ITEM_LOG_FILES,
includedSourcePaths,
sensitiveValues,
textBudget
);
const mainLogPath = getLogFilePath(); const mainLogPath = getLogFilePath();
const auditLogPath = getAuditLogPath(); const auditLogPath = getAuditLogPath();
const renameLogPath = getRenameLogPath(); const renameLogPath = getRenameLogPath();
@@ -664,8 +999,8 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
await addRotatedLog(conversionLogPath ? `${conversionLogPath}.old` : null, "logs/conversion.log.old"); await addRotatedLog(conversionLogPath ? `${conversionLogPath}.old` : null, "logs/conversion.log.old");
await addRecentDirectoryFiles(zip, path.join(baseDir, "session-logs"), "logs/session-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS, MAX_SESSION_LOG_FILES, includedSourcePaths, sensitiveValues, textBudget); await addRecentDirectoryFiles(zip, path.join(baseDir, "session-logs"), "logs/session-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS, MAX_SESSION_LOG_FILES, includedSourcePaths, sensitiveValues, textBudget);
await addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS, MAX_PACKAGE_LOG_FILES, includedSourcePaths, sensitiveValues, textBudget); await addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS, Math.max(0, MAX_PACKAGE_LOG_FILES - relevantPackageLogCount), includedSourcePaths, sensitiveValues, textBudget);
await addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS, MAX_ITEM_LOG_FILES, includedSourcePaths, sensitiveValues, textBudget); await addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS, Math.max(0, MAX_ITEM_LOG_FILES - relevantItemLogCount), includedSourcePaths, sensitiveValues, textBudget);
const supportManifest = await safeReadBoundedJson(path.join(baseDir, SUPPORT_MANIFEST_FILE), MAX_RUNTIME_FILE_BYTES); const supportManifest = await safeReadBoundedJson(path.join(baseDir, SUPPORT_MANIFEST_FILE), MAX_RUNTIME_FILE_BYTES);
if (supportManifest) { if (supportManifest) {
+33 -27
View File
@@ -1,8 +1,9 @@
import fs from "node:fs"; import fs from "node:fs";
import { logTimestamp } from "./log-timestamp"; import { logTimestamp } from "./log-timestamp";
import path from "node:path"; import path from "node:path";
import { addLogListener, removeLogListener } from "./logger"; import { addLogListener, removeLogListener } from "./logger";
import type { SupportTraceConfig } from "../shared/types"; import type { SupportTraceConfig } from "../shared/types";
import { sanitizeDiagnosticFields, sanitizeDiagnosticText } from "./diagnostic-sanitizer";
type TraceLevel = "INFO" | "WARN" | "ERROR"; type TraceLevel = "INFO" | "WARN" | "ERROR";
@@ -28,28 +29,29 @@ let pendingLines: string[] = [];
let flushTimer: NodeJS.Timeout | null = null; let flushTimer: NodeJS.Timeout | null = null;
let autoDisableTimer: NodeJS.Timeout | null = null; let autoDisableTimer: NodeJS.Timeout | null = null;
function sanitizeFieldValue(value: unknown): string { function sanitizeFieldValue(value: unknown): string {
if (value === undefined || value === null) { if (value === undefined || value === null) {
return ""; return "";
} }
if (typeof value === "string") { if (typeof value === "string") {
return value.replace(/\r?\n/g, "\\n"); return sanitizeDiagnosticText(value);
} }
if (typeof value === "number" || typeof value === "boolean") { if (typeof value === "number" || typeof value === "boolean") {
return String(value); return String(value);
} }
try { try {
return JSON.stringify(value).replace(/\r?\n/g, "\\n"); return sanitizeDiagnosticText(JSON.stringify(value));
} catch { } catch {
return String(value); return sanitizeDiagnosticText(value);
} }
} }
function formatFields(fields?: Record<string, unknown>): string { function formatFields(fields?: Record<string, unknown>): string {
if (!fields) { const safeFields = sanitizeDiagnosticFields(fields);
return ""; if (!safeFields) {
} return "";
const parts = Object.entries(fields) }
const parts = Object.entries(safeFields)
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "") .filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`); .map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
return parts.length > 0 ? ` | ${parts.join(" | ")}` : ""; return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
@@ -276,7 +278,7 @@ export function setTraceEnabled(enabled: boolean, note = "", durationMs: number
return next; return next;
} }
export function logTraceEvent( export function logTraceEvent(
level: TraceLevel, level: TraceLevel,
category: string, category: string,
message: string, message: string,
@@ -285,23 +287,27 @@ export function logTraceEvent(
if (!traceConfig.enabled) { if (!traceConfig.enabled) {
return; return;
} }
if (category === "audit" && !traceConfig.includeAudit) { if (category === "audit" && !traceConfig.includeAudit) {
return; return;
} }
appendTraceLine(`${logTimestamp()} [${level}] [${category}] ${message}${formatFields(fields)}\n`); appendTraceLine(`${logTimestamp()} [${level}] [${sanitizeDiagnosticText(category)}] ${sanitizeDiagnosticText(message)}${formatFields(fields)}\n`);
} }
export function shutdownTraceLog(): void { export function flushTraceLog(): void {
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
flushPending();
}
export function shutdownTraceLog(): void {
removeLogListener(mainLogListener); removeLogListener(mainLogListener);
clearAutoDisableTimer(); clearAutoDisableTimer();
if (!traceLogPath) { if (!traceLogPath) {
return; return;
} }
if (flushTimer) { flushTraceLog();
clearTimeout(flushTimer);
flushTimer = null;
}
flushPending();
try { try {
fs.appendFileSync(traceLogPath, `=== Trace-Log Ende: ${logTimestamp()} ===\n`, "utf8"); fs.appendFileSync(traceLogPath, `=== Trace-Log Ende: ${logTimestamp()} ===\n`, "utf8");
} catch { } catch {
+172 -113
View File
@@ -38,7 +38,12 @@ import {
getProviderDailyUsageBytes, getProviderDailyUsageBytes,
getProviderUsageDayKey getProviderUsageDayKey
} from "../shared/provider-daily-limits"; } from "../shared/provider-daily-limits";
import { preservePackageOrderForDisplay, sortPackageOrderByName } from "./package-order"; import {
preservePackageOrderForDisplay,
reconcileCollapsedPackageState,
reconcileOptimisticPackageOrder,
sortPackageOrderByName
} from "./package-order";
import { pruneSelection, shouldClearDownloadSelection, shouldClearDownloadSelectionOnEscape } from "./selection"; import { pruneSelection, shouldClearDownloadSelection, shouldClearDownloadSelectionOnEscape } from "./selection";
import { buildBulkAccountEnabledState, buildConfiguredProviderOrder, getAccountDialogSelectableOptions, matchesAccountModeFilter, pruneAccountRowSelection, resolveAccountUsername, resolveVisibleAccountKind } from "./account-ui"; import { buildBulkAccountEnabledState, buildConfiguredProviderOrder, getAccountDialogSelectableOptions, matchesAccountModeFilter, pruneAccountRowSelection, resolveAccountUsername, resolveVisibleAccountKind } from "./account-ui";
import type { AccountModeFilter } from "./account-ui"; import type { AccountModeFilter } from "./account-ui";
@@ -869,11 +874,56 @@ const historyRetentionLabels: Record<RendererSettings["historyRetentionMode"], s
const AUTO_RENDER_PACKAGE_LIMIT = 260; const AUTO_RENDER_PACKAGE_LIMIT = 260;
export function getSnapshotRenderDelay(itemCount: number, running: boolean, activeTab: MainView): number { export function getSnapshotRenderDelay(_itemCount: number, _running: boolean, _activeTab: MainView): number {
let delay = running ? 500 : itemCount >= 700 ? 100 : itemCount >= 250 ? 150 : 200; return 0;
if (!running) delay = Math.min(delay, 200); }
if (!running && activeTab !== "downloads") delay = Math.max(delay, 800);
return delay; export interface ResetUiActionGate {
busy: boolean;
}
interface ResetUiActionOptions {
gate: ResetUiActionGate;
reset: () => Promise<void>;
reconcile: () => Promise<void>;
setBusy: (busy: boolean) => void;
onError: (error: unknown) => void;
onBusy?: () => void;
}
export async function runResetUiAction(options: ResetUiActionOptions): Promise<"completed" | "failed" | "busy"> {
if (options.gate.busy) {
options.onBusy?.();
return "busy";
}
options.gate.busy = true;
options.setBusy(true);
let failed = false;
let failure: unknown;
try {
await options.reset();
} catch (error) {
failed = true;
failure = error;
}
try {
await options.reconcile();
} catch (error) {
if (!failed) {
failure = error;
}
failed = true;
}
try {
if (failed) {
options.onError(failure);
return "failed";
}
return "completed";
} finally {
options.gate.busy = false;
options.setBusy(false);
}
} }
interface SupportBundleExportUiOptions { interface SupportBundleExportUiOptions {
@@ -1466,19 +1516,7 @@ const DEFAULT_COLUMN_ORDER = ["name", "size", "progress", "hoster", "account", "
const ALL_COLUMN_KEYS = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability", "added"]; const ALL_COLUMN_KEYS = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability", "added"];
const COLUMN_DEFS = downloadColumnDefinitions; const COLUMN_DEFS = downloadColumnDefinitions;
function sameStringArray(a: string[], b: string[]): boolean { function formatMbpsInputFromKbps(kbps: number): string {
if (a.length !== b.length) {
return false;
}
for (let index = 0; index < a.length; index += 1) {
if (a[index] !== b[index]) {
return false;
}
}
return true;
}
function formatMbpsInputFromKbps(kbps: number): string {
const mbps = Math.max(0, Number(kbps) || 0) / 1024; const mbps = Math.max(0, Number(kbps) || 0) / 1024;
return String(Number(mbps.toFixed(2))); return String(Number(mbps.toFixed(2)));
} }
@@ -1567,14 +1605,54 @@ export function App(): ReactElement {
return () => localizer.disconnect(); return () => localizer.disconnect();
}, [settingsDraft.language]); }, [settingsDraft.language]);
const panelDirtyRevisionRef = useRef(0); const panelDirtyRevisionRef = useRef(0);
const latestStateRef = useRef<UiSnapshot | null>(null); const latestStateRef = useRef<UiSnapshot | null>(null);
const masterSnapshotRef = useRef<UiSnapshot | null>(null); const masterSnapshotRef = useRef<UiSnapshot | null>(null);
const snapshotRef = useRef(snapshot); const snapshotRef = useRef(snapshot);
snapshotRef.current = snapshot; snapshotRef.current = snapshot;
const tabRef = useRef(tab); const packageOrderRef = useRef<string[]>([]);
tabRef.current = tab; const serverPackageOrderRef = useRef<string[]>([]);
const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const pendingPackageOrderRef = useRef<string[] | null>(null);
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const pendingPackageOrderAtRef = useRef(0);
const tabRef = useRef(tab);
tabRef.current = tab;
const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const stageAuthoritativeSnapshot = useCallback((fresh: UiSnapshot): UiSnapshot => {
masterSnapshotRef.current = fresh;
serverPackageOrderRef.current = fresh.session.packageOrder;
const order = reconcileOptimisticPackageOrder(
fresh.session.packageOrder,
pendingPackageOrderRef.current,
pendingPackageOrderAtRef.current,
Date.now()
);
pendingPackageOrderRef.current = order.pendingOrder;
pendingPackageOrderAtRef.current = order.pendingAt;
packageOrderRef.current = order.displayOrder;
if (order.displayOrder === fresh.session.packageOrder) {
return fresh;
}
return {
...fresh,
session: {
...fresh.session,
packageOrder: order.displayOrder
}
};
}, []);
const applyAuthoritativeSnapshot = useCallback((fresh: UiSnapshot): void => {
if (stateFlushTimerRef.current) {
clearTimeout(stateFlushTimerRef.current);
stateFlushTimerRef.current = null;
}
const displaySnapshot = stageAuthoritativeSnapshot(fresh);
latestStateRef.current = null;
snapshotRef.current = displaySnapshot;
setSnapshot(displaySnapshot);
}, [stageAuthoritativeSnapshot]);
const reconcileAuthoritativeSnapshot = useCallback(async (): Promise<void> => {
applyAuthoritativeSnapshot(await window.rd.getSnapshot());
}, [applyAuthoritativeSnapshot]);
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const onImportDlcRef = useRef<() => Promise<void>>(() => Promise.resolve()); const onImportDlcRef = useRef<() => Promise<void>>(() => Promise.resolve());
const [dragOver, setDragOver] = useState(false); const [dragOver, setDragOver] = useState(false);
const [draggedProvider, setDraggedProvider] = useState<DebridProvider | null>(null); const [draggedProvider, setDraggedProvider] = useState<DebridProvider | null>(null);
@@ -1590,12 +1668,8 @@ export function App(): ReactElement {
const [collectorError, setCollectorError] = useState(""); const [collectorError, setCollectorError] = useState("");
const [collectorInput, setCollectorInput] = useState<CollectorInputState | null>(null); const [collectorInput, setCollectorInput] = useState<CollectorInputState | null>(null);
const collectorTabsRef = useRef<CollectorTab[]>(collectorTabs); const collectorTabsRef = useRef<CollectorTab[]>(collectorTabs);
const activeCollectorTabRef = useRef(activeCollectorTab); const activeCollectorTabRef = useRef(activeCollectorTab);
const activeTabRef = useRef<Tab>(tab); const activeTabRef = useRef<Tab>(tab);
const packageOrderRef = useRef<string[]>([]);
const serverPackageOrderRef = useRef<string[]>([]);
const pendingPackageOrderRef = useRef<string[] | null>(null);
const pendingPackageOrderAtRef = useRef(0);
const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({}); const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
const [downloadSearch, setDownloadSearch] = useState(""); const [downloadSearch, setDownloadSearch] = useState("");
const [downloadDisplayMode, setDownloadDisplayMode] = useState<DownloadDisplayMode>("packages"); const [downloadDisplayMode, setDownloadDisplayMode] = useState<DownloadDisplayMode>("packages");
@@ -1605,6 +1679,7 @@ export function App(): ReactElement {
const [downloadsSortDescending, setDownloadsSortDescending] = useState(false); const [downloadsSortDescending, setDownloadsSortDescending] = useState(false);
const [showAllPackages, setShowAllPackages] = useState(false); const [showAllPackages, setShowAllPackages] = useState(false);
const [actionBusy, setActionBusy] = useState(false); const [actionBusy, setActionBusy] = useState(false);
const [resetBusy, setResetBusy] = useState(false);
const [accountCheckBusy, setAccountCheckBusy] = useState(false); const [accountCheckBusy, setAccountCheckBusy] = useState(false);
const [accountEnabledOverrides, setAccountEnabledOverrides] = useState<Record<string, boolean>>({}); const [accountEnabledOverrides, setAccountEnabledOverrides] = useState<Record<string, boolean>>({});
const accountEnabledOverridesRef = useRef<Record<string, boolean>>({}); const accountEnabledOverridesRef = useRef<Record<string, boolean>>({});
@@ -1612,6 +1687,7 @@ export function App(): ReactElement {
const accountToggleRevisionRef = useRef(0); const accountToggleRevisionRef = useRef(0);
const accountTogglePendingRef = useRef(0); const accountTogglePendingRef = useRef(0);
const actionBusyRef = useRef(false); const actionBusyRef = useRef(false);
const resetUiActionGateRef = useRef<ResetUiActionGate>({ busy: false });
const actionUnlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const actionUnlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true); const mountedRef = useRef(true);
const [supportTraceEnabled, setSupportTraceEnabled] = useState(false); const [supportTraceEnabled, setSupportTraceEnabled] = useState(false);
@@ -1734,35 +1810,7 @@ export function App(): ReactElement {
activeTabRef.current = tab; activeTabRef.current = tab;
}, [tab]); }, [tab]);
useEffect(() => { useEffect(() => {
const incoming = snapshot.session.packageOrder;
serverPackageOrderRef.current = incoming;
const pending = pendingPackageOrderRef.current;
if (!pending) {
packageOrderRef.current = incoming;
return;
}
if (sameStringArray(pending, incoming)) {
pendingPackageOrderRef.current = null;
pendingPackageOrderAtRef.current = 0;
packageOrderRef.current = incoming;
return;
}
const maxOptimisticHoldMs = 1500;
if (Date.now() - pendingPackageOrderAtRef.current >= maxOptimisticHoldMs) {
pendingPackageOrderRef.current = null;
pendingPackageOrderAtRef.current = 0;
packageOrderRef.current = incoming;
return;
}
packageOrderRef.current = pending;
}, [snapshot.session.packageOrder]);
useEffect(() => {
setSpeedLimitInput(formatMbpsInputFromKbps(settingsDraft.speedLimitKbps)); setSpeedLimitInput(formatMbpsInputFromKbps(settingsDraft.speedLimitKbps));
}, [settingsDraft.speedLimitKbps]); }, [settingsDraft.speedLimitKbps]);
@@ -1805,6 +1853,23 @@ export function App(): ReactElement {
} }
}, []); }, []);
const performReset = useCallback(async (reset: () => Promise<void>): Promise<void> => {
if (!resetUiActionGateRef.current.busy) {
showToast("Zurücksetzen läuft …", 60_000);
}
const result = await runResetUiAction({
gate: resetUiActionGateRef.current,
reset,
reconcile: reconcileAuthoritativeSnapshot,
setBusy: setResetBusy,
onError: (error) => { showToast(`Zurücksetzen fehlgeschlagen: ${String(error)}`, 3200); },
onBusy: () => { showToast("Zurücksetzen läuft bereits …", 2200); }
});
if (result === "completed") {
showToast("Zurücksetzen abgeschlossen", 1800);
}
}, [reconcileAuthoritativeSnapshot, showToast]);
const applyHistoryEntries = useCallback((entries: HistoryEntry[]): void => { const applyHistoryEntries = useCallback((entries: HistoryEntry[]): void => {
const availableIds = entries.map((entry) => entry.id); const availableIds = entries.map((entry) => entry.id);
const availableSet = new Set(availableIds); const availableSet = new Set(availableIds);
@@ -1943,8 +2008,7 @@ export function App(): ReactElement {
if (!mountedRef.current) { if (!mountedRef.current) {
return; return;
} }
masterSnapshotRef.current = state; applyAuthoritativeSnapshot(state);
setSnapshot(state);
if (state.settings.columnOrder?.length > 0) { if (state.settings.columnOrder?.length > 0) {
setColumnOrder(state.settings.columnOrder); setColumnOrder(state.settings.columnOrder);
} }
@@ -1989,8 +2053,7 @@ export function App(): ReactElement {
} else { } else {
merged = wireState; merged = wireState;
} }
masterSnapshotRef.current = merged; latestStateRef.current = stageAuthoritativeSnapshot(merged);
latestStateRef.current = merged;
if (stateFlushTimerRef.current) { return; } if (stateFlushTimerRef.current) { return; }
const itemCount = Object.keys(merged.session.items).length; const itemCount = Object.keys(merged.session.items).length;
@@ -1999,8 +2062,9 @@ export function App(): ReactElement {
stateFlushTimerRef.current = setTimeout(() => { stateFlushTimerRef.current = setTimeout(() => {
stateFlushTimerRef.current = null; stateFlushTimerRef.current = null;
if (latestStateRef.current) { if (latestStateRef.current) {
const next = latestStateRef.current; const next = latestStateRef.current;
setSnapshot(next); snapshotRef.current = next;
setSnapshot(next);
if (next.settings.columnOrder?.length > 0) { if (next.settings.columnOrder?.length > 0) {
setColumnOrder(next.settings.columnOrder); setColumnOrder(next.settings.columnOrder);
} }
@@ -2055,7 +2119,7 @@ export function App(): ReactElement {
if (unsubClipboard) { unsubClipboard(); } if (unsubClipboard) { unsubClipboard(); }
if (unsubUpdateInstallProgress) { unsubUpdateInstallProgress(); } if (unsubUpdateInstallProgress) { unsubUpdateInstallProgress(); }
}; };
}, [clearImportQueueFocusListener]); }, [applyAuthoritativeSnapshot, clearImportQueueFocusListener, stageAuthoritativeSnapshot]);
const downloadsTabActive = tab === "downloads"; const downloadsTabActive = tab === "downloads";
const deferredDownloadSearch = useDeferredValue(downloadSearch); const deferredDownloadSearch = useDeferredValue(downloadSearch);
@@ -2090,28 +2154,16 @@ export function App(): ReactElement {
}, [downloadsTabActive, snapshot.session.packageOrder]); }, [downloadsTabActive, snapshot.session.packageOrder]);
useEffect(() => { useEffect(() => {
if (!downloadsTabActive) { if (!downloadsTabActive) {
return; return;
} }
setCollapsedPackages((prev) => { setCollapsedPackages((prev) => reconcileCollapsedPackageState(
let changed = false; prev,
const next: Record<string, boolean> = { ...prev }; snapshot.session.packageOrder,
const defaultCollapsed = totalPackageCount >= 24; snapshot.session.packages,
for (const packageId of snapshot.session.packageOrder) { totalPackageCount >= 24
if (!(packageId in prev)) { ));
next[packageId] = defaultCollapsed; }, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder, snapshot.session.packages, totalPackageCount]);
changed = true;
}
}
for (const packageId of Object.keys(next)) {
if (!snapshot.session.packages[packageId]) {
delete next[packageId];
changed = true;
}
}
return changed ? next : prev;
});
}, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder, snapshot.session.packages, totalPackageCount]);
// Prune selection when its packages/items disappear (e.g. via delta-removal or // Prune selection when its packages/items disappear (e.g. via delta-removal or
// a backup-driven session swap). selectedIds holds BOTH package and item ids; // a backup-driven session swap). selectedIds holds BOTH package and item ids;
@@ -3316,7 +3368,11 @@ export function App(): ReactElement {
showToast(`Konflikte gelöst: ${overwritten} überschrieben, ${skipped} übersprungen`, 2800); showToast(`Konflikte gelöst: ${overwritten} überschrieben, ${skipped} übersprungen`, 2800);
} }
await window.rd.start(); try {
await window.rd.start();
} finally {
await reconcileAuthoritativeSnapshot().catch(() => undefined);
}
}); });
}; };
@@ -4686,7 +4742,7 @@ export function App(): ReactElement {
canStart: snapshot.canStart, canStart: snapshot.canStart,
canPause: snapshot.canPause, canPause: snapshot.canPause,
canStop: snapshot.canStop, canStop: snapshot.canStop,
actionBusy, actionBusy: actionBusy || resetBusy,
reconnectSeconds: snapshot.reconnectSeconds, reconnectSeconds: snapshot.reconnectSeconds,
reconnectReason: snapshot.session.reconnectReason, reconnectReason: snapshot.session.reconnectReason,
clipboardWatcher: snapshot.clipboardActive, clipboardWatcher: snapshot.clipboardActive,
@@ -4712,7 +4768,7 @@ export function App(): ReactElement {
speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s", speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s",
eta: snapshot.etaText eta: snapshot.etaText
} }
}), [actionBusy, columnOrder, downloadPackageSpeeds, downloadQueueTotalBytes, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]); }), [actionBusy, columnOrder, downloadPackageSpeeds, downloadQueueTotalBytes, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, resetBusy, scheduleCountdown, schedulePickerOpen, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
const downloadsActions: DownloadsViewActions = { const downloadsActions: DownloadsViewActions = {
onDisplayModeChange: setDownloadDisplayMode, onDisplayModeChange: setDownloadDisplayMode,
@@ -4732,10 +4788,7 @@ export function App(): ReactElement {
showToast(`Fortsetzen fehlgeschlagen: ${String(error)}`, 3200); showToast(`Fortsetzen fehlgeschlagen: ${String(error)}`, 3200);
} finally { } finally {
try { try {
const fresh = await window.rd.getSnapshot(); await reconcileAuthoritativeSnapshot();
masterSnapshotRef.current = fresh;
latestStateRef.current = null;
setSnapshot(fresh);
} catch { } catch {
} }
} }
@@ -4746,11 +4799,11 @@ export function App(): ReactElement {
}, },
onPauseDownloads: () => { onPauseDownloads: () => {
setSnapshot((current) => ({ ...current, session: { ...current.session, paused: true } })); setSnapshot((current) => ({ ...current, session: { ...current.session, paused: true } }));
void window.rd.togglePause().then((paused) => { void window.rd.togglePause().then(async () => {
setSnapshot((current) => ({ ...current, session: { ...current.session, paused } })); await reconcileAuthoritativeSnapshot();
}).catch(async (error) => { }).catch(async (error) => {
try { try {
setSnapshot(await window.rd.getSnapshot()); await reconcileAuthoritativeSnapshot();
} catch { } catch {
} }
showToast(`Pause fehlgeschlagen: ${String(error)}`, 3200); showToast(`Pause fehlgeschlagen: ${String(error)}`, 3200);
@@ -4758,9 +4811,11 @@ export function App(): ReactElement {
}, },
onStopDownloads: () => { onStopDownloads: () => {
setSnapshot((current) => ({ ...current, session: { ...current.session, running: false, paused: false } })); setSnapshot((current) => ({ ...current, session: { ...current.session, running: false, paused: false } }));
void window.rd.stop().catch(async (error) => { void window.rd.stop().then(async () => {
await reconcileAuthoritativeSnapshot();
}).catch(async (error) => {
try { try {
setSnapshot(await window.rd.getSnapshot()); await reconcileAuthoritativeSnapshot();
} catch { } catch {
} }
showToast(`Stop fehlgeschlagen: ${String(error)}`, 3200); showToast(`Stop fehlgeschlagen: ${String(error)}`, 3200);
@@ -4920,7 +4975,7 @@ export function App(): ReactElement {
if (failedIds.length === 0) { if (failedIds.length === 0) {
return; return;
} }
void window.rd.resetItems(failedIds).catch(() => {}); void performReset(() => window.rd.resetItems(failedIds));
} }
}; };
const collectorActions: CollectorViewActions = { const collectorActions: CollectorViewActions = {
@@ -6097,17 +6152,21 @@ export function App(): ReactElement {
}}>Ausgewählte Dateien entfernen ({selectedItemIds.length})</button> }}>Ausgewählte Dateien entfernen ({selectedItemIds.length})</button>
)} )}
{hasPackages && !contextMenu.itemId && ( {hasPackages && !contextMenu.itemId && (
<button className="ctx-menu-item" onClick={() => { <button className="ctx-menu-item" disabled={resetBusy} onClick={() => {
for (const id of selectedPackageIds) void window.rd.resetPackage(id).catch(() => {}); void performReset(async () => {
setContextMenu(null); for (const id of selectedPackageIds) {
}}>Zurücksetzen{multi ? ` (${selectedPackageIds.length})` : ""}</button> await window.rd.resetPackage(id);
}
});
setContextMenu(null);
}}>{resetBusy ? "Zurücksetzen läuft …" : `Zurücksetzen${multi ? ` (${selectedPackageIds.length})` : ""}`}</button>
)} )}
{contextMenu.itemId && ( {contextMenu.itemId && (
<button className="ctx-menu-item" onClick={() => { <button className="ctx-menu-item" disabled={resetBusy} onClick={() => {
const itemIds = multi ? selectedItemIds : [contextMenu.itemId!]; const itemIds = multi ? selectedItemIds : [contextMenu.itemId!];
void window.rd.resetItems(itemIds).catch(() => {}); void performReset(() => window.rd.resetItems(itemIds));
setContextMenu(null); setContextMenu(null);
}}>Zurücksetzen{multi ? ` (${selectedItemIds.length})` : ""}</button> }}>{resetBusy ? "Zurücksetzen läuft …" : `Zurücksetzen${multi ? ` (${selectedItemIds.length})` : ""}`}</button>
)} )}
{hasPackages && !multi && (() => { {hasPackages && !multi && (() => {
const pkg = snapshot.session.packages[contextMenu.packageId]; const pkg = snapshot.session.packages[contextMenu.packageId];
+6
View File
@@ -9,6 +9,8 @@ export type AccountToggleTarget =
export interface AccountToggleSettings { export interface AccountToggleSettings {
disabledProviders: DebridProvider[]; disabledProviders: DebridProvider[];
debridLinkDisabledKeyIds: string[]; debridLinkDisabledKeyIds: string[];
megaDebridApiEnabled: boolean;
megaDebridWebEnabled: boolean;
megaDebridDisabledAccountIds: string[]; megaDebridDisabledAccountIds: string[];
megaDebridApiDisabledAccountIds: string[]; megaDebridApiDisabledAccountIds: string[];
megaDebridWebDisabledAccountIds: string[]; megaDebridWebDisabledAccountIds: string[];
@@ -45,6 +47,8 @@ export function setAccountTargetEnabled<T extends AccountToggleSettings>(
: settings.megaDebridWebDisabledAccountIds; : settings.megaDebridWebDisabledAccountIds;
return { return {
...settings, ...settings,
megaDebridApiEnabled: target.kind === "mega-api" && enabled ? true : settings.megaDebridApiEnabled,
megaDebridWebEnabled: target.kind === "mega-web" && enabled ? true : settings.megaDebridWebEnabled,
megaDebridApiDisabledAccountIds: apiDisabled, megaDebridApiDisabledAccountIds: apiDisabled,
megaDebridWebDisabledAccountIds: webDisabled, megaDebridWebDisabledAccountIds: webDisabled,
megaDebridDisabledAccountIds: [...new Set([...apiDisabled, ...webDisabled])] megaDebridDisabledAccountIds: [...new Set([...apiDisabled, ...webDisabled])]
@@ -55,6 +59,8 @@ export function buildAccountToggleSettingsUpdate(settings: AccountToggleSettings
return { return {
disabledProviders: settings.disabledProviders, disabledProviders: settings.disabledProviders,
debridLinkDisabledKeyIds: settings.debridLinkDisabledKeyIds, debridLinkDisabledKeyIds: settings.debridLinkDisabledKeyIds,
megaDebridApiEnabled: settings.megaDebridApiEnabled,
megaDebridWebEnabled: settings.megaDebridWebEnabled,
megaDebridDisabledAccountIds: settings.megaDebridDisabledAccountIds, megaDebridDisabledAccountIds: settings.megaDebridDisabledAccountIds,
megaDebridApiDisabledAccountIds: settings.megaDebridApiDisabledAccountIds, megaDebridApiDisabledAccountIds: settings.megaDebridApiDisabledAccountIds,
megaDebridWebDisabledAccountIds: settings.megaDebridWebDisabledAccountIds megaDebridWebDisabledAccountIds: settings.megaDebridWebDisabledAccountIds
+75
View File
@@ -27,3 +27,78 @@ export function sortPackageOrderByName(order: string[], packages: Record<string,
export function preservePackageOrderForDisplay(packages: PackageEntry[]): PackageEntry[] { export function preservePackageOrderForDisplay(packages: PackageEntry[]): PackageEntry[] {
return packages; return packages;
} }
export type OptimisticPackageOrderStatus = "idle" | "pending" | "acknowledged" | "timed-out";
export interface OptimisticPackageOrderReconciliation {
displayOrder: string[];
pendingOrder: string[] | null;
pendingAt: number;
status: OptimisticPackageOrderStatus;
}
function samePackageOrder(left: string[], right: string[]): boolean {
return left.length === right.length && left.every((packageId, index) => packageId === right[index]);
}
export function reconcileOptimisticPackageOrder(
authoritativeOrder: string[],
pendingOrder: string[] | null,
pendingAt: number,
now: number,
holdMs = 1_500
): OptimisticPackageOrderReconciliation {
if (!pendingOrder) {
return {
displayOrder: authoritativeOrder,
pendingOrder: null,
pendingAt: 0,
status: "idle"
};
}
if (samePackageOrder(authoritativeOrder, pendingOrder)) {
return {
displayOrder: authoritativeOrder,
pendingOrder: null,
pendingAt: 0,
status: "acknowledged"
};
}
if (now - pendingAt >= holdMs) {
return {
displayOrder: authoritativeOrder,
pendingOrder: null,
pendingAt: 0,
status: "timed-out"
};
}
return {
displayOrder: pendingOrder,
pendingOrder,
pendingAt,
status: "pending"
};
}
export function reconcileCollapsedPackageState(
previous: Record<string, boolean>,
packageOrder: string[],
packages: Record<string, PackageEntry>,
defaultCollapsed: boolean
): Record<string, boolean> {
let changed = false;
const next = { ...previous };
for (const packageId of packageOrder) {
if (!(packageId in previous)) {
next[packageId] = defaultCollapsed;
changed = true;
}
}
for (const packageId of Object.keys(next)) {
if (!packages[packageId]) {
delete next[packageId];
changed = true;
}
}
return changed ? next : previous;
}
+11 -1
View File
@@ -22,6 +22,13 @@ export function getRollingMetricDirection(previous: number, next: number): Rolli
return "none"; return "none";
} }
export function shouldAnimateRollingMetric(
direction: RollingMetricDirection,
reducedMotion: boolean
): direction is Exclude<RollingMetricDirection, "none"> {
return direction !== "none" && !reducedMotion;
}
export function RollingMetricValue({ numericValue, value }: RollingMetricValueProps): ReactElement { export function RollingMetricValue({ numericValue, value }: RollingMetricValueProps): ReactElement {
const previousRef = useRef({ numericValue, value }); const previousRef = useRef({ numericValue, value });
const sequenceRef = useRef(0); const sequenceRef = useRef(0);
@@ -34,7 +41,10 @@ export function RollingMetricValue({ numericValue, value }: RollingMetricValuePr
if (previous.value === value && previous.numericValue === numericValue) return; if (previous.value === value && previous.numericValue === numericValue) return;
previousRef.current = { numericValue, value }; previousRef.current = { numericValue, value };
const direction = getRollingMetricDirection(previous.numericValue, numericValue); const direction = getRollingMetricDirection(previous.numericValue, numericValue);
if (direction === "none") { const reducedMotion = typeof window !== "undefined"
&& typeof window.matchMedia === "function"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!shouldAnimateRollingMetric(direction, reducedMotion)) {
setTransition(null); setTransition(null);
return; return;
} }
+11 -3
View File
@@ -366,6 +366,7 @@ export interface DownloadItem {
resumeLinkRenewalFailures?: number; resumeLinkRenewalFailures?: number;
resumeHardResetUsed?: boolean; resumeHardResetUsed?: boolean;
resumeResetPending?: boolean; resumeResetPending?: boolean;
http416FreshRestarts?: number;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
onlineStatus?: "online" | "offline" | "checking"; onlineStatus?: "online" | "offline" | "checking";
@@ -449,7 +450,7 @@ export interface ContainerImportResult {
source: "dlc"; source: "dlc";
} }
export interface RotationEvent { export interface RotationEvent {
id: string; id: string;
at: number; at: number;
level: "INFO" | "WARN" | "ERROR"; level: "INFO" | "WARN" | "ERROR";
@@ -459,8 +460,11 @@ export interface RotationEvent {
reason?: string; reason?: string;
category?: string; category?: string;
cooldownSec?: number; cooldownSec?: number;
next?: string; next?: string;
} attemptId?: string;
itemId?: string;
packageId?: string;
}
export interface UiSnapshot { export interface UiSnapshot {
settings: RendererSettings; settings: RendererSettings;
@@ -485,7 +489,11 @@ export interface UiSnapshot {
requiredBytes: number; requiredBytes: number;
availableBytes: number; availableBytes: number;
deficitBytes: number; deficitBytes: number;
safetyBytes: number;
retryAt: number; retryAt: number;
at: number;
state: "waiting" | "resolved";
resolvedAt?: number;
}>; }>;
payloadKind?: "full" | "delta"; payloadKind?: "full" | "delta";
removedItemIds?: string[]; removedItemIds?: string[];
+173 -17
View File
@@ -1,8 +1,28 @@
import { describe, it, expect } from "vitest"; import fs from "node:fs";
import { logAccountRotation, runWithRotationItemSink, getRecentRotationEvents } from "../src/main/account-rotation-log"; import os from "node:os";
import type { RotationEvent } from "../src/shared/types"; import path from "node:path";
import { afterEach, describe, it, expect } from "vitest";
describe("rotation item-sink (AsyncLocalStorage)", () => { import {
getAccountRotationLogPath,
getRecentRotationEvents,
initAccountRotationLog,
logAccountRotation,
runWithRotationItemSink,
shutdownAccountRotationLog,
type CorrelatedRotationEvent
} from "../src/main/account-rotation-log";
import type { RotationEvent } from "../src/shared/types";
const tempDirs: string[] = [];
afterEach(() => {
shutdownAccountRotationLog();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("rotation item-sink (AsyncLocalStorage)", () => {
it("routes the FULL rotation trail (incl. TEST) to the active item sink", async () => { it("routes the FULL rotation trail (incl. TEST) to the active item sink", async () => {
const captured: RotationEvent[] = []; const captured: RotationEvent[] = [];
await runWithRotationItemSink((ev) => captured.push(ev), async () => { await runWithRotationItemSink((ev) => captured.push(ev), async () => {
@@ -15,10 +35,110 @@ describe("rotation item-sink (AsyncLocalStorage)", () => {
const events = captured.map((e) => e.event); const events = captured.map((e) => e.event);
expect(events).toEqual(["TEST", "FAILED", "TEST", "OK"]); expect(events).toEqual(["TEST", "FAILED", "TEST", "OK"]);
const failed = captured.find((e) => e.event === "FAILED"); const failed = captured.find((e) => e.event === "FAILED");
expect(failed?.reason).toBe("Timeout"); expect(failed?.reason).toBe("Timeout");
expect(failed?.next).toBe("Account 2/3 (cd**zw)"); expect(failed?.accountLabel).toBe("Account 1/3");
}); expect(failed?.next).toBe("Account 2/3");
});
it("removes account identities, credentials and source URLs before events reach a sink or ring", async () => {
const captured: RotationEvent[] = [];
const sourceUrl = "https://source-user:source-pass@files.example.test/private/file.rar?token=query-secret";
await runWithRotationItemSink((event) => captured.push(event), async () => {
logAccountRotation("WARN", "Mega-Debrid Web", "Account 1/3 (al***ce)", "FAILED", {
reason: `Incorrect password for alice@example.test password=provider-secret masked=al***ce@identity.invalid source=${sourceUrl}`,
category: "invalid",
cooldownSec: 30,
next: "Account 2/3 (bo***ob)",
token: "direct-token-secret",
authorization: "Bearer direct-bearer-secret",
credentials: {
username: "nested-user",
password: "nested-password-secret",
apiKey: "nested-api-key-secret",
sourceUrl
}
});
});
const event = captured[0];
const serialized = JSON.stringify(event);
expect(event).toMatchObject({
accountLabel: "Account 1/3",
category: "invalid",
cooldownSec: 30,
next: "Account 2/3"
});
expect(event.reason).toContain("Incorrect password");
expect(event.reason).toMatch(/files\.example\.test#[a-f0-9]{10}/);
expect(serialized).not.toContain("alice@example.test");
expect(serialized).not.toContain("provider-secret");
expect(serialized).not.toContain("source-user");
expect(serialized).not.toContain("source-pass");
expect(serialized).not.toContain("query-secret");
expect(serialized).not.toContain("al***ce");
expect(serialized).not.toContain("bo***ob");
for (const sensitive of ["identity.invalid", "direct-token-secret", "direct-bearer-secret", "nested-user", "nested-password-secret", "nested-api-key-secret"]) {
expect(serialized).not.toContain(sensitive);
}
expect(JSON.stringify(getRecentRotationEvents(10))).not.toContain("provider-secret");
});
it("writes only anonymous accounts and fingerprinted links to the account rotation log", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rotation-safety-"));
tempDirs.push(root);
const sourceUrl = "https://log-user:log-pass@rapidgator.net/file/private?token=rotation-secret";
initAccountRotationLog(root);
logAccountRotation("WARN", "Mega-Debrid API", "Account 1/2 (lo***in)", "FAILED", {
reason: `Unauthorized login=private-login password=private-password at ${sourceUrl}`,
category: "invalid",
link: sourceUrl,
next: "Account 2/2 (ne***xt)",
token: "direct-log-token-secret",
authorization: "Bearer direct-log-bearer-secret",
credentials: {
username: "nested-log-user",
password: "nested-log-password-secret",
apiKey: "nested-log-api-key-secret",
sourceUrl
}
});
const logPath = getAccountRotationLogPath();
expect(logPath).not.toBeNull();
shutdownAccountRotationLog();
const content = fs.readFileSync(logPath!, "utf8");
expect(content).toContain("Account 1/2 | FAILED");
expect(content).toContain("category=invalid");
expect(content).toMatch(/link=rapidgator\.net#[a-f0-9]{10}/);
expect(content).toContain("next=Account 2/2");
for (const sensitive of ["log-user", "log-pass", "rotation-secret", "private-login", "private-password", "lo***in", "ne***xt", "direct-log-token-secret", "direct-log-bearer-secret", "nested-log-user", "nested-log-password-secret", "nested-log-api-key-secret", sourceUrl]) {
expect(content).not.toContain(sensitive);
}
});
it("writes the active correlation IDs into the account rotation log", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rotation-correlation-"));
tempDirs.push(root);
initAccountRotationLog(root);
await runWithRotationItemSink(
() => undefined,
async () => {
logAccountRotation("INFO", "Mega-Debrid API", "Account 1/1", "OK");
},
{ attemptId: "attempt-log", itemId: "item-log", packageId: "package-log" }
);
const logPath = getAccountRotationLogPath();
expect(logPath).not.toBeNull();
shutdownAccountRotationLog();
const content = fs.readFileSync(logPath!, "utf8");
expect(content).toContain("attemptId=attempt-log");
expect(content).toContain("itemId=item-log");
expect(content).toContain("packageId=package-log");
});
it("does not leak events to the sink outside the run() scope", () => { it("does not leak events to the sink outside the run() scope", () => {
const captured: RotationEvent[] = []; const captured: RotationEvent[] = [];
@@ -26,7 +146,7 @@ describe("rotation item-sink (AsyncLocalStorage)", () => {
expect(captured).toHaveLength(0); expect(captured).toHaveLength(0);
}); });
it("isolates two parallel item sinks (no cross-attribution)", async () => { it("isolates two parallel item sinks (no cross-attribution)", async () => {
const a: RotationEvent[] = []; const a: RotationEvent[] = [];
const b: RotationEvent[] = []; const b: RotationEvent[] = [];
await Promise.all([ await Promise.all([
@@ -44,15 +164,51 @@ describe("rotation item-sink (AsyncLocalStorage)", () => {
expect(a.every((e) => e.provider === "Mega-Debrid Web")).toBe(true); expect(a.every((e) => e.provider === "Mega-Debrid Web")).toBe(true);
expect(b.every((e) => e.provider === "Debrid-Link")).toBe(true); expect(b.every((e) => e.provider === "Debrid-Link")).toBe(true);
expect(a.map((e) => e.event)).toEqual(["TEST", "OK"]); expect(a.map((e) => e.event)).toEqual(["TEST", "OK"]);
expect(b.map((e) => e.event)).toEqual(["TEST", "FAILED"]); expect(b.map((e) => e.event)).toEqual(["TEST", "FAILED"]);
}); });
it("attaches and isolates optional attempt, item and package IDs across parallel rotations", async () => {
const first: CorrelatedRotationEvent[] = [];
const second: CorrelatedRotationEvent[] = [];
await Promise.all([
runWithRotationItemSink(
(event) => first.push(event),
async () => {
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1/2", "TEST");
await new Promise((resolve) => setTimeout(resolve, 10));
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1/2", "OK");
},
{ attemptId: "attempt-a", itemId: "item-a", packageId: "package-a" }
),
runWithRotationItemSink(
(event) => second.push(event),
async () => {
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/2", "TEST");
await new Promise((resolve) => setTimeout(resolve, 5));
logAccountRotation("WARN", "Mega-Debrid Web", "Account 2/2", "FAILED");
},
{ attemptId: "attempt-b", itemId: "item-b", packageId: "package-b" }
)
]);
expect(first).toHaveLength(2);
expect(first.every((event) => event.attemptId === "attempt-a" && event.itemId === "item-a" && event.packageId === "package-a")).toBe(true);
expect(second).toHaveLength(2);
expect(second.every((event) => event.attemptId === "attempt-b" && event.itemId === "item-b" && event.packageId === "package-b")).toBe(true);
const correlatedRing = getRecentRotationEvents(10).filter((event) => event.attemptId === "attempt-a" || event.attemptId === "attempt-b");
expect(correlatedRing).toHaveLength(4);
expect(correlatedRing.filter((event) => event.attemptId === "attempt-a").every((event) => event.itemId === "item-a" && event.packageId === "package-a")).toBe(true);
expect(correlatedRing.filter((event) => event.attemptId === "attempt-b").every((event) => event.itemId === "item-b" && event.packageId === "package-b")).toBe(true);
});
it("feeds the global UI ring with TEST and outcome events", () => { it("feeds the global UI ring with TEST and outcome events", () => {
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "TEST"); logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "TEST");
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "OK", { fileName: "ring.mkv" }); logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "OK", { fileName: "ring.mkv" });
const ring = getRecentRotationEvents(10); const ring = getRecentRotationEvents(10);
expect(ring.some((e) => e.event === "OK" && e.accountLabel === "Account 9 (zz)")).toBe(true); expect(ring.some((e) => e.event === "OK" && e.accountLabel === "Account 9")).toBe(true);
expect(ring.some((e) => e.event === "TEST" && e.accountLabel === "Account 9 (zz)")).toBe(true); expect(ring.some((e) => e.event === "TEST" && e.accountLabel === "Account 9")).toBe(true);
}); });
it("marks TIMEOUT_COOLDOWN as a failed attempt in the global UI ring without changing its event type", () => { it("marks TIMEOUT_COOLDOWN as a failed attempt in the global UI ring without changing its event type", () => {
@@ -62,7 +218,7 @@ describe("rotation item-sink (AsyncLocalStorage)", () => {
next: "Account 11 (yz)" next: "Account 11 (yz)"
}); });
const event = getRecentRotationEvents(10).find((entry) => entry.accountLabel === "Account 10 (xy)"); const event = getRecentRotationEvents(10).find((entry) => entry.accountLabel === "Account 10");
expect(event).toMatchObject({ expect(event).toMatchObject({
event: "TIMEOUT_COOLDOWN", event: "TIMEOUT_COOLDOWN",
+24
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
buildAccountToggleSettingsUpdate,
SerialTaskQueue, SerialTaskQueue,
setAccountTargetEnabled, setAccountTargetEnabled,
type AccountToggleTarget type AccountToggleTarget
@@ -47,6 +48,8 @@ describe("account toggle queue", () => {
let settings = { let settings = {
disabledProviders: [], disabledProviders: [],
debridLinkDisabledKeyIds: [], debridLinkDisabledKeyIds: [],
megaDebridApiEnabled: false,
megaDebridWebEnabled: true,
megaDebridApiDisabledAccountIds: [], megaDebridApiDisabledAccountIds: [],
megaDebridWebDisabledAccountIds: [...accountIds], megaDebridWebDisabledAccountIds: [...accountIds],
megaDebridDisabledAccountIds: [...accountIds] megaDebridDisabledAccountIds: [...accountIds]
@@ -60,4 +63,25 @@ describe("account toggle queue", () => {
expect(settings.megaDebridWebDisabledAccountIds).toEqual([]); expect(settings.megaDebridWebDisabledAccountIds).toEqual([]);
expect(settings.megaDebridDisabledAccountIds).toEqual([]); expect(settings.megaDebridDisabledAccountIds).toEqual([]);
}); });
it("reactivates a disabled Mega-Debrid mode when one of its accounts is enabled", () => {
const settings = {
disabledProviders: [],
debridLinkDisabledKeyIds: [],
megaDebridApiEnabled: false,
megaDebridWebEnabled: false,
megaDebridApiDisabledAccountIds: ["api-1"],
megaDebridWebDisabledAccountIds: ["web-1"],
megaDebridDisabledAccountIds: ["api-1", "web-1"]
};
const next = setAccountTargetEnabled(settings, { kind: "mega-web", accountId: "web-1" }, true);
const update = buildAccountToggleSettingsUpdate(next);
expect(next.megaDebridWebEnabled).toBe(true);
expect(next.megaDebridApiEnabled).toBe(false);
expect(update.megaDebridWebEnabled).toBe(true);
expect(update.megaDebridApiEnabled).toBe(false);
expect(update.megaDebridWebDisabledAccountIds).toEqual([]);
});
}); });
+73 -4
View File
@@ -5,7 +5,7 @@ import { AvatarMenu, getAvatarMenuKeyboardAction } from "../src/renderer/shell/A
import { AppHeader } from "../src/renderer/shell/AppHeader"; import { AppHeader } from "../src/renderer/shell/AppHeader";
import { AppShell } from "../src/renderer/shell/AppShell"; import { AppShell } from "../src/renderer/shell/AppShell";
import { buildMainNavigation } from "../src/renderer/shell/shell-model"; import { buildMainNavigation } from "../src/renderer/shell/shell-model";
import { getSnapshotRenderDelay, runSupportBundleExportUi, SupportBundleToast } from "../src/renderer/App"; import { getSnapshotRenderDelay, runResetUiAction, runSupportBundleExportUi, SupportBundleToast } from "../src/renderer/App";
describe("desktop shell", () => { describe("desktop shell", () => {
it("uses keyboard-focusable controls for every copy target", () => { it("uses keyboard-focusable controls for every copy target", () => {
@@ -35,9 +35,78 @@ describe("desktop shell", () => {
expect(removal).toContain('title: "Ausgewählte Links löschen"'); expect(removal).toContain('title: "Ausgewählte Links löschen"');
}); });
it("renders active download telemetry at a stable half-second cadence", () => { it("does not add a second renderer debounce after main-process telemetry throttling", () => {
expect(getSnapshotRenderDelay(2_470, true, "downloads")).toBe(500); expect(getSnapshotRenderDelay(2_470, true, "downloads")).toBe(0);
expect(getSnapshotRenderDelay(2_470, true, "statistics")).toBe(500); expect(getSnapshotRenderDelay(2_470, true, "statistics")).toBe(0);
});
it("guards a pending reset, reconciles the snapshot, and releases the busy state", async () => {
let finishReset: () => void = () => undefined;
const pendingReset = new Promise<void>((resolve) => { finishReset = resolve; });
const gate = { busy: false };
const busyStates: boolean[] = [];
const events: string[] = [];
let resetCalls = 0;
const first = runResetUiAction({
gate,
reset: async () => {
resetCalls += 1;
await pendingReset;
events.push("reset");
},
reconcile: async () => { events.push("reconcile"); },
setBusy: (busy) => { busyStates.push(busy); },
onError: () => { events.push("error"); }
});
const duplicate = await runResetUiAction({
gate,
reset: async () => { resetCalls += 1; },
reconcile: async () => { events.push("duplicate-reconcile"); },
setBusy: (busy) => { busyStates.push(busy); },
onError: () => { events.push("duplicate-error"); }
});
expect(duplicate).toBe("busy");
expect(gate.busy).toBe(true);
expect(resetCalls).toBe(1);
expect(busyStates).toEqual([true]);
finishReset();
await expect(first).resolves.toBe("completed");
expect(events).toEqual(["reset", "reconcile"]);
expect(busyStates).toEqual([true, false]);
expect(gate.busy).toBe(false);
});
it("reports reset failures and still reconciles the authoritative snapshot", async () => {
const gate = { busy: false };
const busyStates: boolean[] = [];
const errors: unknown[] = [];
const events: string[] = [];
const failure = new Error("Teildatei ist gesperrt");
await expect(runResetUiAction({
gate,
reset: async () => { throw failure; },
reconcile: async () => { events.push("reconcile"); },
setBusy: (busy) => { busyStates.push(busy); },
onError: (error) => { errors.push(error); }
})).resolves.toBe("failed");
expect(errors).toEqual([failure]);
expect(events).toEqual(["reconcile"]);
expect(busyStates).toEqual([true, false]);
expect(gate.busy).toBe(false);
});
it("routes every renderer reset through the guarded authoritative workflow", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
expect(source).not.toMatch(/window\.rd\.reset(?:Package|Items)\([^\n]*\.catch\(\(\) => \{\}\)/);
expect(source.match(/performReset\(/g)).toHaveLength(3);
expect(source).toContain('disabled={resetBusy}');
expect(source).toContain('actionBusy: actionBusy || resetBusy');
}); });
it("keeps support bundle progress tied to the unresolved export", async () => { it("keeps support bundle progress tied to the unresolved export", async () => {
+42 -5
View File
@@ -30,7 +30,7 @@ describe("audit-log", () => {
expect(content).toContain("changedKeys"); expect(content).toContain("changedKeys");
}); });
it("rotates oversized audit logs on startup", () => { it("rotates oversized audit logs on startup", () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-alog-rotate-")); const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-alog-rotate-"));
tempDirs.push(baseDir); tempDirs.push(baseDir);
@@ -42,7 +42,44 @@ describe("audit-log", () => {
expect(fs.existsSync(oversizedPath)).toBe(true); expect(fs.existsSync(oversizedPath)).toBe(true);
expect(fs.existsSync(`${oversizedPath}.old`)).toBe(true); expect(fs.existsSync(`${oversizedPath}.old`)).toBe(true);
const content = fs.readFileSync(oversizedPath, "utf8"); const content = fs.readFileSync(oversizedPath, "utf8");
expect(content).toContain("Audit-Log Start"); expect(content).toContain("Audit-Log Start");
}); });
});
it("redacts secrets, identities, direct links and local paths from audit logs", () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-alog-sensitive-"));
tempDirs.push(baseDir);
initAuditLog(baseDir);
logAuditEvent(
"ERROR",
"Abruf https://rapidgator.net/file/private-id/archive.rar?token=url-secret für audit@example.com in C:\\Users\\Administrator\\Downloads\\archive.rar fehlgeschlagen",
{
directUrl: "https://rapidgator.net/file/private-id/archive.rar?token=url-secret",
authorization: "Bearer authorization-secret-value",
details: {
password: "password-secret-value",
cookie: "sid=cookie-secret-value",
email: "audit@example.com",
extractPath: "/var/lib/downloader/archive.rar"
}
}
);
const logPath = getAuditLogPath();
expect(logPath).not.toBeNull();
const content = fs.readFileSync(logPath!, "utf8");
expect(content).toMatch(/rapidgator\.net#[a-f0-9]{10}/);
expect(content).toContain("<redacted>");
expect(content).toContain("<redacted-account>");
expect(content).toContain("<redacted-path>");
expect(content).not.toContain("private-id");
expect(content).not.toContain("url-secret");
expect(content).not.toContain("authorization-secret-value");
expect(content).not.toContain("password-secret-value");
expect(content).not.toContain("cookie-secret-value");
expect(content).not.toContain("audit@example.com");
expect(content).not.toContain("C:\\Users\\Administrator");
expect(content).not.toContain("/var/lib/downloader");
});
});
+96
View File
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { writeClipboardTextFromIpc } from "../src/main/clipboard-ipc";
import type { TrustedIpcOptions } from "../src/main/ipc-security";
const electronMocks = vi.hoisted(() => ({
writeText: vi.fn()
}));
const loggerMocks = vi.hoisted(() => ({
info: vi.fn(),
warn: vi.fn()
}));
vi.mock("electron", () => ({
clipboard: {
writeText: electronMocks.writeText
}
}));
vi.mock("../src/main/logger", () => ({
logger: loggerMocks
}));
const trustedOptions: TrustedIpcOptions = {
isPackaged: false,
devServerUrl: "http://localhost:5180",
appPath: "C:\\Program Files\\MDD"
};
function eventFor(url: string) {
return {
senderFrame: { url },
sender: {
getURL: () => url
}
};
}
beforeEach(() => {
electronMocks.writeText.mockReset();
loggerMocks.info.mockReset();
loggerMocks.warn.mockReset();
});
describe("clipboard IPC", () => {
it("writes trusted renderer text through Electron clipboard", () => {
const result = writeClipboardTextFromIpc(
eventFor("http://localhost:5180/downloads"),
"https://rapidgator.net/file/example",
trustedOptions
);
expect(result).toBe(true);
expect(electronMocks.writeText).toHaveBeenCalledOnce();
expect(electronMocks.writeText).toHaveBeenCalledWith("https://rapidgator.net/file/example");
expect(loggerMocks.info).toHaveBeenCalledWith("Zwischenablage geschrieben: bytes=35");
expect(JSON.stringify(loggerMocks.info.mock.calls)).not.toContain("rapidgator.net");
});
it("rejects untrusted renderer calls before touching the clipboard", () => {
expect(() => writeClipboardTextFromIpc(
eventFor("https://attacker.example/downloads"),
"private value",
trustedOptions
)).toThrow("IPC-Absender ist nicht vertrauenswürdig");
expect(electronMocks.writeText).not.toHaveBeenCalled();
});
it("logs native clipboard failures without the copied value", () => {
electronMocks.writeText.mockImplementationOnce(() => {
throw new Error("Clipboard busy");
});
expect(() => writeClipboardTextFromIpc(
eventFor("http://localhost:5180/downloads"),
"private-link-value",
trustedOptions
)).toThrow("Clipboard busy");
expect(loggerMocks.warn).toHaveBeenCalledWith("Zwischenablage-Schreiben fehlgeschlagen: Error: Clipboard busy");
expect(JSON.stringify(loggerMocks.warn.mock.calls)).not.toContain("private-link-value");
});
it("rejects UTF-8 payloads larger than 16 MiB before touching the clipboard", () => {
const oversizedText = "ä".repeat((8 * 1024 * 1024) + 1);
expect(Buffer.byteLength(oversizedText, "utf8")).toBe((16 * 1024 * 1024) + 2);
expect(() => writeClipboardTextFromIpc(
eventFor("http://localhost:5180/downloads"),
oversizedText,
trustedOptions
)).toThrow("Ungültiger Zwischenablageinhalt");
expect(electronMocks.writeText).not.toHaveBeenCalled();
});
});
+130 -20
View File
@@ -1,11 +1,26 @@
import { describe, expect, it } from "vitest"; import fs from "node:fs";
import { import os from "node:os";
formatConversionBlock, import path from "node:path";
hasActiveConversionTrace, import { afterEach, describe, expect, it } from "vitest";
runWithConversionTrace, import {
traceConversionPhase, formatConversionBlock,
type ConversionTrace getConversionLogPath,
} from "../src/main/conversion-trace"; hasActiveConversionTrace,
initConversionLog,
runWithConversionTrace,
shutdownConversionLog,
traceConversionPhase,
type ConversionTrace
} from "../src/main/conversion-trace";
const tempDirs: string[] = [];
afterEach(() => {
shutdownConversionLog();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("formatConversionBlock", () => { describe("formatConversionBlock", () => {
it("renders a header with verdict + total and one indented line per phase", () => { it("renders a header with verdict + total and one indented line per phase", () => {
@@ -26,26 +41,89 @@ describe("formatConversionBlock", () => {
const lines = block.split("\n"); const lines = block.split("\n");
expect(lines[0]).toContain("[CONV]"); expect(lines[0]).toContain("[CONV]");
expect(lines[0]).toContain("item=tvs-foo.part5.rar"); expect(lines[0]).toContain("itemId=id1");
expect(lines[0]).not.toContain("tvs-foo.part5.rar");
expect(lines[0]).not.toContain("rapidgator.net");
expect(lines[0]).toContain("result=OK"); expect(lines[0]).toContain("result=OK");
expect(lines[0]).toContain("total=1450ms"); expect(lines[0]).toContain("total=1450ms");
expect(lines[0]).toContain("slots=conv2/dl6/max8"); expect(lines[0]).toContain("slots=conv2/dl6/max8");
expect(lines).toHaveLength(4); expect(lines).toHaveLength(4);
expect(lines[2]).toContain("+5ms token"); expect(lines[2]).toContain("+5ms token");
expect(lines[2]).toContain("token=fresh"); expect(lines[2]).toContain("account=Account 2/2");
expect(lines[2]).toContain("token=fresh");
expect(lines[2]).toContain("workMs=812"); expect(lines[2]).toContain("workMs=812");
}); });
it("includes the failure detail in the header verdict", () => { it("includes the failure detail in the header verdict", () => {
const trace: ConversionTrace = { const trace: ConversionTrace = {
startedAt: 0, itemId: "i", itemName: "x", link: "l", providerOrder: "megadebrid-web", notes: {}, startedAt: 0, itemId: "i", itemName: "x", link: "l", providerOrder: "megadebrid-web", notes: {},
phases: [{ atMs: 60000, phase: "caller-timeout", provider: "megadebrid-web", outcome: "timeout", detail: "Unrestrict Timeout nach 60s" }] phases: [{ atMs: 60000, phase: "caller-timeout", provider: "megadebrid-web", outcome: "timeout", detail: "Unrestrict Timeout nach 60s" }]
}; };
const block = formatConversionBlock(trace, "FAIL", "Unrestrict Timeout nach 60s", 60003); const block = formatConversionBlock(trace, "FAIL", "Unrestrict Timeout nach 60s", 60003);
expect(block.split("\n")[0]).toContain("result=FAIL (Unrestrict Timeout nach 60s)"); expect(block.split("\n")[0]).toContain("result=FAIL (Unrestrict Timeout nach 60s)");
expect(block).toContain("caller-timeout"); expect(block).toContain("caller-timeout");
}); });
});
it("keeps diagnostic value while removing identities, credentials and source URLs", () => {
const sourceUrl = "https://source-user:source-pass@files.example.test/private/file.rar?token=query-secret";
const trace: ConversionTrace = {
startedAt: 0,
itemId: "item-safe",
itemName: "file.rar",
link: sourceUrl,
providerOrder: "megadebrid-web",
notes: {
retry: 1,
auth: "login=trace-user password=trace-password",
token: "note-token-secret",
credentials: JSON.stringify({ login: "nested-trace-user", password: "nested-trace-password" })
},
phases: [{
atMs: 25,
phase: "mega-account",
provider: "megadebrid-web",
account: "Account 1/3 (tr***ce)",
outcome: "failed",
detail: `Incorrect password for trace@example.test token=provider-token masked=tr***ce@identity.invalid source=${sourceUrl}`
}]
};
const block = formatConversionBlock(trace, "FAIL", `Provider rejected password=header-secret at ${sourceUrl}`, 30);
expect(block).toContain("result=FAIL");
expect(block).toContain("Incorrect password");
expect(block).toContain("account=Account 1/3");
expect(block).toContain("itemId=item-safe");
expect(block).not.toContain("files.example.test");
for (const sensitive of ["source-user", "source-pass", "query-secret", "trace-user", "trace-password", "trace@example.test", "provider-token", "header-secret", "tr***ce", "identity.invalid", "note-token-secret", "nested-trace-user", "nested-trace-password", sourceUrl]) {
expect(block).not.toContain(sensitive);
}
expect(block).not.toContain("https://");
});
it("renders shared opaque correlation IDs without exposing item names or source links", () => {
const trace: ConversionTrace = {
startedAt: 0,
attemptId: "attempt-42",
itemId: "item-42",
packageId: "package-42",
itemName: "Private.Release.Name.part1.rar",
link: "https://private-user:private-password@rapidgator.net/file/private-token/Private.Release.Name.part1.rar",
providerOrder: "megadebrid-web",
notes: {},
phases: []
};
const block = formatConversionBlock(trace, "OK", "", 25);
expect(block).toContain("attemptId=attempt-42");
expect(block).toContain("itemId=item-42");
expect(block).toContain("packageId=package-42");
expect(block).not.toContain("Private.Release.Name");
expect(block).not.toContain("rapidgator.net");
expect(block).not.toContain("private-token");
expect(block).not.toContain("link=");
});
});
describe("conversion trace context", () => { describe("conversion trace context", () => {
it("traceConversionPhase is a no-op outside an active trace and does not throw", () => { it("traceConversionPhase is a no-op outside an active trace and does not throw", () => {
@@ -53,7 +131,7 @@ describe("conversion trace context", () => {
expect(() => traceConversionPhase({ phase: "orphan" })).not.toThrow(); expect(() => traceConversionPhase({ phase: "orphan" })).not.toThrow();
}); });
it("activates an ambient trace across awaits inside runWithConversionTrace", async () => { it("activates an ambient trace across awaits inside runWithConversionTrace", async () => {
expect(hasActiveConversionTrace()).toBe(false); expect(hasActiveConversionTrace()).toBe(false);
const seen = await runWithConversionTrace( const seen = await runWithConversionTrace(
{ itemId: "i", itemName: "n", link: "l", providerOrder: "megadebrid-api" }, { itemId: "i", itemName: "n", link: "l", providerOrder: "megadebrid-api" },
@@ -65,7 +143,39 @@ describe("conversion trace context", () => {
return before && afterAwait; return before && afterAwait;
} }
); );
expect(seen).toBe(true); expect(seen).toBe(true);
expect(hasActiveConversionTrace()).toBe(false); expect(hasActiveConversionTrace()).toBe(false);
}); });
});
it("carries optional correlation IDs through the async trace into the written block", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-conversion-correlation-"));
tempDirs.push(root);
initConversionLog(root);
await runWithConversionTrace(
{
attemptId: "attempt-written",
itemId: "item-written",
packageId: "package-written",
itemName: "Clear.Release.Name.rar",
link: "https://rapidgator.net/file/clear-link-token/Clear.Release.Name.rar",
providerOrder: "megadebrid-api"
},
async () => {
traceConversionPhase({ phase: "chain-try", provider: "megadebrid-api" });
await Promise.resolve();
}
);
const logPath = getConversionLogPath();
expect(logPath).not.toBeNull();
shutdownConversionLog();
const content = fs.readFileSync(logPath!, "utf8");
expect(content).toContain("attemptId=attempt-written");
expect(content).toContain("itemId=item-written");
expect(content).toContain("packageId=package-written");
expect(content).not.toContain("Clear.Release.Name");
expect(content).not.toContain("rapidgator.net");
expect(content).not.toContain("clear-link-token");
});
});
+421 -14
View File
@@ -4,7 +4,7 @@ import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
import { isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors"; import { isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors";
import { checkRapidgatorOnline, classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, getProviderRuntimeSnapshot, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, parseRapidgatorFileSize, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid"; import { checkRapidgatorOnline, classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getProviderRuntimeSnapshot, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, parseRapidgatorFileSize, primeDebridLinkRuntimeCooldownForTests, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
@@ -315,7 +315,7 @@ describe("debrid service", () => {
expect(calledUrls.some((url) => url.includes("debrid-link.com/api/v2/downloader/list?ids=dl-link-1"))).toBe(true); expect(calledUrls.some((url) => url.includes("debrid-link.com/api/v2/downloader/list?ids=dl-link-1"))).toBe(true);
}); });
it("rotates to the next Debrid-Link key when the first key is invalid", async () => { it("rotates to the next Debrid-Link key when the first key is invalid", async () => {
const settings = { const settings = {
...defaultSettings(), ...defaultSettings(),
debridLinkApiKeys: "dl-key-one\ndl-key-two", debridLinkApiKeys: "dl-key-one\ndl-key-two",
@@ -368,8 +368,23 @@ describe("debrid service", () => {
expect(authHeaders).toEqual(["Bearer dl-key-one", "Bearer dl-key-two"]); expect(authHeaders).toEqual(["Bearer dl-key-one", "Bearer dl-key-two"]);
expect(result.provider).toBe("debridlink"); expect(result.provider).toBe("debridlink");
expect(result.providerLabel).toContain("Key 2"); expect(result.providerLabel).toContain("Key 2");
expect(result.directUrl).toBe("https://debrid-link.example/valid.bin"); expect(result.directUrl).toBe("https://debrid-link.example/valid.bin");
}); });
it("clears Debrid-Link runtime cooldown when a key is reactivated live", () => {
const keys = parseDebridLinkApiKeys("dl-key-one\ndl-key-two");
const settings = {
...defaultSettings(),
debridLinkApiKeys: "dl-key-one\ndl-key-two",
debridLinkDisabledKeyIds: [keys[0].id]
};
primeDebridLinkRuntimeCooldownForTests(keys[0].id, 60_000, "stale cooldown");
const service = new DebridService(settings);
service.setSettings({ ...settings, debridLinkDisabledKeyIds: [] });
expect(getDebridLinkKeyCooldownStateForTests(keys[0].id)).toBeNull();
});
it("looks up limits and rotates keys when Debrid-Link host quota is reached", async () => { it("looks up limits and rotates keys when Debrid-Link host quota is reached", async () => {
const settings = { const settings = {
@@ -1351,7 +1366,7 @@ describe("debrid service", () => {
expect(realDebridWeb).not.toHaveBeenCalled(); expect(realDebridWeb).not.toHaveBeenCalled();
}); });
it("treats MegaDebrid as not configured when no credentials are set", async () => { it("treats MegaDebrid as not configured when no credentials are set", async () => {
const settings = { const settings = {
...defaultSettings(), ...defaultSettings(),
megaLogin: "", megaLogin: "",
@@ -1363,10 +1378,125 @@ describe("debrid service", () => {
}; };
const service = new DebridService(settings); const service = new DebridService(settings);
await expect(service.unrestrictLink("https://rapidgator.net/file/missing-mega-web")).rejects.toThrow(/nicht konfiguriert/i); await expect(service.unrestrictLink("https://rapidgator.net/file/missing-mega-web")).rejects.toThrow(/nicht konfiguriert/i);
}); });
it("uses Mega web fallback when API fails", async () => { it("keeps dedicated Mega-Debrid pools disabled when both explicit mode flags are false", async () => {
const fetchSpy = vi.fn(async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("action=connectUser")) {
return new Response(JSON.stringify({ response_code: "ok", token: "disabled-api-token" }), { status: 200 });
}
if (url.includes("action=getLink")) {
return new Response(JSON.stringify({
response_code: "ok",
debridLink: "https://mega-cdn.example/disabled-api.rar",
filename: "disabled-api.rar"
}), { status: 200 });
}
return new Response("not-found", { status: 404 });
});
globalThis.fetch = fetchSpy as typeof fetch;
const megaWeb = vi.fn(async () => ({
fileName: "disabled-web.rar",
directUrl: "https://mega-web.example/disabled-web.rar",
fileSize: null,
retriesUsed: 0
}));
for (const preferApi of [true, false]) {
const settings = {
...defaultSettings(),
megaLogin: "legacy-user",
megaPassword: "legacy-pass",
megaCredentials: "legacy-user:legacy-pass\napi-user:api-pass\nweb-user:web-pass",
megaDebridApiCredentials: "api-user:api-pass",
megaDebridWebCredentials: "web-user:web-pass",
megaDebridApiEnabled: false,
megaDebridWebEnabled: false,
megaDebridPreferApi: preferApi,
providerOrder: [] as const,
providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
await expect(service.unrestrictLink(`https://rapidgator.net/file/dedicated-disabled-${preferApi}`)).rejects.toThrow(/nicht konfiguriert/i);
}
expect(fetchSpy).not.toHaveBeenCalled();
expect(megaWeb).not.toHaveBeenCalled();
});
it("keeps the preferred API fallback for legacy Mega-Debrid settings without dedicated pool fields", async () => {
const settings = {
...defaultSettings(),
megaLogin: "legacy-api-user",
megaPassword: "legacy-api-pass",
megaCredentials: "legacy-api-user:legacy-api-pass",
megaDebridApiEnabled: false,
megaDebridWebEnabled: false,
megaDebridPreferApi: true,
providerOrder: [] as const,
providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
delete (settings as Partial<typeof settings>).megaDebridApiCredentials;
delete (settings as Partial<typeof settings>).megaDebridWebCredentials;
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("action=connectUser")) {
return new Response(JSON.stringify({ response_code: "ok", token: "legacy-api-token" }), { status: 200 });
}
if (url.includes("action=getLink")) {
return new Response(JSON.stringify({
response_code: "ok",
debridLink: "https://mega-cdn.example/legacy-api.rar",
filename: "legacy-api.rar"
}), { status: 200 });
}
return new Response("not-found", { status: 404 });
}) as typeof fetch;
const service = new DebridService(settings);
const result = await service.unrestrictLink("https://rapidgator.net/file/legacy-api");
expect(result.directUrl).toBe("https://mega-cdn.example/legacy-api.rar");
});
it("keeps the preferred Web fallback for legacy Mega-Debrid settings without dedicated pool fields", async () => {
const settings = {
...defaultSettings(),
megaLogin: "legacy-web-user",
megaPassword: "legacy-web-pass",
megaCredentials: "legacy-web-user:legacy-web-pass",
megaDebridApiEnabled: false,
megaDebridWebEnabled: false,
megaDebridPreferApi: false,
providerOrder: [] as const,
providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
delete (settings as Partial<typeof settings>).megaDebridApiCredentials;
delete (settings as Partial<typeof settings>).megaDebridWebCredentials;
const megaWeb = vi.fn(async () => ({
fileName: "legacy-web.rar",
directUrl: "https://mega-web.example/legacy-web.rar",
fileSize: null,
retriesUsed: 0
}));
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const result = await service.unrestrictLink("https://rapidgator.net/file/legacy-web");
expect(result.directUrl).toBe("https://mega-web.example/legacy-web.rar");
expect(megaWeb).toHaveBeenCalledTimes(1);
});
it("uses Mega web fallback when API fails", async () => {
const settings = { const settings = {
...defaultSettings(), ...defaultSettings(),
token: "", token: "",
@@ -1524,6 +1654,201 @@ describe("debrid service", () => {
} }
}); });
it("releases Mega-Debrid Web in-flight state when the provider ignores caller abort", async () => {
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaCredentials: "ignored-web-user:ignored-web-pass",
megaDebridApiCredentials: "",
megaDebridWebCredentials: "ignored-web-user:ignored-web-pass",
megaDebridApiEnabled: false,
megaDebridWebEnabled: true,
providerOrder: [] as const,
providerPrimary: "megadebrid-web" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
let markProviderStarted: () => void = () => {};
const providerStarted = new Promise<void>((resolve) => {
markProviderStarted = resolve;
});
let rejectProvider: (reason?: unknown) => void = () => {};
const ignoredProviderPromise = new Promise<never>((_resolve, reject) => {
rejectProvider = reject;
});
const megaWeb = vi.fn(() => {
markProviderStarted();
return ignoredProviderPromise;
});
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const controller = new AbortController();
const request = service.unrestrictLink("https://rapidgator.net/file/ignored-web-abort", controller.signal);
const outcome = request.then(
() => ({ status: "fulfilled" as const, error: null }),
(error: unknown) => ({ status: "rejected" as const, error })
);
await providerStarted;
expect(getMegaDebridInFlightCountForMode("web")).toBe(1);
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
controller.abort("settings_refresh");
const settled = await Promise.race([
outcome,
new Promise<null>((resolve) => {
timeout = setTimeout(() => resolve(null), 100);
})
]);
expect(settled).not.toBeNull();
expect(settled?.status).toBe("rejected");
expect(String(settled?.error)).toMatch(/aborted/i);
expect(getMegaDebridInFlightCountForMode("web")).toBe(0);
} finally {
if (timeout) {
clearTimeout(timeout);
}
rejectProvider(new Error("late Mega-Web provider failure"));
await outcome;
}
await new Promise<void>((resolve) => setTimeout(resolve, 0));
expect(getMegaDebridInFlightCountForMode("web")).toBe(0);
});
it("releases Mega-Debrid Web in-flight state when the provider ignores the account timeout", async () => {
process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS = "20";
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaCredentials: "ignored-timeout-user:ignored-timeout-pass",
megaDebridApiCredentials: "",
megaDebridWebCredentials: "ignored-timeout-user:ignored-timeout-pass",
megaDebridApiEnabled: false,
megaDebridWebEnabled: true,
providerOrder: [] as const,
providerPrimary: "megadebrid-web" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
let markProviderStarted: () => void = () => {};
const providerStarted = new Promise<void>((resolve) => {
markProviderStarted = resolve;
});
let rejectProvider: (reason?: unknown) => void = () => {};
const ignoredProviderPromise = new Promise<never>((_resolve, reject) => {
rejectProvider = reject;
});
const megaWeb = vi.fn(() => {
markProviderStarted();
return ignoredProviderPromise;
});
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const request = service.unrestrictLink("https://rapidgator.net/file/ignored-web-timeout");
const outcome = request.then(
() => ({ status: "fulfilled" as const, error: null }),
(error: unknown) => ({ status: "rejected" as const, error })
);
await providerStarted;
expect(getMegaDebridInFlightCountForMode("web")).toBe(1);
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
const settled = await Promise.race([
outcome,
new Promise<null>((resolve) => {
timeout = setTimeout(() => resolve(null), 200);
})
]);
expect(settled).not.toBeNull();
expect(settled?.status).toBe("rejected");
expect(String(settled?.error)).toMatch(/mega_debrid_slow_link|aborted/i);
expect(getMegaDebridInFlightCountForMode("web")).toBe(0);
} finally {
if (timeout) {
clearTimeout(timeout);
}
rejectProvider(new Error("late Mega-Web timeout failure"));
await outcome;
}
await new Promise<void>((resolve) => setTimeout(resolve, 0));
expect(getMegaDebridInFlightCountForMode("web")).toBe(0);
});
it("releases Mega-Debrid API in-flight state when getLink ignores caller abort", async () => {
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaCredentials: "ignored-api-user:ignored-api-pass",
megaDebridApiCredentials: "ignored-api-user:ignored-api-pass",
megaDebridWebCredentials: "",
megaDebridApiEnabled: true,
megaDebridWebEnabled: false,
providerOrder: [] as const,
providerPrimary: "megadebrid-api" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
let markGetLinkStarted: () => void = () => {};
const getLinkStarted = new Promise<void>((resolve) => {
markGetLinkStarted = resolve;
});
let rejectGetLink: (reason?: unknown) => void = () => {};
const ignoredGetLinkPromise = new Promise<Response>((_resolve, reject) => {
rejectGetLink = reject;
});
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("action=connectUser")) {
return new Response(JSON.stringify({ response_code: "ok", token: "ignored-api-token" }), { status: 200 });
}
if (url.includes("action=getLink")) {
markGetLinkStarted();
return ignoredGetLinkPromise;
}
return new Response("not-found", { status: 404 });
}) as typeof fetch;
const service = new DebridService(settings);
const controller = new AbortController();
const request = service.unrestrictLink("https://rapidgator.net/file/ignored-api-abort", controller.signal);
const outcome = request.then(
() => ({ status: "fulfilled" as const, error: null }),
(error: unknown) => ({ status: "rejected" as const, error })
);
await getLinkStarted;
expect(getMegaDebridInFlightCountForMode("api")).toBe(1);
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
controller.abort("reset");
const settled = await Promise.race([
outcome,
new Promise<null>((resolve) => {
timeout = setTimeout(() => resolve(null), 100);
})
]);
expect(settled).not.toBeNull();
expect(settled?.status).toBe("rejected");
expect(String(settled?.error)).toMatch(/aborted/i);
expect(getMegaDebridInFlightCountForMode("api")).toBe(0);
} finally {
if (timeout) {
clearTimeout(timeout);
}
rejectGetLink(new Error("late Mega-Debrid API provider failure"));
await outcome;
}
await new Promise<void>((resolve) => setTimeout(resolve, 0));
expect(getMegaDebridInFlightCountForMode("api")).toBe(0);
});
it("does not cache a stale Mega-Debrid API token after credentials change during connect", async () => { it("does not cache a stale Mega-Debrid API token after credentials change during connect", async () => {
const oldSettings = { const oldSettings = {
...defaultSettings(), ...defaultSettings(),
@@ -2341,17 +2666,99 @@ describe("debrid service", () => {
expect(getMegaDebridAccountCooldownState(key, afterReset)).toBeNull(); expect(getMegaDebridAccountCooldownState(key, afterReset)).toBeNull();
}); });
it("does NOT treat a per-hoster 'no server' failure as an account daily-limit signal (no until-restart park)", () => { it("does NOT treat a per-hoster 'no server' failure as an account daily-limit signal (no until-restart park)", () => {
const noServer = classifyMegaDebridAccountFailureForTests(new Error("no server available for this host")); const noServer = classifyMegaDebridAccountFailureForTests(new Error("no server available for this host"));
expect(noServer.limitSignal).toBeFalsy(); expect(noServer.limitSignal).toBeFalsy();
expect(noServer.category).toBe("quota"); expect(noServer.category).toBe("quota");
expect(noServer.cooldownMs).toBeGreaterThan(0); expect(noServer.cooldownMs).toBeGreaterThan(0);
const genuineEmpty = classifyMegaDebridAccountFailureForTests(new Error("Antwort leer")); const genuineEmpty = classifyMegaDebridAccountFailureForTests(new Error("Antwort leer"));
expect(genuineEmpty.limitSignal).toBe(true); expect(genuineEmpty.limitSignal).toBe(true);
}); });
it("classifies an empty Mega-Debrid API result ('Linkgenerierung lieferte kein Ergebnis') as a fast transient, not a 30s cooldown", () => { it("sanitizes provider-supplied account failures before they leave Mega-Debrid rotation", async () => {
const login = "private-user@example.test";
const password = "provider-password-secret";
const sourceUrl = "https://source-user:source-pass@files.example.test/private/file.rar?token=query-secret";
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: login,
megaPassword: password,
megaCredentials: `${login}:${password}`,
megaDebridApiCredentials: "",
megaDebridWebCredentials: `${login}:${password}`,
megaDebridApiEnabled: false,
megaDebridWebEnabled: true,
megaDebridPreferApi: false,
providerOrder: [] as const,
providerPrimary: "megadebrid-web" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
const megaWeb = vi.fn(async () => {
throw new Error(`Incorrect password for ${login} login=${login} password=${password} source=${sourceUrl}`);
});
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const error = await service.unrestrictLink("https://rapidgator.net/file/provider-error").then(() => null, (caught: unknown) => caught as Error);
const message = String(error?.message || error || "");
const cooldown = getMegaDebridAccountCooldownState(`${getMegaDebridAccountId(login)}:web`);
expect(message).toContain("ungueltiger Account");
expect(message).toContain("Account 1/1");
expect(message).toMatch(/files\.example\.test#[a-f0-9]{10}/);
expect(cooldown?.category).toBe("invalid");
for (const sensitive of [login, password, "source-user", "source-pass", "query-secret", sourceUrl]) {
expect(message).not.toContain(sensitive);
expect(cooldown?.message || "").not.toContain(sensitive);
}
expect(message).not.toContain("*");
});
it("sanitizes provider-supplied API key failures before they leave Debrid-Link rotation", async () => {
const apiKey = "provider-debrid-link-secret";
const sourceUrl = "https://source-user:source-pass@files.example.test/private/file.rar?token=query-secret";
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
debridLinkApiKeys: apiKey,
providerOrder: [] as const,
providerPrimary: "debridlink" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
globalThis.fetch = (async () => new Response(JSON.stringify({
success: false,
error: "badToken",
error_description: `Rejected api_key=${apiKey} source=${sourceUrl}`
}), {
status: 401,
headers: { "Content-Type": "application/json" }
})) as typeof fetch;
const service = new DebridService(settings);
const error = await service.unrestrictLink("https://rapidgator.net/file/provider-key-error").then(() => null, (caught: unknown) => caught as Error);
const message = String(error?.message || error || "");
const keyId = parseDebridLinkApiKeys(apiKey)[0].id;
const cooldown = getDebridLinkKeyCooldownStateForTests(keyId);
expect(message).toContain("ungueltiger oder deaktivierter API-Key");
expect(message).toContain("Key 1/1");
expect(message).toMatch(/files\.example\.test#[a-f0-9]{10}/);
expect(getDebridLinkKeyRuntimeStateForTests(keyId)).toBe("invalid");
for (const sensitive of [apiKey, "source-user", "source-pass", "query-secret", sourceUrl]) {
expect(message).not.toContain(sensitive);
expect(cooldown?.message || "").not.toContain(sensitive);
}
expect(message).not.toContain("*");
});
it("classifies an empty Mega-Debrid API result ('Linkgenerierung lieferte kein Ergebnis') as a fast transient, not a 30s cooldown", () => {
const result = classifyMegaDebridAccountFailureForTests(new Error("Mega-Debrid API: Linkgenerierung lieferte kein Ergebnis")); const result = classifyMegaDebridAccountFailureForTests(new Error("Mega-Debrid API: Linkgenerierung lieferte kein Ergebnis"));
expect(result.fatal).toBe(false); expect(result.fatal).toBe(false);
expect(result.cooldownMs).toBe(0); expect(result.cooldownMs).toBe(0);
+1 -1
View File
@@ -583,7 +583,7 @@ describe("debug-server", () => {
expect(entries).toContain("overview/settings.json"); expect(entries).toContain("overview/settings.json");
expect(entries).toContain("overview/accounts.json"); expect(entries).toContain("overview/accounts.json");
expect(entries).toContain("overview/debug-setup.json"); expect(entries).toContain("overview/debug-setup.json");
expect(entries).toContain("overview/self-check.json"); expect(entries).not.toContain("overview/self-check.json");
expect(entries).toContain("overview/trace-config.json"); expect(entries).toContain("overview/trace-config.json");
expect(entries).toContain("logs/audit.log"); expect(entries).toContain("logs/audit.log");
expect(entries).toContain("logs/rename.log"); expect(entries).toContain("logs/rename.log");
File diff suppressed because it is too large Load Diff
+63 -18
View File
@@ -79,35 +79,42 @@ function createSnapshot(running: boolean, paused: boolean): UiSnapshot {
}; };
} }
function findStartAction(node: ReactNode): (() => void) | null { interface DownloadActions {
onStartDownloads?: () => void;
onPauseDownloads?: () => void;
onStopDownloads?: () => void;
}
function findDownloadActions(node: ReactNode): DownloadActions | null {
if (Array.isArray(node)) { if (Array.isArray(node)) {
for (const child of node) { for (const child of node) {
const action = findStartAction(child); const actions = findDownloadActions(child);
if (action) return action; if (actions) return actions;
} }
return null; return null;
} }
if (!isValidElement(node)) return null; if (!isValidElement(node)) return null;
const props = node.props as { const props = node.props as {
actions?: { onStartDownloads?: () => void }; actions?: DownloadActions;
children?: ReactNode; children?: ReactNode;
toolbar?: ReactNode; toolbar?: ReactNode;
}; };
if (typeof props.actions?.onStartDownloads === "function") { if (props.actions && typeof props.actions.onStartDownloads === "function") {
return props.actions.onStartDownloads; return props.actions;
} }
return findStartAction(props.toolbar) ?? findStartAction(props.children); return findDownloadActions(props.toolbar) ?? findDownloadActions(props.children);
} }
async function flushAsyncAction(): Promise<void> { async function flushAsyncAction(): Promise<void> {
await new Promise<void>((resolve) => setImmediate(resolve)); await new Promise<void>((resolve) => setImmediate(resolve));
} }
function renderPausedStartAction( function renderDownloadActions(
initialSnapshot: UiSnapshot, initialSnapshot: UiSnapshot,
togglePause: () => Promise<boolean>, togglePause: () => Promise<boolean>,
getSnapshot: () => Promise<UiSnapshot> getSnapshot: () => Promise<UiSnapshot>,
): () => void { stop: () => Promise<void> = async () => undefined
): DownloadActions {
hookState.capturedSnapshot = false; hookState.capturedSnapshot = false;
hookState.initialSnapshot = initialSnapshot; hookState.initialSnapshot = initialSnapshot;
hookState.currentSnapshot = initialSnapshot; hookState.currentSnapshot = initialSnapshot;
@@ -125,14 +132,14 @@ function renderPausedStartAction(
devicePixelRatio: 1, devicePixelRatio: 1,
matchMedia: () => ({ matches: false }), matchMedia: () => ({ matches: false }),
prompt: () => null, prompt: () => null,
rd: { getSnapshot, togglePause }, rd: { getSnapshot, togglePause, stop },
removeEventListener: () => {}, removeEventListener: () => {},
setInterval, setInterval,
setTimeout setTimeout
}); });
const action = findStartAction(App() as ReactElement); const actions = findDownloadActions(App() as ReactElement);
if (!action) throw new Error("Download-Startaktion nicht gefunden"); if (!actions) throw new Error("Download-Aktionen nicht gefunden");
return action; return actions;
} }
describe("paused download resume reconciliation", () => { describe("paused download resume reconciliation", () => {
@@ -149,13 +156,13 @@ describe("paused download resume reconciliation", () => {
it("restores the authoritative paused state when togglePause rejects without a state event", async () => { it("restores the authoritative paused state when togglePause rejects without a state event", async () => {
const initial = createSnapshot(true, true); const initial = createSnapshot(true, true);
const authoritative = createSnapshot(true, true); const authoritative = createSnapshot(true, true);
const action = renderPausedStartAction( const actions = renderDownloadActions(
initial, initial,
async () => { throw new Error("Kein aktiver Download-Account verfügbar"); }, async () => { throw new Error("Kein aktiver Download-Account verfügbar"); },
async () => authoritative async () => authoritative
); );
action(); actions.onStartDownloads?.();
await flushAsyncAction(); await flushAsyncAction();
expect(hookState.currentSnapshot).toEqual(authoritative); expect(hookState.currentSnapshot).toEqual(authoritative);
@@ -164,13 +171,51 @@ describe("paused download resume reconciliation", () => {
it("replaces stale running state when togglePause returns false without a state event", async () => { it("replaces stale running state when togglePause returns false without a state event", async () => {
const initial = createSnapshot(true, true); const initial = createSnapshot(true, true);
const authoritative = createSnapshot(false, false); const authoritative = createSnapshot(false, false);
const action = renderPausedStartAction( const actions = renderDownloadActions(
initial, initial,
async () => false, async () => false,
async () => authoritative async () => authoritative
); );
action(); actions.onStartDownloads?.();
await flushAsyncAction();
expect(hookState.currentSnapshot).toEqual(authoritative);
});
it("replaces stale running state when pausing returns false without a state event", async () => {
const initial = createSnapshot(true, false);
const authoritative = createSnapshot(false, false);
const actions = renderDownloadActions(
initial,
async () => false,
async () => authoritative
);
actions.onPauseDownloads?.();
await flushAsyncAction();
expect(hookState.currentSnapshot).toEqual(authoritative);
});
it("loads the complete authoritative snapshot after stop succeeds without a state event", async () => {
const initial = createSnapshot(true, false);
const authoritative = {
...createSnapshot(false, false),
canStart: true,
stats: {
...createSnapshot(false, false).stats,
totalDownloaded: 4096
}
};
const actions = renderDownloadActions(
initial,
async () => false,
async () => authoritative,
async () => undefined
);
actions.onStopDownloads?.();
await flushAsyncAction(); await flushAsyncAction();
expect(hookState.currentSnapshot).toEqual(authoritative); expect(hookState.currentSnapshot).toEqual(authoritative);
+8 -1
View File
@@ -48,7 +48,7 @@ import {
formatHosterLabel, formatHosterLabel,
normalizeDownloadServiceLabel normalizeDownloadServiceLabel
} from "../src/renderer/download-format"; } from "../src/renderer/download-format";
import { getRollingMetricDirection } from "../src/renderer/ui/RollingMetricValue"; import { getRollingMetricDirection, shouldAnimateRollingMetric } from "../src/renderer/ui/RollingMetricValue";
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime(); const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
@@ -98,6 +98,13 @@ describe("rollende Downloadkennzahlen", () => {
expect(getRollingMetricDirection(300, 300)).toBe("none"); expect(getRollingMetricDirection(300, 300)).toBe("none");
}); });
it("skips rolling animations when reduced motion is requested", () => {
expect(shouldAnimateRollingMetric("up", true)).toBe(false);
expect(shouldAnimateRollingMetric("down", true)).toBe(false);
expect(shouldAnimateRollingMetric("up", false)).toBe(true);
expect(shouldAnimateRollingMetric("none", false)).toBe(false);
});
it("animates exactly the five stable sidebar metrics", () => { it("animates exactly the five stable sidebar metrics", () => {
const html = renderToStaticMarkup(<DownloadsSidebarStatus model={withRuntime(createInput())} />); const html = renderToStaticMarkup(<DownloadsSidebarStatus model={withRuntime(createInput())} />);
expect(html.match(/class="downloads-rolling-value"/g)).toHaveLength(5); expect(html.match(/class="downloads-rolling-value"/g)).toHaveLength(5);
+51 -4
View File
@@ -35,7 +35,7 @@ describe("item-log", () => {
expect(content).toContain("episode.part2.rar"); expect(content).toContain("episode.part2.rar");
}); });
it("writes detail events into the item log", async () => { it("writes detail events into the item log", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-")); const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-"));
tempDirs.push(baseDir); tempDirs.push(baseDir);
@@ -60,9 +60,56 @@ describe("item-log", () => {
expect(logPath).not.toBeNull(); expect(logPath).not.toBeNull();
const content = fs.readFileSync(logPath!, "utf8"); const content = fs.readFileSync(logPath!, "utf8");
expect(content).toContain("Entpack-Fehler"); expect(content).toContain("Entpack-Fehler");
expect(content).toContain("archive=episode.part2.rar"); expect(content).toContain("archive=episode.part2.rar");
expect(content).toContain("code=missing_parts"); expect(content).toContain("code=missing_parts");
}); });
it("redacts secrets, identities, direct links and local paths from item logs", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-"));
tempDirs.push(baseDir);
initItemLogs(baseDir);
ensureItemLog({
itemId: "item-sensitive",
packageId: "pkg-sensitive",
packageName: "Sensitive Paket",
fileName: "episode.part2.rar",
targetPath: "C:\\Users\\Administrator\\Downloads\\Sensitive Paket\\episode.part2.rar"
});
logItemEvent(
"item-sensitive",
"ERROR",
"Download https://ddownload.com/private/file.rar?auth=url-secret für user@example.net nach /mnt/downloads/file.rar fehlgeschlagen",
{
downloadUrl: "https://ddownload.com/private/file.rar?auth=url-secret",
password: "password-secret-value",
metadata: {
cookies: "sid=cookie-secret-value",
username: "user@example.net",
localPath: "\\\\server\\share\\file.rar"
}
}
);
await new Promise((resolve) => setTimeout(resolve, 350));
const logPath = getItemLogPath("item-sensitive");
expect(logPath).not.toBeNull();
const content = fs.readFileSync(logPath!, "utf8");
expect(content).toMatch(/ddownload\.com#[a-f0-9]{10}/);
expect(content).toContain("<redacted>");
expect(content).toContain("<redacted-account>");
expect(content).toContain("<redacted-path>");
expect(content).not.toContain("/private/file.rar");
expect(content).not.toContain("url-secret");
expect(content).not.toContain("password-secret-value");
expect(content).not.toContain("cookie-secret-value");
expect(content).not.toContain("user@example.net");
expect(content).not.toContain("C:\\Users\\Administrator");
expect(content).not.toContain("/mnt/downloads");
expect(content).not.toContain("\\\\server\\share");
});
it("keeps traversal-like item ids inside the item log directory", () => { it("keeps traversal-like item ids inside the item log directory", () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-")); const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-"));
+36
View File
@@ -0,0 +1,36 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { configureLogger, flushLogger, getLogFilePath, logger } from "../src/main/logger";
const tempDirs: string[] = [];
afterEach(async () => {
await flushLogger();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("logger", () => {
it("redacts secrets, accounts, URLs and local paths before every log sink", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-logger-redaction-"));
tempDirs.push(baseDir);
configureLogger(baseDir);
logger.warn("URL=https://rapidgator.net/file/private?token=secret | target=C:\\Users\\Admin\\Desktop\\private.rar | email=user@example.com | password=hunter2 | Authorization: Bearer abc.def");
await flushLogger();
const content = fs.readFileSync(getLogFilePath(), "utf8");
expect(content).toContain("rapidgator.net#");
expect(content).toContain("<redacted-path>");
expect(content).toContain("<redacted-account>");
expect(content).toContain("password=<redacted>");
expect(content).not.toContain("/file/private");
expect(content).not.toContain("C:\\Users\\Admin");
expect(content).not.toContain("user@example.com");
expect(content).not.toContain("hunter2");
expect(content).not.toContain("abc.def");
});
});
+52 -5
View File
@@ -56,11 +56,58 @@ describe("package-log", () => {
const logPath = getPackageLogPath("pkg-2"); const logPath = getPackageLogPath("pkg-2");
expect(logPath).not.toBeNull(); expect(logPath).not.toBeNull();
const content = fs.readFileSync(logPath!, "utf8"); const content = fs.readFileSync(logPath!, "utf8");
expect(content).toContain("Passwort-Versuch"); expect(content).toContain("Passwort-Versuch");
expect(content).toContain("archive=episode.part1.rar"); expect(content).toContain("archive=episode.part1.rar");
expect(content).toContain("password=\"secret\""); expect(content).toContain("password=<redacted>");
}); expect(content).not.toContain("\"secret\"");
});
it("redacts secrets, identities, direct links and local paths from package logs", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-plog-"));
tempDirs.push(baseDir);
initPackageLogs(baseDir);
ensurePackageLog({
packageId: "pkg-sensitive",
name: "Sensitive Paket",
outputDir: "C:\\Users\\Administrator\\Downloads\\Sensitive Paket",
extractDir: "/srv/downloads/Sensitive Paket"
});
logPackageEvent(
"pkg-sensitive",
"ERROR",
"Abruf https://rapidgator.net/file/private-id/archive.rar?token=query-secret für owner@example.org unter C:\\Users\\Administrator\\Downloads\\archive.rar fehlgeschlagen",
{
directUrl: "https://rapidgator.net/file/private-id/archive.rar?token=query-secret",
apiToken: "token-secret-value",
nested: {
cookie: "session=cookie-secret-value",
email: "owner@example.org",
outputPath: "/home/downloader/archive.rar"
}
}
);
await new Promise((resolve) => setTimeout(resolve, 350));
const logPath = getPackageLogPath("pkg-sensitive");
expect(logPath).not.toBeNull();
const content = fs.readFileSync(logPath!, "utf8");
expect(content).toMatch(/rapidgator\.net#[a-f0-9]{10}/);
expect(content).toContain("<redacted>");
expect(content).toContain("<redacted-account>");
expect(content).toContain("<redacted-path>");
expect(content).not.toContain("private-id");
expect(content).not.toContain("query-secret");
expect(content).not.toContain("token-secret-value");
expect(content).not.toContain("cookie-secret-value");
expect(content).not.toContain("owner@example.org");
expect(content).not.toContain("C:\\Users\\Administrator");
expect(content).not.toContain("/home/downloader");
expect(content).not.toContain("/srv/downloads");
});
it("keeps traversal-like package ids inside the package log directory", () => { it("keeps traversal-like package ids inside the package log directory", () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-plog-")); const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-plog-"));
+83 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { PackageEntry } from "../src/shared/types"; import type { PackageEntry } from "../src/shared/types";
import { preservePackageOrderForDisplay } from "../src/renderer/package-order"; import {
preservePackageOrderForDisplay,
reconcileCollapsedPackageState,
reconcileOptimisticPackageOrder
} from "../src/renderer/package-order";
function createPackage(id: string, itemIds: string[], downloadStartedAt = 0): PackageEntry { function createPackage(id: string, itemIds: string[], downloadStartedAt = 0): PackageEntry {
const now = Date.now(); const now = Date.now();
@@ -39,3 +43,81 @@ describe("preservePackageOrderForDisplay", () => {
expect(preservePackageOrderForDisplay(packages).map((pkg) => pkg.id)).toEqual(["pkg-first", "pkg-second", "pkg-third"]); expect(preservePackageOrderForDisplay(packages).map((pkg) => pkg.id)).toEqual(["pkg-first", "pkg-second", "pkg-third"]);
}); });
}); });
describe("reconcileCollapsedPackageState", () => {
it("keeps user collapse choices stable while package metadata and order change", () => {
const previous = { "pkg-first": true, "pkg-second": false };
const packages = {
"pkg-first": { ...createPackage("pkg-first", ["first-item"], 300), status: "downloading" as const },
"pkg-second": { ...createPackage("pkg-second", ["second-item"], 100), status: "completed" as const }
};
const next = reconcileCollapsedPackageState(previous, ["pkg-second", "pkg-first"], packages, true);
expect(next).toBe(previous);
expect(next).toEqual({ "pkg-first": true, "pkg-second": false });
});
it("defaults only new packages and removes packages that disappeared", () => {
const previous = { "pkg-old": false, "pkg-stale": true };
const packages = {
"pkg-old": createPackage("pkg-old", ["old-item"]),
"pkg-new": createPackage("pkg-new", ["new-item"])
};
expect(reconcileCollapsedPackageState(previous, ["pkg-old", "pkg-new"], packages, true)).toEqual({
"pkg-old": false,
"pkg-new": true
});
});
});
describe("reconcileOptimisticPackageOrder", () => {
it("keeps the optimistic order visible while an older state event arrives", () => {
const pending = ["pkg-a", "pkg-c", "pkg-b"];
expect(reconcileOptimisticPackageOrder(
["pkg-a", "pkg-b", "pkg-c"],
pending,
1_000,
1_500
)).toEqual({
displayOrder: pending,
pendingOrder: pending,
pendingAt: 1_000,
status: "pending"
});
});
it("accepts the authoritative order once it acknowledges the optimistic change", () => {
const pending = ["pkg-a", "pkg-c", "pkg-b"];
expect(reconcileOptimisticPackageOrder(
pending,
pending,
1_000,
1_500
)).toEqual({
displayOrder: pending,
pendingOrder: null,
pendingAt: 0,
status: "acknowledged"
});
});
it("returns to the authoritative order after the optimistic hold times out", () => {
const authoritative = ["pkg-a", "pkg-b", "pkg-c"];
expect(reconcileOptimisticPackageOrder(
authoritative,
["pkg-a", "pkg-c", "pkg-b"],
1_000,
2_500
)).toEqual({
displayOrder: authoritative,
pendingOrder: null,
pendingAt: 0,
status: "timed-out"
});
});
});
+192
View File
@@ -0,0 +1,192 @@
import { describe, expect, it, vi } from "vitest";
import { AppController } from "../src/main/app-controller";
vi.mock("electron", () => ({
app: { getPath: () => "C:\\MDD\\Test" },
BrowserWindow: class {},
clipboard: {},
dialog: {},
ipcMain: { handle: vi.fn(), on: vi.fn() },
Menu: { buildFromTemplate: vi.fn(), setApplicationMenu: vi.fn() },
safeStorage: { isEncryptionAvailable: () => false, encryptString: vi.fn(), decryptString: vi.fn() },
shell: {},
Tray: class {}
}));
describe("reset controller boundary", () => {
it("audits reset completion only after the manager operation succeeds", async () => {
const packagePromise = Promise.resolve();
const itemPromise = Promise.resolve();
const controller = Object.create(AppController.prototype) as {
manager: {
resetPackage: ReturnType<typeof vi.fn>;
resetItems: ReturnType<typeof vi.fn>;
};
audit: ReturnType<typeof vi.fn>;
resetPackage: (packageId: string) => Promise<void>;
resetItems: (itemIds: string[]) => Promise<void>;
};
controller.manager = {
resetPackage: vi.fn(() => packagePromise),
resetItems: vi.fn(() => itemPromise)
};
controller.audit = vi.fn();
await controller.resetPackage("package-1");
await controller.resetItems(["item-1"]);
expect(controller.audit.mock.calls.map((call) => call[1])).toEqual([
"Paket-Reset angefordert",
"Paket-Reset abgeschlossen",
"Item-Reset angefordert",
"Item-Reset abgeschlossen"
]);
});
it("audits reset failures instead of reporting a false success", async () => {
const controller = Object.create(AppController.prototype) as {
manager: {
resetPackage: ReturnType<typeof vi.fn>;
resetItems: ReturnType<typeof vi.fn>;
};
audit: ReturnType<typeof vi.fn>;
resetPackage: (packageId: string) => Promise<void>;
resetItems: (itemIds: string[]) => Promise<void>;
};
controller.manager = {
resetPackage: vi.fn(async () => { throw new Error("C:\\private\\locked.part"); }),
resetItems: vi.fn(async () => { throw new Error("item locked"); })
};
controller.audit = vi.fn();
await expect(controller.resetPackage("package-1")).rejects.toThrow();
await expect(controller.resetItems(["item-1"])).rejects.toThrow();
expect(controller.audit.mock.calls.map((call) => call[1])).toEqual([
"Paket-Reset angefordert",
"Paket-Reset fehlgeschlagen",
"Item-Reset angefordert",
"Item-Reset fehlgeschlagen"
]);
});
});
describe("session control diagnostics", () => {
it("records requested and applied phases for stop and pause", () => {
const snapshot = {
session: {
running: true,
paused: false,
packages: { "package-1": {} },
items: {
"item-1": { status: "downloading" },
"item-2": { status: "queued" }
}
}
};
const controller = Object.create(AppController.prototype) as {
manager: {
stop: ReturnType<typeof vi.fn>;
togglePause: ReturnType<typeof vi.fn>;
getSnapshot: ReturnType<typeof vi.fn>;
};
audit: ReturnType<typeof vi.fn>;
stop: () => void;
togglePause: () => boolean;
};
controller.manager = {
stop: vi.fn(() => { snapshot.session.running = false; }),
togglePause: vi.fn(() => {
snapshot.session.running = true;
snapshot.session.paused = true;
return true;
}),
getSnapshot: vi.fn(() => snapshot)
};
controller.audit = vi.fn();
controller.stop();
controller.togglePause();
expect(controller.audit.mock.calls.map((call) => call[1])).toEqual([
"Session-Stopp angefordert",
"Session-Stopp angewendet",
"Pause angefordert",
"Pause angewendet"
]);
});
});
describe("support bundle diagnostics", () => {
it("records selection and export phases without target paths", () => {
const controller = Object.create(AppController.prototype) as {
manager: {
getSnapshot: ReturnType<typeof vi.fn>;
};
audit: ReturnType<typeof vi.fn>;
recordSupportBundleExportSelected: () => void;
recordSupportBundleExportLifecycle: (event: {
phase: "write" | "failure";
durationMs: number;
totalDurationMs: number;
bytes?: number;
failedPhase?: "write";
code?: string;
}) => void;
};
controller.manager = {
getSnapshot: vi.fn(() => ({
session: {
running: true,
paused: false,
packages: { "package-1": {} },
items: {
"item-1": { status: "downloading" },
"item-2": { status: "queued" }
}
}
}))
};
controller.audit = vi.fn();
controller.recordSupportBundleExportSelected();
controller.recordSupportBundleExportLifecycle({
phase: "write",
durationMs: 250,
totalDurationMs: 700,
bytes: 4096
});
controller.recordSupportBundleExportLifecycle({
phase: "failure",
durationMs: 300,
totalDurationMs: 1000,
failedPhase: "write",
code: "ENOSPC"
});
expect(controller.audit.mock.calls).toEqual([
["INFO", "Support-Bundle-Ziel ausgewählt", {
phase: "selected",
running: true,
paused: false,
packageCount: 1,
itemCount: 2,
activeItemCount: 1
}],
["INFO", "Support-Bundle geschrieben", {
phase: "write",
durationMs: 250,
totalDurationMs: 700,
bytes: 4096
}],
["ERROR", "Support-Bundle-Export fehlgeschlagen", {
phase: "failure",
durationMs: 300,
totalDurationMs: 1000,
failedPhase: "write",
code: "ENOSPC"
}]
]);
expect(JSON.stringify(controller.audit.mock.calls)).not.toContain("C:\\");
});
});
+64 -5
View File
@@ -954,7 +954,7 @@ describe("settings storage", () => {
expect(loaded.packageOrder).toEqual(empty.packageOrder); expect(loaded.packageOrder).toEqual(empty.packageOrder);
}); });
it("loads backup session when primary session is corrupted", () => { it("loads backup session when primary session is corrupted", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir); tempDirs.push(dir);
const paths = createStoragePaths(dir); const paths = createStoragePaths(dir);
@@ -1005,10 +1005,69 @@ describe("settings storage", () => {
expect(loaded.items["item-backup"]?.fileName).toBe("backup-file.rar"); expect(loaded.items["item-backup"]?.fileName).toBe("backup-file.rar");
const restoredPrimary = JSON.parse(fs.readFileSync(paths.sessionFile, "utf8")) as { packages?: Record<string, unknown> }; const restoredPrimary = JSON.parse(fs.readFileSync(paths.sessionFile, "utf8")) as { packages?: Record<string, unknown> };
expect(restoredPrimary.packages && "pkg-backup" in restoredPrimary.packages).toBe(true); expect(restoredPrimary.packages && "pkg-backup" in restoredPrimary.packages).toBe(true);
}); });
it("returns defaults when config file contains invalid JSON", () => { it("preserves resume recovery state across a session save and reload", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const session = emptySession();
const now = Date.now();
const outputDir = path.join(dir, "out");
const itemId = "item-resume";
session.packageOrder = ["pkg-resume"];
session.packages["pkg-resume"] = {
id: "pkg-resume",
name: "Resume Package",
outputDir,
extractDir: path.join(dir, "extract"),
status: "queued",
itemIds: [itemId],
cancelled: false,
enabled: true,
createdAt: now,
updatedAt: now
};
session.items[itemId] = {
id: itemId,
packageId: "pkg-resume",
url: "https://example.com/resume-file",
provider: "megadebrid-web",
status: "queued",
retries: 2,
speedBps: 0,
downloadedBytes: 8192,
totalBytes: 16384,
progressPercent: 50,
fileName: "resume-file.bin",
targetPath: path.join(outputDir, "resume-file.bin"),
resumable: true,
attempts: 3,
lastError: "",
fullStatus: "Resume-Link erneuern",
resumeLinkRenewalFailures: 4,
resumeHardResetUsed: true,
resumeResetPending: true,
http416FreshRestarts: 2,
createdAt: now,
updatedAt: now
};
saveSession(paths, session);
const loaded = loadSession(paths);
expect(loaded.items[itemId]).toEqual(expect.objectContaining({
downloadedBytes: 8192,
totalBytes: 16384,
resumeLinkRenewalFailures: 4,
resumeHardResetUsed: true,
resumeResetPending: true,
http416FreshRestarts: 2
}));
});
it("returns defaults when config file contains invalid JSON", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir); tempDirs.push(dir);
const paths = createStoragePaths(dir); const paths = createStoragePaths(dir);
+703 -5
View File
@@ -6,18 +6,39 @@ import { afterEach, describe, expect, it } from "vitest";
import { import {
buildSupportBundle, buildSupportBundle,
createSupportBundleExportRunner, createSupportBundleExportRunner,
type SupportBundleExportLifecycleEvent,
writeSupportBundleAtomically writeSupportBundleAtomically
} from "../src/main/support-bundle"; } from "../src/main/support-bundle";
import type { DownloadManager } from "../src/main/download-manager"; import type { DownloadManager } from "../src/main/download-manager";
import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/main/session-log"; import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/main/session-log";
import { initAccountRotationLog, logAccountRotation, shutdownAccountRotationLog } from "../src/main/account-rotation-log"; import { initAccountRotationLog, logAccountRotation, shutdownAccountRotationLog } from "../src/main/account-rotation-log";
import { configureLogger, flushLoggerSync, logger } from "../src/main/logger";
import { ensurePackageLog, initPackageLogs, logPackageEvent, shutdownPackageLogs } from "../src/main/package-log";
import { ensureItemLog, initItemLogs, logItemEvent, shutdownItemLogs } from "../src/main/item-log";
import { initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "../src/main/trace-log";
import {
primeDebridLinkRuntimeCooldownForTests,
primeMegaDebridInFlightForTests,
primeMegaDebridRuntimeCooldownForTests,
resetDebridLinkRuntimeStateForTests,
resetMegaDebridRuntimeStateForTests
} from "../src/main/debrid";
import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
const tempDirs: string[] = []; const tempDirs: string[] = [];
const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join(""); const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join("");
afterEach(() => { afterEach(() => {
shutdownTraceLog();
shutdownItemLogs();
shutdownPackageLogs();
shutdownSessionLog(); shutdownSessionLog();
shutdownAccountRotationLog(); shutdownAccountRotationLog();
resetDebridLinkRuntimeStateForTests();
resetMegaDebridRuntimeStateForTests();
flushLoggerSync();
configureLogger(process.cwd());
for (const dir of tempDirs.splice(0)) { for (const dir of tempDirs.splice(0)) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { } try { fs.rmSync(dir, { recursive: true, force: true }); } catch { }
} }
@@ -149,17 +170,336 @@ describe("buildSupportBundle (async, non-blocking)", () => {
expect(buffer.length).toBeGreaterThan(0); expect(buffer.length).toBeGreaterThan(0);
const entries = new AdmZip(buffer).getEntries().map((e) => e.entryName); const entries = new AdmZip(buffer).getEntries().map((e) => e.entryName);
expect(entries).toContain("overview/meta.json"); expect(entries).toContain("overview/meta.json");
expect(entries).toContain("overview/settings.json"); expect(entries).toContain("overview/settings.json");
expect(entries).toContain("overview/debug-setup.json");
expect(entries).not.toContain("overview/self-check.json");
expect(entries).toContain("runtime/debug_host.txt"); expect(entries).toContain("runtime/debug_host.txt");
expect(entries).toContain("runtime/debug_support_manifest.json"); expect(entries).toContain("runtime/debug_support_manifest.json");
expect(entries).toContain("overview/support-manifest.json"); expect(entries).toContain("overview/support-manifest.json");
expect(entries).not.toContain(`runtime/${legacyManifestFile}`); expect(entries).not.toContain(`runtime/${legacyManifestFile}`);
expect(entries).not.toContain(["overview/", "a", "i-manifest.json"].join("")); expect(entries).not.toContain(["overview/", "a", "i-manifest.json"].join(""));
const hostEntry = new AdmZip(buffer).getEntry("runtime/debug_host.txt"); const hostEntry = new AdmZip(buffer).getEntry("runtime/debug_host.txt");
expect(hostEntry?.getData().toString("utf8")).toBe("host-info-test"); expect(hostEntry?.getData().toString("utf8")).toBe("host-info-test");
}); const meta = JSON.parse(new AdmZip(buffer).getEntry("overview/meta.json")!.getData().toString("utf8"));
expect(meta.limits).toMatchObject({
directoryLogDiscoveryWindowHours: 8,
currentAndRelevantLogsIgnoreAgeFilter: true
});
expect(meta.limits).not.toHaveProperty("logWindowHours");
});
it("replaces overview clear names with stable bundle-local aliases while retaining extension, size, status and correlation", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-aliases-"));
tempDirs.push(root);
fs.writeFileSync(path.join(root, "rd_history.json"), JSON.stringify([{
id: "history-private-id",
name: "Private Linux Collection.iso",
totalBytes: 8_000,
downloadedBytes: 8_000,
fileCount: 1,
provider: "megadebrid-web",
completedAt: 4,
durationSeconds: 5,
status: "completed",
outputDir: "C:\\Private\\History",
urls: ["https://example.invalid/private"]
}]), "utf8");
const snapshot = {
stats: {},
session: {
version: 1,
packageOrder: ["package-private-id"],
packages: {
"package-private-id": {
id: "package-private-id",
name: "Private Series Collection.zip",
outputDir: "C:\\Private\\Output",
extractDir: "C:\\Private\\Extract",
status: "downloading",
itemIds: ["item-private-id"],
cancelled: false,
enabled: true,
cleanedDownloadedBytes: 500,
cleanedTotalBytes: 500,
createdAt: 1,
updatedAt: 2
}
},
items: {
"item-private-id": {
id: "item-private-id",
packageId: "package-private-id",
url: "https://rapidgator.net/file/example",
provider: "megadebrid-web",
status: "downloading",
retries: 0,
speedBps: 100,
downloadedBytes: 250,
totalBytes: 1_000,
progressPercent: 25,
fileName: "Private.Show.S01E01.part1.rar",
targetPath: "C:\\Private\\Output\\Private.Show.S01E01.part1.rar",
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Download läuft",
createdAt: 1,
updatedAt: 2,
onlineStatus: "online"
}
},
runStartedAt: 1,
totalDownloadedBytes: 750,
summaryText: "",
reconnectUntil: 0,
reconnectReason: "",
paused: false,
running: true,
updatedAt: 2
},
speedText: "Geschwindigkeit: 100 B/s",
etaText: "ETA: 1m",
canStart: false,
canStop: true,
canPause: true
};
const manager = {
getSnapshot: () => snapshot,
getPackageLogPath: () => null,
getItemLogPath: () => null
} as unknown as DownloadManager;
const buffer = await buildSupportBundle(manager, root, { hostDiagnosticsMode: "none", debugSetupMode: "deferred" });
const zip = new AdmZip(buffer);
const packages = JSON.parse(zip.getEntry("overview/packages.json")!.getData().toString("utf8"));
const items = JSON.parse(zip.getEntry("overview/items.json")!.getData().toString("utf8"));
const history = JSON.parse(zip.getEntry("overview/history.json")!.getData().toString("utf8"));
const overviewText = [packages, items, history].map((value) => JSON.stringify(value)).join("\n");
expect(packages.packages[0]).toMatchObject({
id: "package-private-id",
name: "package-001.zip",
status: "downloading",
downloadedBytes: 750,
totalBytes: 1_500
});
expect(items.items[0]).toMatchObject({
id: "item-private-id",
packageId: "package-private-id",
fileName: "item-001.rar",
status: "downloading",
downloadedBytes: 250,
totalBytes: 1_000
});
expect(history.entries[0]).toMatchObject({
id: "history-private-id",
name: "history-001.iso",
status: "completed",
downloadedBytes: 8_000,
totalBytes: 8_000
});
expect(overviewText).not.toContain("Private Series Collection.zip");
expect(overviewText).not.toContain("Private.Show.S01E01.part1.rar");
expect(overviewText).not.toContain("Private Linux Collection.iso");
});
it("adds provider runtime diagnostics with pool-local aliases and no internal account or key identifiers", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-provider-runtime-"));
tempDirs.push(root);
const disabledApiAccountId = getMegaDebridAccountId("beta-login");
const disabledDebridKeyId = getDebridLinkApiKeyId("debrid-token-two");
fs.writeFileSync(path.join(root, "rd_downloader_config.json"), JSON.stringify({
megaDebridApiCredentials: "alpha-login:alpha-password\nbeta-login:beta-password",
megaDebridWebCredentials: "gamma-login:gamma-password",
megaDebridApiEnabled: true,
megaDebridWebEnabled: false,
megaDebridApiDisabledAccountIds: [disabledApiAccountId],
debridLinkApiKeys: "debrid-token-one,debrid-token-two",
debridLinkDisabledKeyIds: [disabledDebridKeyId]
}), "utf8");
const apiAccountKey = `${getMegaDebridAccountId("alpha-login")}:api`;
const debridKeyId = getDebridLinkApiKeyId("debrid-token-one");
primeMegaDebridRuntimeCooldownForTests(apiAccountKey, 60_000, "private account cooldown detail");
primeMegaDebridInFlightForTests(apiAccountKey, 2);
primeDebridLinkRuntimeCooldownForTests(debridKeyId, 45_000, "private key cooldown detail");
const buffer = await buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none", debugSetupMode: "deferred" });
const runtime = JSON.parse(new AdmZip(buffer).getEntry("overview/runtime-diagnostics.json")!.getData().toString("utf8"));
const providerText = JSON.stringify(runtime.providerRuntime);
expect(runtime.providerRuntime).toMatchObject({
megaDebrid: {
rotationCursor: 0,
pools: {
api: {
configuredCount: 2,
activeCount: 1,
disabledCount: 1,
inFlight: 2,
accounts: [{
account: "Account 1/2",
inFlight: 2,
cooldown: {
category: "temporary"
}
}]
},
web: {
configuredCount: 1,
activeCount: 0,
enabled: false,
inFlight: 0
}
}
},
debridLink: {
configuredCount: 2,
activeCount: 1,
disabledCount: 1,
keys: [{
account: "Key 1/2",
cooldown: {
category: "temporary"
}
}]
}
});
expect(runtime.providerRuntime.megaDebrid.pools.api.accounts[0].cooldown.remainingMs).toBeGreaterThan(0);
expect(runtime.providerRuntime.debridLink.keys[0].cooldown.remainingMs).toBeGreaterThan(0);
for (const forbidden of [
"alpha-login",
"beta-login",
"gamma-login",
"debrid-token-one",
"debrid-token-two",
getMegaDebridAccountId("alpha-login"),
debridKeyId
]) {
expect(providerText).not.toContain(forbidden);
}
});
it("includes runtime rotation, disk-wait, export-phase and resume-recovery diagnostics", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
tempDirs.push(root);
const snapshot = {
stats: {},
rotationEvents: [{
id: "rotation-1",
at: 1_234,
level: "WARN",
provider: "Mega-Debrid Web",
accountLabel: "Account 2/3 (be***ta)",
event: "FAILED",
reason: "timeout",
next: "Account 3/3 (ga***ma)"
}],
diskWaitEvents: [{
phase: "download",
ownerId: "item-resume",
itemId: "item-resume",
packageId: "package-resume",
volumeKey: "C:",
requiredBytes: 2_048,
availableBytes: 1_024,
deficitBytes: 1_024,
retryAt: 2_000
}],
session: {
version: 1,
packageOrder: ["package-resume"],
packages: {
"package-resume": {
id: "package-resume",
name: "Resume",
status: "queued",
itemIds: ["item-resume"],
cancelled: false,
enabled: true,
createdAt: 1,
updatedAt: 2
}
},
items: {
"item-resume": {
id: "item-resume",
packageId: "package-resume",
url: "https://rapidgator.net/file/example",
provider: "megadebrid-web",
status: "queued",
retries: 2,
speedBps: 0,
downloadedBytes: 1_024,
totalBytes: 2_048,
progressPercent: 50,
fileName: "resume.bin",
targetPath: "C:\\Downloads\\resume.bin",
resumable: true,
attempts: 1,
lastError: "range_ignored_on_resume:1024/2048",
fullStatus: "Warte auf Teildatei-Freigabe",
resumeLinkRenewalFailures: 2,
resumeHardResetUsed: false,
resumeResetPending: true,
createdAt: 1,
updatedAt: 2,
onlineStatus: "online"
}
},
runStartedAt: 1,
totalDownloadedBytes: 1_024,
summaryText: "",
reconnectUntil: 0,
reconnectReason: "",
paused: false,
running: true,
updatedAt: 2
},
speedText: "Geschwindigkeit: 0 B/s",
etaText: "ETA: --",
canStart: false,
canStop: true,
canPause: true
};
const manager = {
getSnapshot: () => snapshot,
getPackageLogPath: () => null,
getItemLogPath: () => null
} as unknown as DownloadManager;
const buffer = await buildSupportBundle(manager, root, { hostDiagnosticsMode: "none", debugSetupMode: "deferred" });
const zip = new AdmZip(buffer);
const runtimeDiagnostics = JSON.parse(zip.getEntry("overview/runtime-diagnostics.json")!.getData().toString("utf8"));
const itemDiagnostics = JSON.parse(zip.getEntry("overview/items.json")!.getData().toString("utf8"));
expect(runtimeDiagnostics).toMatchObject({
bundleBuild: {
state: "building",
hostDiagnosticsMode: "none",
debugSetupMode: "deferred"
},
rotationEvents: [{
provider: "Mega-Debrid Web",
accountLabel: "Account 2/3 (<redacted-account>)",
event: "FAILED",
reason: "timeout"
}],
diskWaitEvents: [{
phase: "download",
itemId: "item-resume",
deficitBytes: 1_024
}]
});
expect(runtimeDiagnostics.bundleBuild.startedAt).toEqual(expect.any(String));
expect(itemDiagnostics.items[0]).toMatchObject({
id: "item-resume",
resumeLinkRenewalFailures: 2,
resumeHardResetUsed: false,
resumeResetPending: true
});
});
it("does not block the event loop while building (a concurrent timer still fires)", async () => { it("does not block the event loop while building (a concurrent timer still fires)", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
@@ -215,6 +555,53 @@ describe("buildSupportBundle (async, non-blocking)", () => {
expect(sessionEntries).toHaveLength(1); expect(sessionEntries).toHaveLength(1);
}); });
it("flushes every pending logger before reading bundle files", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-flush-"));
tempDirs.push(root);
flushLoggerSync();
configureLogger(root);
initSessionLog(root);
initPackageLogs(root);
initItemLogs(root);
initTraceLog(root);
setTraceEnabled(true, "bundle-flush-test", 0);
ensurePackageLog({
packageId: "package-flush",
name: "Flush Package",
outputDir: path.join(root, "output"),
extractDir: path.join(root, "extract")
});
ensureItemLog({
itemId: "item-flush",
packageId: "package-flush",
packageName: "Flush Package",
fileName: "flush.bin",
targetPath: path.join(root, "output", "flush.bin")
});
logger.info("main-buffer-marker");
logPackageEvent("package-flush", "INFO", "package-buffer-marker");
logItemEvent("item-flush", "INFO", "item-buffer-marker");
logTraceEvent("INFO", "support", "trace-buffer-marker");
const buffer = await buildSupportBundle(fakeManager(), root, {
hostDiagnosticsMode: "none",
debugSetupMode: "deferred"
});
const zip = new AdmZip(buffer);
const packageEntry = zip.getEntries().find((entry) => entry.entryName.startsWith("logs/package-logs/"));
const itemEntry = zip.getEntries().find((entry) => entry.entryName.startsWith("logs/item-logs/"));
const entryNames = zip.getEntries().map((entry) => entry.entryName);
expect(zip.getEntry("logs/rd_downloader.log")?.getData().toString("utf8") || "").toContain("main-buffer-marker");
expect(zip.getEntry("logs/session.log")?.getData().toString("utf8") || "").toContain("main-buffer-marker");
expect(zip.getEntry("logs/trace.log")?.getData().toString("utf8") || "").toContain("trace-buffer-marker");
expect(packageEntry, entryNames.join("\n")).toBeDefined();
expect(itemEntry, entryNames.join("\n")).toBeDefined();
expect(packageEntry?.getData().toString("utf8") || "").toContain("package-buffer-marker");
expect(itemEntry?.getData().toString("utf8") || "").toContain("item-buffer-marker");
});
it("bounds recent item logs to the newest diagnostic files", async () => { it("bounds recent item logs to the newest diagnostic files", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
tempDirs.push(root); tempDirs.push(root);
@@ -240,6 +627,113 @@ describe("buildSupportBundle (async, non-blocking)", () => {
expect(itemEntries).toContain("logs/item-logs/item-364.txt"); expect(itemEntries).toContain("logs/item-logs/item-364.txt");
}); });
it("prioritizes active package and item logs beyond the bounded directory scan", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-priority-"));
tempDirs.push(root);
const packageLogs = path.join(root, "package-logs");
const itemLogs = path.join(root, "item-logs");
fs.mkdirSync(packageLogs, { recursive: true });
fs.mkdirSync(itemLogs, { recursive: true });
for (let index = 0; index < 2_048; index += 1) {
const name = `${String(index).padStart(4, "0")}-filler.log`;
fs.writeFileSync(path.join(packageLogs, name), "package filler", "utf8");
fs.writeFileSync(path.join(itemLogs, name), "item filler", "utf8");
}
initPackageLogs(root);
initItemLogs(root);
ensurePackageLog({
packageId: "zzzz-active-package",
name: "Active Package",
outputDir: path.join(root, "output"),
extractDir: path.join(root, "extract")
});
ensureItemLog({
itemId: "zzzz-active-item",
packageId: "zzzz-active-package",
packageName: "Active Package",
fileName: "active.bin",
targetPath: path.join(root, "output", "active.bin")
});
logPackageEvent("zzzz-active-package", "INFO", "active-package-marker");
logItemEvent("zzzz-active-item", "INFO", "active-item-marker");
const snapshot = {
stats: {},
session: {
version: 1,
packageOrder: ["zzzz-active-package"],
packages: {
"zzzz-active-package": {
id: "zzzz-active-package",
name: "Active Package",
status: "downloading",
itemIds: ["zzzz-active-item"],
cancelled: false,
enabled: true,
createdAt: 1,
updatedAt: 2
}
},
items: {
"zzzz-active-item": {
id: "zzzz-active-item",
packageId: "zzzz-active-package",
url: "https://files.example.test/active",
status: "downloading",
retries: 0,
speedBps: 1,
downloadedBytes: 1,
totalBytes: 2,
progressPercent: 50,
fileName: "active.bin",
targetPath: path.join(root, "output", "active.bin"),
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Lädt",
createdAt: 1,
updatedAt: 2,
onlineStatus: "online"
}
},
runStartedAt: 1,
totalDownloadedBytes: 1,
summaryText: "",
reconnectUntil: 0,
reconnectReason: "",
paused: false,
running: true,
updatedAt: 2
},
speedText: "Geschwindigkeit: 1 B/s",
etaText: "ETA: 1s",
canStart: false,
canStop: true,
canPause: true
};
const manager = {
getSnapshot: () => snapshot,
getPackageLogPath: () => { throw new Error("bundle export must not create package logs"); },
getItemLogPath: () => { throw new Error("bundle export must not create item logs"); }
} as unknown as DownloadManager;
const buffer = await buildSupportBundle(manager, root, {
hostDiagnosticsMode: "none",
debugSetupMode: "deferred"
});
const zip = new AdmZip(buffer);
const packageEntries = zip.getEntries().filter((entry) => entry.entryName.startsWith("logs/package-logs/"));
const itemEntries = zip.getEntries().filter((entry) => entry.entryName.startsWith("logs/item-logs/"));
const packageText = packageEntries.map((entry) => entry.getData().toString("utf8")).join("\n");
const itemText = itemEntries.map((entry) => entry.getData().toString("utf8")).join("\n");
expect(packageText).toContain("active-package-marker");
expect(itemText).toContain("active-item-marker");
expect(packageEntries.length).toBeLessThanOrEqual(8);
expect(itemEntries.length).toBeLessThanOrEqual(16);
}, 15_000);
it("redacts active DTOs, runtime text and logs at the ZIP boundary", async () => { it("redacts active DTOs, runtime text and logs at the ZIP boundary", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-sensitive-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-sensitive-"));
tempDirs.push(root); tempDirs.push(root);
@@ -344,6 +838,91 @@ describe("buildSupportBundle (async, non-blocking)", () => {
expect(itemOverview.items?.[0]).not.toHaveProperty("url"); expect(itemOverview.items?.[0]).not.toHaveProperty("url");
}); });
it("redacts slash-escaped URLs at the ZIP boundary after credentials change", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-escaped-url-"));
tempDirs.push(root);
const itemLogs = path.join(root, "item-logs");
fs.mkdirSync(itemLogs, { recursive: true });
fs.writeFileSync(
path.join(itemLogs, "escaped-url.log"),
String.raw`{"url":"https:\/\/legacy-user:p!7@escaped-private-host.invalid\/secret?value=private"}`,
"utf8"
);
const buffer = await buildSupportBundle(fakeManager(), root, {
hostDiagnosticsMode: "none",
debugSetupMode: "deferred"
});
const text = new AdmZip(buffer).getEntry("logs/item-logs/escaped-url.log")?.getData().toString("utf8") || "";
expect(text).toContain("<redacted-url>");
expect(text).not.toContain("legacy-user");
expect(text).not.toContain("p!7");
expect(text).not.toContain("escaped-private-host.invalid");
});
it("redacts historical comma-style account labels at the ZIP boundary", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-account-label-"));
tempDirs.push(root);
const itemLogs = path.join(root, "item-logs");
fs.mkdirSync(itemLogs, { recursive: true });
fs.writeFileSync(path.join(itemLogs, "historical-labels.log"), [
"Mega-Debrid (Account 1/3, Hi*******cal): uebersprungen",
"Debrid-Link (Key 2/4, old********value): fehlgeschlagen"
].join("\n"), "utf8");
const buffer = await buildSupportBundle(fakeManager(), root, {
hostDiagnosticsMode: "none",
debugSetupMode: "deferred"
});
const text = new AdmZip(buffer).getEntry("logs/item-logs/historical-labels.log")?.getData().toString("utf8") || "";
expect(text).toContain("Account 1/3, <redacted-account>");
expect(text).toContain("Key 2/4, <redacted-account>");
expect(text).not.toContain("Hi*******cal");
expect(text).not.toContain("old********value");
});
it("keeps static archive directories stable when a short credential matches their name", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-static-path-"));
tempDirs.push(root);
fs.writeFileSync(path.join(root, "rd_downloader_config.json"), JSON.stringify({
megaDebridWebCredentials: "archive-user:logs"
}), "utf8");
const itemLogs = path.join(root, "item-logs");
fs.mkdirSync(itemLogs, { recursive: true });
fs.writeFileSync(path.join(itemLogs, "recent.log"), "diagnostic", "utf8");
const buffer = await buildSupportBundle(fakeManager(), root, {
hostDiagnosticsMode: "none",
debugSetupMode: "deferred"
});
const entries = new AdmZip(buffer).getEntries().map((entry) => entry.entryName);
expect(entries).toContain("logs/item-logs/recent.log");
});
it("keeps separately redacted log filenames distinct", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-archive-names-"));
tempDirs.push(root);
const itemLogs = path.join(root, "item-logs");
fs.mkdirSync(itemLogs, { recursive: true });
fs.writeFileSync(path.join(itemLogs, "abcdefghijklmnopqrstuvwxyz1234567890-one.log"), "first-log-marker", "utf8");
fs.writeFileSync(path.join(itemLogs, "abcdefghijklmnopqrstuvwxyz1234567890-two.log"), "second-log-marker", "utf8");
const buffer = await buildSupportBundle(fakeManager(), root, {
hostDiagnosticsMode: "none",
debugSetupMode: "deferred"
});
const entries = new AdmZip(buffer).getEntries().filter((entry) => entry.entryName.startsWith("logs/item-logs/"));
const text = entries.map((entry) => entry.getData().toString("utf8")).join("\n");
expect(entries).toHaveLength(2);
expect(new Set(entries.map((entry) => entry.entryName)).size).toBe(2);
expect(text).toContain("first-log-marker");
expect(text).toContain("second-log-marker");
});
it("bounds active DTOs and recent log tails while keeping the event loop responsive", async () => { it("bounds active DTOs and recent log tails while keeping the event loop responsive", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-load-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-load-"));
tempDirs.push(root); tempDirs.push(root);
@@ -394,6 +973,99 @@ describe("buildSupportBundle (async, non-blocking)", () => {
}); });
describe("support bundle export runner", () => { describe("support bundle export runner", () => {
it("emits busy, cancel, build, write and success lifecycle phases with deterministic durations", async () => {
let now = 0;
let releaseBuild: (buffer: Buffer) => void = () => undefined;
let signalBuildStarted: () => void = () => undefined;
const buildStarted = new Promise<void>((resolve) => { signalBuildStarted = resolve; });
const buildPending = new Promise<Buffer>((resolve) => { releaseBuild = resolve; });
const lifecycle: SupportBundleExportLifecycleEvent[] = [];
let chooseCount = 0;
const run = createSupportBundleExportRunner({
now: () => now,
chooseFile: async () => {
chooseCount += 1;
now += 5;
return chooseCount === 1 ? "C:\\Private\\support.zip" : null;
},
build: async () => {
signalBuildStarted();
const buffer = await buildPending;
now += 20;
return buffer;
},
write: async () => {
now += 30;
},
onLifecycle: (event) => {
lifecycle.push(event);
}
});
const first = run();
await buildStarted;
await expect(run()).resolves.toMatchObject({ saved: false, busy: true });
releaseBuild(Buffer.from("zip"));
await expect(first).resolves.toEqual({ saved: true, busy: false, filePath: "C:\\Private\\support.zip" });
await expect(run()).resolves.toEqual({ saved: false, busy: false });
expect(lifecycle).toEqual([
{ phase: "busy", durationMs: 0, totalDurationMs: 0 },
{ phase: "build", durationMs: 20, totalDurationMs: 25, bytes: 3 },
{ phase: "write", durationMs: 30, totalDurationMs: 55, bytes: 3 },
{ phase: "success", durationMs: 55, totalDurationMs: 55, bytes: 3 },
{ phase: "cancel", durationMs: 5, totalDurationMs: 5 }
]);
});
it("reports a path-free failure phase and duration when writing fails", async () => {
let now = 100;
const lifecycle: SupportBundleExportLifecycleEvent[] = [];
const failures: unknown[] = [];
const target = "C:\\Users\\Alice\\Desktop\\private-support.zip";
const run = createSupportBundleExportRunner({
now: () => now,
chooseFile: async () => {
now += 4;
return target;
},
build: async () => {
now += 6;
return Buffer.from("zip");
},
write: async () => {
now += 9;
throw Object.assign(new Error(`ENOSPC while writing ${target}`), { code: "ENOSPC" });
},
onLifecycle: (event) => {
lifecycle.push(event);
},
onFailure: (error) => {
failures.push(error);
}
});
await expect(run()).rejects.toMatchObject({
name: "SupportBundleExportError",
phase: "write",
durationMs: 19,
code: "ENOSPC"
});
expect(lifecycle).toEqual([
{ phase: "build", durationMs: 6, totalDurationMs: 10, bytes: 3 },
{
phase: "failure",
failedPhase: "write",
durationMs: 9,
totalDurationMs: 19,
code: "ENOSPC"
}
]);
expect(failures).toHaveLength(1);
expect(String((failures[0] as Error).message)).not.toContain(target);
expect(String((failures[0] as Error).message)).not.toContain("Alice");
});
it("returns a visible busy result for reentry without choosing another target", async () => { it("returns a visible busy result for reentry without choosing another target", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-export-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-export-"));
tempDirs.push(root); tempDirs.push(root);
@@ -430,6 +1102,32 @@ describe("support bundle export runner", () => {
await expect(first).resolves.toEqual({ saved: true, busy: false, filePath: target }); await expect(first).resolves.toEqual({ saved: true, busy: false, filePath: target });
}); });
it("records the selected export target before bundle construction starts", async () => {
const phases: string[] = [];
const run = createSupportBundleExportRunner({
chooseFile: async () => {
phases.push("choose");
return "C:\\Temp\\support.zip";
},
onStart: ({ filePath }) => {
phases.push(`start:${path.basename(filePath)}`);
},
build: async () => {
phases.push("build");
return Buffer.from("zip");
},
write: async () => {
phases.push("write");
},
onSuccess: () => {
phases.push("success");
}
});
await expect(run()).resolves.toEqual({ saved: true, busy: false, filePath: "C:\\Temp\\support.zip" });
expect(phases).toEqual(["choose", "start:support.zip", "build", "write", "success"]);
});
it("reports success only after the target write has completed", async () => { it("reports success only after the target write has completed", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-export-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-export-"));
tempDirs.push(root); tempDirs.push(root);
@@ -476,7 +1174,7 @@ describe("support bundle export runner", () => {
} }
}); });
await expect(run()).rejects.toThrow("write failed"); await expect(run()).rejects.toMatchObject({ name: "SupportBundleExportError", phase: "write" });
await expect(run()).resolves.toEqual({ saved: true, busy: false, filePath: target }); await expect(run()).resolves.toEqual({ saved: true, busy: false, filePath: target });
}); });
+31 -3
View File
@@ -17,7 +17,7 @@ afterEach(() => {
}); });
describe("trace-log", () => { describe("trace-log", () => {
it("captures main log lines and explicit trace events when enabled", async () => { it("captures main log lines and explicit trace events when enabled", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-tlog-")); const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-tlog-"));
tempDirs.push(baseDir); tempDirs.push(baseDir);
@@ -49,8 +49,36 @@ describe("trace-log", () => {
const traceConfig = getTraceConfig(); const traceConfig = getTraceConfig();
expect(traceConfig.enabled).toBe(true); expect(traceConfig.enabled).toBe(true);
expect(traceConfig.autoDisableAt).toBeTruthy(); expect(traceConfig.autoDisableAt).toBeTruthy();
expect(JSON.parse(fs.readFileSync(traceConfigPath!, "utf8")).enabled).toBe(true); expect(JSON.parse(fs.readFileSync(traceConfigPath!, "utf8")).enabled).toBe(true);
}); });
it("redacts sensitive messages and fields before writing the trace log", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-tlog-redaction-"));
tempDirs.push(baseDir);
configureLogger(baseDir);
initTraceLog(baseDir);
setTraceEnabled(true, "redaction-test");
logTraceEvent("WARN", "download", "Failed https://rapidgator.net/file/private at C:\\Users\\Admin\\Desktop\\private.rar", {
directUrl: "https://cdn.example/private?token=secret",
targetPath: "C:\\Users\\Admin\\Desktop\\private.rar",
email: "user@example.com",
password: "hunter2"
});
await new Promise((resolve) => setTimeout(resolve, 350));
const content = fs.readFileSync(getTraceLogPath()!, "utf8");
expect(content).toContain("rapidgator.net#");
expect(content).toContain("cdn.example#");
expect(content).toContain("<redacted-path>");
expect(content).toContain("<redacted-account>");
expect(content).toContain("<redacted>");
expect(content).not.toContain("/file/private");
expect(content).not.toContain("C:\\Users\\Admin");
expect(content).not.toContain("user@example.com");
expect(content).not.toContain("hunter2");
});
it("auto-disables support trace after the requested duration", async () => { it("auto-disables support trace after the requested duration", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-tlog-expire-")); const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-tlog-expire-"));