Harden type safety and recovery guards
This commit is contained in:
parent
f6558470c3
commit
17e947fc6b
@ -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);
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user