Harden type safety and recovery guards

This commit is contained in:
Sucukdeluxe 2026-03-10 05:54:19 +01:00
parent f6558470c3
commit 17e947fc6b
8 changed files with 77 additions and 117 deletions

View File

@ -32,7 +32,7 @@ import { getItemLogPath, initItemLogs, shutdownItemLogs } from "./item-log";
import { getPackageLogPath, initPackageLogs, shutdownPackageLogs } from "./package-log";
import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log";
import { MegaWebFallback } from "./mega-web-fallback";
import { addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistoryForRetention, loadSession, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage";
import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistoryForRetention, loadSession, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage";
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server";
import { encryptBackup, decryptBackup } from "./backup-crypto";
@ -587,6 +587,8 @@ export class AppController {
// Restore settings — ALL credentials are included (no more masking)
const importedSettings = parsed.settings as AppSettings;
const importedSettingsRecord = importedSettings as unknown as Record<string, unknown>;
const currentSettingsRecord = this.settings as unknown as Record<string, unknown>;
// Legacy backup compatibility: if credentials were masked with ***, keep current values
const SENSITIVE_KEYS: (keyof AppSettings)[] = [
"token", "megaLogin", "megaPassword", "bestToken", "allDebridToken",
@ -594,9 +596,9 @@ export class AppController {
"debridLinkApiKeys", "linkSnappyLogin", "linkSnappyPassword"
];
for (const key of SENSITIVE_KEYS) {
const val = (importedSettings as Record<string, unknown>)[key];
const val = importedSettingsRecord[key];
if (typeof val === "string" && val.startsWith("***")) {
(importedSettings as Record<string, unknown>)[key] = (this.settings as Record<string, unknown>)[key];
importedSettingsRecord[key] = currentSettingsRecord[key];
}
}
const restoredSettings = normalizeSettings(importedSettings);

View File

@ -580,7 +580,7 @@ async function fetchDebridLinkHostLimitForKey(apiKey: { id: string; label: strin
throw new Error(String(lastError || `Debrid-Link Limits für ${apiKey.label} fehlgeschlagen`).replace(/^Error:\s*/i, ""));
}
function uniqueProviderOrder(order: DebridProvider[]): DebridProvider[] {
function uniqueProviderOrder(order: readonly DebridProvider[]): DebridProvider[] {
const seen = new Set<DebridProvider>();
const result: DebridProvider[] = [];
for (const provider of order) {
@ -1668,94 +1668,6 @@ class DebridLinkClient {
: "";
logger.warn(`Debrid-Link${keyLabel}: ${failure.message}${cooldownInfo}, pruefe naechsten Key`);
}
continue;
let lastError = "";
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
if (signal?.aborted) throw new Error("aborted:debrid");
try {
const res = await fetch(`${DEBRID_LINK_API_BASE}/downloader/add`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Bearer ${apiKey.token}`
},
body: `url=${encodeURIComponent(link)}`,
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
});
const json = await res.json() as Record<string, unknown>;
if (!json.success) {
const errorCode = String(json.error || "");
const errorDesc = String(json.error_description || json.error || "Unbekannter Debrid-Link-Fehler");
if (DEBRID_LINK_QUOTA_ERRORS.has(errorCode)) {
logger.warn(`Debrid-Link${keyLabel}: API-Quota erreicht (${errorCode}: ${errorDesc}), wechsle zum naechsten Key`);
debridLinkKeyCooldowns.set(apiKey.id, Date.now() + DEBRID_LINK_KEY_COOLDOWN_MS);
break;
}
if (DEBRID_LINK_SKIP_KEY_ERRORS.has(errorCode)) {
logger.warn(`Debrid-Link${keyLabel}: Key kann Link nicht verarbeiten (${errorCode}: ${errorDesc}), wechsle zum naechsten Key`);
debridLinkKeyCooldowns.set(apiKey.id, Date.now() + DEBRID_LINK_KEY_COOLDOWN_MS);
break;
}
if (errorCode === "badToken" || errorCode === "expired_token") {
throw new Error(`Debrid-Link${keyLabel}: Ungültiger oder abgelaufener API-Key`);
}
if (errorCode === "floodDetected") {
await sleep(retryDelay(attempt), signal);
continue;
}
throw new Error(`Debrid-Link${keyLabel}: ${errorDesc}`);
}
const value = json.value as Record<string, unknown> | undefined;
if (!value) {
throw new Error(`Debrid-Link${keyLabel}: Keine Daten in Antwort`);
}
const directUrl = String(value.downloadUrl || "");
if (!directUrl) {
throw new Error(`Debrid-Link${keyLabel}: Keine Download-URL in Antwort`);
}
const fileName = String(value.name || "") || filenameFromUrl(directUrl) || filenameFromUrl(link);
const fileSize = typeof value.size === "number" && value.size > 0 ? value.size : null;
logger.info(`Debrid-Link${keyLabel}: Unrestrict OK → ${fileName || "?"}`);
return {
fileName,
directUrl,
fileSize,
retriesUsed: attempt - 1,
sourceLabel: apiKey.label,
sourceAccountId: apiKey.id,
sourceAccountLabel: apiKey.label
};
} catch (error) {
lastError = compactErrorText(error);
if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) {
throw error;
}
if (/Ungültig|abgelaufen/i.test(lastError)) {
throw error;
}
logger.warn(`Debrid-Link${keyLabel}: Fehler bei Unrestrict-Versuch ${attempt}/${REQUEST_RETRIES}: ${lastError}`);
if (attempt < REQUEST_RETRIES) {
await sleep(retryDelay(attempt), signal);
}
}
}
if (keyIdx + 1 < this.apiKeys.length) {
const nextKey = this.apiKeys[keyIdx + 1];
const nextKeyLabel = this.apiKeys.length > 1 ? ` (${nextKey.label})` : "";
logger.info(`Debrid-Link${keyLabel}: kein Erfolg, wechsle zu naechstem Key${nextKeyLabel}`);
}
}
if (!usableKeySeen) {
@ -2299,7 +2211,7 @@ class DdownloadClient {
if (!xfss) {
throw new Error("DDownload Login fehlgeschlagen (kein Session-Cookie)");
}
this.cookies = [loginCookie, xfss].filter(Boolean).map((c: string) => c.split(";")[0]).join("; ");
this.cookies = [loginCookie, xfss].filter((c): c is string => Boolean(c)).map((c) => c.split(";")[0]).join("; ");
}
public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {

View File

@ -6,6 +6,7 @@ import { v4 as uuidv4 } from "uuid";
import {
AllDebridHostInfo,
AppSettings,
DebridProvider,
DownloadItem,
DownloadStats,
DownloadSummary,
@ -2662,7 +2663,9 @@ export class DownloadManager extends EventEmitter {
// Reuse result if same URL was already checked
if (checkedUrls.has(url)) {
const cached = checkedUrls.get(url);
if (cached !== undefined) {
this.applyRapidgatorCheckResult(item, cached);
}
this.emitState();
continue;
}
@ -6219,8 +6222,8 @@ export class DownloadManager extends EventEmitter {
}
private getPackageHistoryDurationSeconds(pkg: PackageEntry): number {
const startedAt = pkg.downloadStartedAt > 0 ? pkg.downloadStartedAt : pkg.createdAt;
const finishedAtCandidate = pkg.downloadCompletedAt > 0 ? pkg.downloadCompletedAt : nowMs();
const startedAt = (pkg.downloadStartedAt || 0) > 0 ? (pkg.downloadStartedAt || 0) : pkg.createdAt;
const finishedAtCandidate = (pkg.downloadCompletedAt || 0) > 0 ? (pkg.downloadCompletedAt || 0) : nowMs();
const finishedAt = Math.max(startedAt || 0, finishedAtCandidate || 0);
if (startedAt <= 0 || finishedAt <= 0) {
return 1;
@ -6458,7 +6461,7 @@ export class DownloadManager extends EventEmitter {
private getProviderOrder(): DebridProvider[] {
if (this.settings.providerOrder && this.settings.providerOrder.length > 0) {
return this.settings.providerOrder;
return [...this.settings.providerOrder];
}
return [
this.settings.providerPrimary,
@ -7195,7 +7198,7 @@ export class DownloadManager extends EventEmitter {
errorText: string,
claimedTargetPath: string
): void {
active.genericErrorRetries += 1;
active.genericErrorRetries = Number(active.genericErrorRetries || 0) + 1;
item.retries += 1;
if (claimedTargetPath) {
try {
@ -7660,6 +7663,7 @@ export class DownloadManager extends EventEmitter {
// Persist retry counters so shelve logic survives reconnect interruption
this.retryStateByItem.set(item.id, {
freshRetryUsed: Boolean(active.freshRetryUsed),
resumeHardResetUsed: Boolean(active.resumeHardResetUsed),
stallRetries: Number(active.stallRetries || 0),
genericErrorRetries: Number(active.genericErrorRetries || 0),
unrestrictRetries: Number(active.unrestrictRetries || 0)
@ -7676,6 +7680,7 @@ export class DownloadManager extends EventEmitter {
item.fullStatus = "Paket gestoppt";
this.retryStateByItem.set(item.id, {
freshRetryUsed: Boolean(active.freshRetryUsed),
resumeHardResetUsed: Boolean(active.resumeHardResetUsed),
stallRetries: Number(active.stallRetries || 0),
genericErrorRetries: Number(active.genericErrorRetries || 0),
unrestrictRetries: Number(active.unrestrictRetries || 0)
@ -9389,8 +9394,9 @@ export class DownloadManager extends EventEmitter {
const stat = await fs.promises.stat(part);
// Find the item that owns this file to get its expected totalBytes
const ownerItem = this.findItemByDiskPath(pkg, part);
const minBytes = ownerItem?.totalBytes && ownerItem.totalBytes > 0
? ownerItem.totalBytes - ALLOCATION_UNIT_SIZE
const ownerTotalBytes = ownerItem?.totalBytes ?? 0;
const minBytes = ownerTotalBytes > 0
? ownerTotalBytes - ALLOCATION_UNIT_SIZE
: 10240;
if (stat.size < minBytes) {
allMissingFullOnDisk = false;
@ -9933,8 +9939,9 @@ export class DownloadManager extends EventEmitter {
: 10240;
if (stat.size >= minSize) {
// Re-check: another task may have started this item during the await
if (this.activeTasks.has(item.id) || item.status === "downloading"
|| item.status === "validating" || item.status === "integrity_check") {
const latestItem = this.session.items[item.id];
if (!latestItem || this.activeTasks.has(item.id) || latestItem.status === "downloading"
|| latestItem.status === "validating" || latestItem.status === "integrity_check") {
continue;
}
// Guard against pre-allocated sparse files from a hard crash: file has

View File

@ -267,7 +267,7 @@ export function updateTraceConfig(patch: Partial<SupportTraceConfig>): SupportTr
});
persistTraceConfig();
scheduleAutoDisable();
appendTraceLine(`${new Date().toISOString()} [INFO] [trace] Konfiguration aktualisiert${formatFields(traceConfig)}\n`);
appendTraceLine(`${new Date().toISOString()} [INFO] [trace] Konfiguration aktualisiert${formatFields(traceConfig as unknown as Record<string, unknown>)}\n`);
return getTraceConfig();
}

View File

@ -271,8 +271,26 @@ export function nowMs(): number {
return Date.now();
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error(String(signal.reason || "aborted")));
return;
}
const timer = setTimeout(() => {
cleanup();
resolve();
}, ms);
const onAbort = (): void => {
clearTimeout(timer);
cleanup();
reject(new Error(String(signal?.reason || "aborted")));
};
const cleanup = (): void => {
signal?.removeEventListener("abort", onAbort);
};
signal?.addEventListener("abort", onAbort, { once: true });
});
}
export function formatEta(seconds: number): string {

View File

@ -1,4 +1,4 @@
import { CSSProperties, DragEvent, KeyboardEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { CSSProperties, DragEvent, KeyboardEvent as ReactKeyboardEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
import type {
AllDebridHostInfo,
@ -761,6 +761,7 @@ function validateAccountDialog(dialog: AccountDialogState): string | null {
const emptyStats = (): DownloadStats => ({
totalDownloaded: 0,
totalDownloadedAllTime: 0,
totalFiles: 0,
totalFilesSession: 0,
totalFilesAllTime: 0,
totalPackages: 0,
@ -771,6 +772,24 @@ const emptyStats = (): DownloadStats => ({
runtimeMeasuredAt: 0
});
type StatsSectionItem = {
key: string;
eyebrow: string;
label: string;
value: string;
compactValue?: boolean;
danger?: boolean;
clickable?: boolean;
title?: string;
onClick?: () => void;
};
type StatsSection = {
key: string;
title: string;
items: StatsSectionItem[];
};
const emptySnapshot = (): UiSnapshot => ({
settings: {
token: "", realDebridUseWebLogin: false, megaLogin: "", megaPassword: "", megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, bestToken: "", bestDebridUseWebLogin: false, allDebridToken: "", allDebridUseWebLogin: false, ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "", debridLinkApiKeys: "", linkSnappyLogin: "", linkSnappyPassword: "",
@ -3204,6 +3223,7 @@ export function App(): ReactElement {
});
void window.rd.togglePackage(packageId).catch((error) => {
if (previousEnabled !== null) {
const revertedEnabled = previousEnabled;
setSnapshot((prev) => {
const pkg = prev.session.packages[packageId];
if (!pkg) {
@ -3217,8 +3237,8 @@ export function App(): ReactElement {
...prev.session.packages,
[packageId]: {
...pkg,
enabled: previousEnabled,
status: previousEnabled && pkg.status === "paused" ? "queued" : pkg.status,
enabled: revertedEnabled,
status: revertedEnabled && pkg.status === "paused" ? "queued" : pkg.status,
updatedAt: Date.now()
}
},
@ -3554,7 +3574,7 @@ export function App(): ReactElement {
}, [selectedIds, settingsDraft.confirmDeleteSelection, executeDeleteSelection]);
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
const onKey = (e: globalThis.KeyboardEvent): void => {
if (e.key === "Escape") {
const target = e.target as HTMLElement;
if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA") {
@ -3801,7 +3821,7 @@ export function App(): ReactElement {
.map((it) => it.id);
void window.rd.resetItems(failedIds).catch(() => {});
};
const statsSections = [
const statsSections: StatsSection[] = [
{
key: "live",
title: "Aktuell",
@ -3835,7 +3855,7 @@ export function App(): ReactElement {
{ key: "files-total", eyebrow: "Gesamt", label: "Fertige Dateien", value: String(snapshot.stats.totalFilesAllTime) }
]
}
] as const;
];
return (
<div
@ -5955,7 +5975,7 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, stripe
const exProgress = Math.min(50, Math.floor(((extracted + extractingProgress) / total) * 50));
const combinedProgress = Math.min(100, useExtractSplit ? dlProgress + exProgress : dlProgress);
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
const onKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>): void => {
if (e.key === "Enter") { onFinishEdit(pkg.id, pkg.name, editingName); }
if (e.key === "Escape") { onFinishEdit(pkg.id, pkg.name, pkg.name); }
};
@ -6093,7 +6113,7 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, stripe
);
case "progress": return (
<span key={col} className="pkg-col pkg-col-progress">
{item.totalBytes > 0 ? (
{(item.totalBytes || 0) > 0 ? (
<span className="progress-inline progress-inline-small">
<span className="progress-inline-bar" style={{ width: `${item.progressPercent}%` }} />
<span className="progress-inline-text">{item.progressPercent}%</span>

View File

@ -42,7 +42,7 @@ export interface BandwidthScheduleEntry {
export interface DownloadStats {
totalDownloaded: number;
totalDownloadedAllTime: number;
totalFiles: number;
totalFiles?: number;
totalFilesSession: number;
totalFilesAllTime: number;
totalPackages: number;
@ -74,7 +74,7 @@ export interface AppSettings {
linkSnappyPassword: string;
archivePasswordList: string;
rememberToken: boolean;
providerOrder: DebridProvider[];
providerOrder: readonly DebridProvider[];
providerPrimary: DebridProvider;
providerSecondary: DebridFallbackProvider;
providerTertiary: DebridFallbackProvider;
@ -168,7 +168,7 @@ export interface PackageEntry {
itemIds: string[];
cancelled: boolean;
enabled: boolean;
priority: PackagePriority;
priority?: PackagePriority;
postProcessLabel?: string;
downloadStartedAt?: number;
downloadCompletedAt?: number;

View File

@ -14,6 +14,7 @@ import { getItemLogPath, initItemLogs, shutdownItemLogs } from "../src/main/item
import { createStoragePaths, emptySession } from "../src/main/storage";
import { primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests } from "../src/main/debrid";
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log";
import { UnrestrictedLink } from "../src/main/realdebrid";
const tempDirs: string[] = [];
const originalFetch = globalThis.fetch;
@ -5694,7 +5695,7 @@ describe("download manager", () => {
{
megaWebUnrestrict: vi.fn(async (_link: string, signal?: AbortSignal) => {
unrestrictCalls += 1;
return await new Promise((resolve, reject) => {
return await new Promise<UnrestrictedLink | null>((resolve, reject) => {
const rejector = (error: Error): void => {
signal?.removeEventListener("abort", onAbort);
pendingRejectors.delete(rejector);