Release v1.4.23 with critical bug audit fixes
Build and Release / build (push) Has been cancelled

This commit is contained in:
Sucukdeluxe
2026-02-28 12:16:08 +01:00
parent f70237f13d
commit 9598fca34e
17 changed files with 723 additions and 78 deletions
+34 -14
View File
@@ -141,15 +141,42 @@ export function removeSampleArtifacts(extractDir: string): { files: number; dirs
let removedFiles = 0;
let removedDirs = 0;
const allDirs: string[] = [];
const sampleDirs: string[] = [];
const stack = [extractDir];
const countFilesRecursive = (rootDir: string): number => {
let count = 0;
const dirs = [rootDir];
while (dirs.length > 0) {
const current = dirs.pop() as string;
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
dirs.push(full);
} else if (entry.isFile()) {
count += 1;
}
}
}
return count;
};
while (stack.length > 0) {
const current = stack.pop() as string;
allDirs.push(current);
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
const base = entry.name.toLowerCase();
if (SAMPLE_DIR_NAMES.has(base)) {
sampleDirs.push(full);
continue;
}
stack.push(full);
continue;
}
@@ -157,13 +184,11 @@ export function removeSampleArtifacts(extractDir: string): { files: number; dirs
continue;
}
const parent = path.basename(path.dirname(full)).toLowerCase();
const stem = path.parse(entry.name).name.toLowerCase();
const ext = path.extname(entry.name).toLowerCase();
const inSampleDir = SAMPLE_DIR_NAMES.has(parent);
const isSampleVideo = SAMPLE_VIDEO_EXTENSIONS.has(ext) && SAMPLE_TOKEN_RE.test(stem);
if (inSampleDir || isSampleVideo) {
if (isSampleVideo) {
try {
fs.rmSync(full, { force: true });
removedFiles += 1;
@@ -174,17 +199,12 @@ export function removeSampleArtifacts(extractDir: string): { files: number; dirs
}
}
allDirs.sort((a, b) => b.length - a.length);
for (const dir of allDirs) {
if (dir === extractDir) {
continue;
}
const base = path.basename(dir).toLowerCase();
if (!SAMPLE_DIR_NAMES.has(base)) {
continue;
}
sampleDirs.sort((a, b) => b.length - a.length);
for (const dir of sampleDirs) {
try {
const filesInDir = countFilesRecursive(dir);
fs.rmSync(dir, { recursive: true, force: true });
removedFiles += filesInDir;
removedDirs += 1;
} catch {
// ignore
+31 -6
View File
@@ -320,6 +320,9 @@ class MegaDebridClient {
web.retriesUsed = attempt - 1;
return web;
}
if (!lastError) {
lastError = web ? "Mega-Web Antwort ohne Download-Link" : "Mega-Web Antwort leer";
}
if (attempt < REQUEST_RETRIES) {
await sleep(retryDelay(attempt));
}
@@ -358,7 +361,7 @@ class BestDebridClient {
"User-Agent": "RD-Node-Downloader/1.1.12"
};
if (request.useAuthHeader) {
headers.Authorization = this.token;
headers.Authorization = `Bearer ${this.token}`;
}
const response = await fetch(request.url, {
@@ -478,7 +481,7 @@ class AllDebridClient {
const responseLink = pickString(info, ["link"]);
const byResponse = canonicalToInput.get(canonicalLink(responseLink));
const byIndex = chunk[i] || "";
const byIndex = chunk.length === 1 ? chunk[0] : "";
const original = byResponse || byIndex;
if (!original) {
continue;
@@ -609,7 +612,7 @@ export class DebridService {
reportResolved(link, fromPage);
});
const stillUnresolved = unresolved.filter((link) => !clean.has(link));
const stillUnresolved = unresolved.filter((link) => !clean.has(link) && !isRapidgatorLink(link));
await runWithConcurrency(stillUnresolved, 4, async (link) => {
try {
const unrestricted = await this.unrestrictLink(link);
@@ -629,6 +632,31 @@ export class DebridService {
this.settings.providerTertiary
);
const primary = order[0];
if (!this.settings.autoProviderFallback) {
if (!this.isProviderConfigured(primary)) {
throw new Error(`${PROVIDER_LABELS[primary]} nicht konfiguriert`);
}
try {
const result = await this.unrestrictViaProvider(primary, link);
let fileName = result.fileName;
if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) {
const fromPage = await resolveRapidgatorFilename(link);
if (fromPage) {
fileName = fromPage;
}
}
return {
...result,
fileName,
provider: primary,
providerLabel: PROVIDER_LABELS[primary]
};
} catch (error) {
throw new Error(`Unrestrict fehlgeschlagen: ${PROVIDER_LABELS[primary]}: ${compactErrorText(error)}`);
}
}
let configuredFound = false;
const attempts: string[] = [];
@@ -637,9 +665,6 @@ export class DebridService {
continue;
}
configuredFound = true;
if (!this.settings.autoProviderFallback && attempts.length > 0) {
break;
}
try {
const result = await this.unrestrictViaProvider(provider, link);
+112 -30
View File
@@ -42,6 +42,8 @@ const DEFAULT_DOWNLOAD_CONNECT_TIMEOUT_MS = 25000;
const DEFAULT_GLOBAL_STALL_WATCHDOG_TIMEOUT_MS = 90000;
const DEFAULT_POST_EXTRACT_TIMEOUT_MS = 4 * 60 * 60 * 1000;
function getDownloadStallTimeoutMs(): number {
const fromEnv = Number(process.env.RD_STALL_TIMEOUT_MS ?? NaN);
if (Number.isFinite(fromEnv) && fromEnv >= 2000 && fromEnv <= 600000) {
@@ -71,6 +73,14 @@ function getGlobalStallWatchdogTimeoutMs(): number {
return DEFAULT_GLOBAL_STALL_WATCHDOG_TIMEOUT_MS;
}
function getPostExtractTimeoutMs(): number {
const fromEnv = Number(process.env.RD_POST_EXTRACT_TIMEOUT_MS ?? NaN);
if (Number.isFinite(fromEnv) && fromEnv >= 2000 && fromEnv <= 24 * 60 * 60 * 1000) {
return Math.floor(fromEnv);
}
return DEFAULT_POST_EXTRACT_TIMEOUT_MS;
}
type DownloadManagerOptions = {
megaWebUnrestrict?: MegaWebUnrestrictor;
};
@@ -562,17 +572,26 @@ export class DownloadManager extends EventEmitter {
if (pkg.status === "paused") {
pkg.status = "queued";
}
let hasReactivatedRunItems = false;
for (const itemId of pkg.itemIds) {
const item = this.session.items[itemId];
if (!item) {
continue;
}
if (this.session.running && !isFinishedStatus(item.status)) {
this.runOutcomes.delete(itemId);
this.runItemIds.add(itemId);
hasReactivatedRunItems = true;
}
if (item.status === "queued" && item.fullStatus === "Paket gestoppt") {
item.fullStatus = "Wartet";
item.updatedAt = nowMs();
}
}
if (this.session.running) {
if (hasReactivatedRunItems) {
this.runPackageIds.add(packageId);
}
void this.ensureScheduler().catch((err) => logger.warn(`ensureScheduler Fehler (togglePackage): ${compactErrorText(err)}`));
}
}
@@ -1297,6 +1316,8 @@ export class DownloadManager extends EventEmitter {
this.speedBytesLastWindow = 0;
this.lastGlobalProgressBytes = 0;
this.lastGlobalProgressAt = nowMs();
this.globalSpeedLimitQueue = Promise.resolve();
this.globalSpeedLimitNextAt = 0;
this.summary = null;
this.persistSoon();
this.emitState(true);
@@ -2103,6 +2124,9 @@ export class DownloadManager extends EventEmitter {
while (true) {
try {
const unrestricted = await this.debridService.unrestrictLink(item.url);
if (active.abortController.signal.aborted) {
throw new Error(`aborted:${active.abortReason}`);
}
item.provider = unrestricted.provider;
item.retries += unrestricted.retriesUsed;
item.fileName = sanitizeFilename(unrestricted.fileName || filenameFromUrl(item.url));
@@ -2206,23 +2230,28 @@ export class DownloadManager extends EventEmitter {
return;
} catch (error) {
const reason = active.abortReason;
const claimedTargetPath = this.claimedTargetPathByItem.get(item.id) || "";
if (reason === "cancel") {
item.status = "cancelled";
item.fullStatus = "Entfernt";
this.recordRunOutcome(item.id, "cancelled");
try {
fs.rmSync(item.targetPath, { force: true });
} catch {
// ignore
if (claimedTargetPath) {
try {
fs.rmSync(claimedTargetPath, { force: true });
} catch {
// ignore
}
}
} else if (reason === "stop") {
item.status = "cancelled";
item.fullStatus = "Gestoppt";
this.recordRunOutcome(item.id, "cancelled");
try {
fs.rmSync(item.targetPath, { force: true });
} catch {
// ignore
if (claimedTargetPath) {
try {
fs.rmSync(claimedTargetPath, { force: true });
} catch {
// ignore
}
}
} else if (reason === "shutdown") {
item.status = "queued";
@@ -2916,6 +2945,10 @@ export class DownloadManager extends EventEmitter {
private cachedSpeedLimitAt = 0;
private globalSpeedLimitQueue: Promise<void> = Promise.resolve();
private globalSpeedLimitNextAt = 0;
private getEffectiveSpeedLimitKbps(): number {
const now = nowMs();
if (now - this.cachedSpeedLimitAt < 2000) {
@@ -2947,6 +2980,25 @@ export class DownloadManager extends EventEmitter {
return 0;
}
private async applyGlobalSpeedLimit(chunkBytes: number, bytesPerSecond: number): Promise<void> {
const task = this.globalSpeedLimitQueue
.catch(() => undefined)
.then(async () => {
const now = nowMs();
const waitMs = Math.max(0, this.globalSpeedLimitNextAt - now);
if (waitMs > 0) {
await sleep(waitMs);
}
const startAt = Math.max(nowMs(), this.globalSpeedLimitNextAt);
const durationMs = Math.max(1, Math.ceil((chunkBytes / bytesPerSecond) * 1000));
this.globalSpeedLimitNextAt = startAt + durationMs;
});
this.globalSpeedLimitQueue = task;
await task;
}
private async applySpeedLimit(chunkBytes: number, localWindowBytes: number, localWindowStarted: number): Promise<void> {
const limitKbps = this.getEffectiveSpeedLimitKbps();
if (limitKbps <= 0) {
@@ -2963,16 +3015,11 @@ export class DownloadManager extends EventEmitter {
if (sleepMs > 0) {
await sleep(Math.min(300, sleepMs));
}
}
return;
}
return;
}
this.pruneSpeedEvents(now);
const globalBytes = this.speedBytesLastWindow + chunkBytes;
const globalAllowed = bytesPerSecond * 3;
if (globalBytes > globalAllowed) {
await sleep(Math.min(250, Math.ceil(((globalBytes - globalAllowed) / bytesPerSecond) * 1000)));
}
await this.applyGlobalSpeedLimit(chunkBytes, bytesPerSecond);
}
private findReadyArchiveSets(pkg: PackageEntry): Set<string> {
@@ -3194,9 +3241,28 @@ export class DownloadManager extends EventEmitter {
updateExtractingStatus("Entpacken 0%");
this.emitState();
const extractTimeoutMs = 4 * 60 * 60 * 1000;
const extractTimeoutMs = getPostExtractTimeoutMs();
const extractAbortController = new AbortController();
let timedOut = false;
const onParentAbort = (): void => {
if (extractAbortController.signal.aborted) {
return;
}
extractAbortController.abort("aborted:extract");
};
if (signal) {
if (signal.aborted) {
onParentAbort();
} else {
signal.addEventListener("abort", onParentAbort, { once: true });
}
}
const extractDeadline = setTimeout(() => {
logger.error(`Post-Processing Extraction Timeout nach 4h: pkg=${pkg.name}`);
timedOut = true;
logger.error(`Post-Processing Extraction Timeout nach ${Math.ceil(extractTimeoutMs / 1000)}s: pkg=${pkg.name}`);
if (!extractAbortController.signal.aborted) {
extractAbortController.abort("extract_timeout");
}
}, extractTimeoutMs);
try {
const result = await extractPackageArchives({
@@ -3207,7 +3273,7 @@ export class DownloadManager extends EventEmitter {
removeLinks: this.settings.removeLinkFilesAfterExtract,
removeSamples: this.settings.removeSamplesAfterExtract,
passwordList: this.settings.archivePasswordList,
signal,
signal: extractAbortController.signal,
onProgress: (progress) => {
const label = progress.phase === "done"
? "Entpacken 100%"
@@ -3224,7 +3290,6 @@ export class DownloadManager extends EventEmitter {
this.emitState();
}
});
clearTimeout(extractDeadline);
logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`);
if (result.failed > 0) {
const reason = compactErrorText(result.lastError || "Entpacken fehlgeschlagen");
@@ -3257,19 +3322,29 @@ export class DownloadManager extends EventEmitter {
pkg.status = "completed";
}
} catch (error) {
clearTimeout(extractDeadline);
const reasonRaw = String(error || "");
if (reasonRaw.includes("aborted:extract")) {
for (const entry of completedItems) {
if (/^Entpacken/i.test(entry.fullStatus || "")) {
entry.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)";
if (reasonRaw.includes("aborted:extract") || reasonRaw.includes("extract_timeout")) {
if (timedOut) {
const timeoutReason = `Entpacken Timeout nach ${Math.ceil(extractTimeoutMs / 1000)}s`;
logger.error(`Post-Processing Entpacken Timeout: pkg=${pkg.name}`);
for (const entry of completedItems) {
entry.fullStatus = `Entpack-Fehler: ${timeoutReason}`;
entry.updatedAt = nowMs();
}
entry.updatedAt = nowMs();
pkg.status = "failed";
pkg.updatedAt = nowMs();
} else {
for (const entry of completedItems) {
if (/^Entpacken/i.test(entry.fullStatus || "")) {
entry.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)";
}
entry.updatedAt = nowMs();
}
pkg.status = pkg.enabled ? "queued" : "paused";
pkg.updatedAt = nowMs();
logger.info(`Post-Processing Entpacken abgebrochen: pkg=${pkg.name}`);
return;
}
pkg.status = pkg.enabled ? "queued" : "paused";
pkg.updatedAt = nowMs();
logger.info(`Post-Processing Entpacken abgebrochen: pkg=${pkg.name}`);
return;
}
const reason = compactErrorText(error);
logger.error(`Post-Processing Entpacken Exception: pkg=${pkg.name}, reason=${reason}`);
@@ -3278,6 +3353,11 @@ export class DownloadManager extends EventEmitter {
entry.updatedAt = nowMs();
}
pkg.status = "failed";
} finally {
clearTimeout(extractDeadline);
if (signal) {
signal.removeEventListener("abort", onParentAbort);
}
}
} else if (failed > 0) {
pkg.status = "failed";
@@ -3380,6 +3460,8 @@ export class DownloadManager extends EventEmitter {
this.reservedTargetPaths.clear();
this.claimedTargetPathByItem.clear();
this.itemContributedBytes.clear();
this.globalSpeedLimitQueue = Promise.resolve();
this.globalSpeedLimitNextAt = 0;
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
this.lastGlobalProgressAt = nowMs();
this.persistNow();
+30 -1
View File
@@ -12,8 +12,12 @@ const NO_EXTRACTOR_MESSAGE = "WinRAR/UnRAR nicht gefunden. Bitte WinRAR installi
let resolvedExtractorCommand: string | null = null;
let resolveFailureReason = "";
let resolveFailureAt = 0;
let externalExtractorSupportsPerfFlags = true;
const EXTRACTOR_RETRY_AFTER_MS = 30_000;
const DEFAULT_ZIP_ENTRY_MEMORY_LIMIT_MB = 256;
export interface ExtractOptions {
packageDir: string;
targetDir: string;
@@ -45,6 +49,14 @@ const EXTRACT_PER_GIB_TIMEOUT_MS = 4 * 60 * 1000;
const EXTRACT_MAX_TIMEOUT_MS = 120 * 60 * 1000;
const ARCHIVE_SORT_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
function zipEntryMemoryLimitBytes(): number {
const fromEnvMb = Number(process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB ?? NaN);
if (Number.isFinite(fromEnvMb) && fromEnvMb >= 8 && fromEnvMb <= 4096) {
return Math.floor(fromEnvMb * 1024 * 1024);
}
return DEFAULT_ZIP_ENTRY_MEMORY_LIMIT_MB * 1024 * 1024;
}
export function pathSetKey(filePath: string): string {
return process.platform === "win32" ? filePath.toLowerCase() : filePath;
}
@@ -490,7 +502,12 @@ async function resolveExtractorCommand(): Promise<string> {
return resolvedExtractorCommand;
}
if (resolveFailureReason) {
throw new Error(resolveFailureReason);
const age = Date.now() - resolveFailureAt;
if (age < EXTRACTOR_RETRY_AFTER_MS) {
throw new Error(resolveFailureReason);
}
resolveFailureReason = "";
resolveFailureAt = 0;
}
const candidates = winRarCandidates();
@@ -503,12 +520,14 @@ async function resolveExtractorCommand(): Promise<string> {
if (!probe.missingCommand) {
resolvedExtractorCommand = command;
resolveFailureReason = "";
resolveFailureAt = 0;
logger.info(`Entpacker erkannt: ${command}`);
return command;
}
}
resolveFailureReason = NO_EXTRACTOR_MESSAGE;
resolveFailureAt = Date.now();
throw new Error(resolveFailureReason);
}
@@ -581,6 +600,7 @@ async function runExternalExtract(
if (result.missingCommand) {
resolvedExtractorCommand = null;
resolveFailureReason = NO_EXTRACTOR_MESSAGE;
resolveFailureAt = Date.now();
throw new Error(NO_EXTRACTOR_MESSAGE);
}
@@ -592,6 +612,7 @@ async function runExternalExtract(
function extractZipArchive(archivePath: string, targetDir: string, conflictMode: ConflictMode): void {
const mode = effectiveConflictMode(conflictMode);
const memoryLimitBytes = zipEntryMemoryLimitBytes();
const zip = new AdmZip(archivePath);
const entries = zip.getEntries();
const resolvedTarget = path.resolve(targetDir);
@@ -605,6 +626,14 @@ function extractZipArchive(archivePath: string, targetDir: string, conflictMode:
fs.mkdirSync(outputPath, { recursive: true });
continue;
}
const uncompressedSize = Number((entry as unknown as { header?: { size?: number } }).header?.size ?? NaN);
if (Number.isFinite(uncompressedSize) && uncompressedSize > memoryLimitBytes) {
const entryMb = Math.ceil(uncompressedSize / (1024 * 1024));
const limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024));
throw new Error(`ZIP-Eintrag zu groß für internen Entpacker (${entryMb} MB > ${limitMb} MB)`);
}
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
// TOCTOU note: There is a small race between existsSync and writeFileSync below.
// This is acceptable here because zip extraction is single-threaded and we need
+8 -1
View File
@@ -25,18 +25,25 @@ function clampNumber(value: unknown, fallback: number, min: number, max: number)
return Math.max(min, Math.min(max, Math.floor(num)));
}
function createScheduleId(index: number): string {
return `sched-${Date.now().toString(36)}-${index.toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
function normalizeBandwidthSchedules(raw: unknown): BandwidthScheduleEntry[] {
if (!Array.isArray(raw)) {
return [];
}
const normalized: BandwidthScheduleEntry[] = [];
for (const entry of raw) {
for (let index = 0; index < raw.length; index += 1) {
const entry = raw[index];
if (!entry || typeof entry !== "object") {
continue;
}
const value = entry as Partial<BandwidthScheduleEntry>;
const rawId = typeof value.id === "string" ? value.id.trim() : "";
normalized.push({
id: rawId || createScheduleId(index),
startHour: clampNumber(value.startHour, 0, 0, 23),
endHour: clampNumber(value.endHour, 8, 0, 23),
speedLimitKbps: clampNumber(value.speedLimitKbps, 0, 0, 500000),
+70 -5
View File
@@ -12,6 +12,7 @@ import { logger } from "./logger";
const RELEASE_FETCH_TIMEOUT_MS = 12000;
const CONNECT_TIMEOUT_MS = 30000;
const DOWNLOAD_BODY_IDLE_TIMEOUT_MS = 45000;
const RETRIES_PER_CANDIDATE = 3;
const RETRY_DELAY_MS = 1500;
const UPDATE_USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`;
@@ -69,6 +70,14 @@ function timeoutController(ms: number): { signal: AbortSignal; clear: () => void
};
}
function getDownloadBodyIdleTimeoutMs(): number {
const fromEnv = Number(process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS ?? NaN);
if (Number.isFinite(fromEnv) && fromEnv >= 1000 && fromEnv <= 30 * 60 * 1000) {
return Math.floor(fromEnv);
}
return DOWNLOAD_BODY_IDLE_TIMEOUT_MS;
}
export function parseVersionParts(version: string): number[] {
const cleaned = version.replace(/^v/i, "").trim();
return cleaned.split(".").map((part) => Number(part.replace(/[^0-9].*$/, "") || "0"));
@@ -200,9 +209,9 @@ function readHttpStatusFromError(error: unknown): number {
return match ? Number(match[1]) : 0;
}
function isRecoverableDownloadError(error: unknown): boolean {
function isRetryableDownloadError(error: unknown): boolean {
const status = readHttpStatusFromError(error);
if (status === 404 || status === 403 || status === 429 || status >= 500) {
if (status === 429 || status >= 500) {
return true;
}
@@ -215,6 +224,14 @@ function isRecoverableDownloadError(error: unknown): boolean {
|| text.includes("aborted");
}
function shouldTryNextDownloadCandidate(error: unknown): boolean {
const status = readHttpStatusFromError(error);
if (status >= 400 && status <= 599) {
return true;
}
return isRetryableDownloadError(error);
}
function deriveUpdateFileName(check: UpdateCheckResult, url: string): string {
const fromName = String(check.setupAssetName || "").trim();
if (fromName) {
@@ -298,7 +315,55 @@ async function downloadFile(url: string, targetPath: string): Promise<void> {
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
const source = Readable.fromWeb(response.body as unknown as NodeReadableStream<Uint8Array>);
const target = fs.createWriteStream(targetPath);
await pipeline(source, target);
const idleTimeoutMs = getDownloadBodyIdleTimeoutMs();
let idleTimer: NodeJS.Timeout | null = null;
const clearIdleTimer = (): void => {
if (idleTimer) {
clearTimeout(idleTimer);
idleTimer = null;
}
};
const onIdleTimeout = (): void => {
const timeoutError = new Error(`Update Download Body Timeout nach ${Math.ceil(idleTimeoutMs / 1000)}s`);
source.destroy(timeoutError);
target.destroy(timeoutError);
};
const resetIdleTimer = (): void => {
if (idleTimeoutMs <= 0) {
return;
}
clearIdleTimer();
idleTimer = setTimeout(onIdleTimeout, idleTimeoutMs);
};
const onSourceData = (): void => {
resetIdleTimer();
};
const onSourceDone = (): void => {
clearIdleTimer();
};
if (idleTimeoutMs > 0) {
source.on("data", onSourceData);
source.on("end", onSourceDone);
source.on("close", onSourceDone);
source.on("error", onSourceDone);
target.on("close", onSourceDone);
target.on("error", onSourceDone);
resetIdleTimer();
}
try {
await pipeline(source, target);
} finally {
clearIdleTimer();
source.off("data", onSourceData);
source.off("end", onSourceDone);
source.off("close", onSourceDone);
source.off("error", onSourceDone);
target.off("close", onSourceDone);
target.off("error", onSourceDone);
}
logger.info(`Update-Download abgeschlossen: ${targetPath}`);
}
@@ -319,7 +384,7 @@ async function downloadWithRetries(url: string, targetPath: string): Promise<voi
} catch {
// ignore
}
if (attempt < RETRIES_PER_CANDIDATE && isRecoverableDownloadError(error)) {
if (attempt < RETRIES_PER_CANDIDATE && isRetryableDownloadError(error)) {
logger.warn(`Update-Download Retry ${attempt}/${RETRIES_PER_CANDIDATE} für ${url}: ${compactErrorText(error)}`);
await sleep(RETRY_DELAY_MS * attempt);
continue;
@@ -342,7 +407,7 @@ async function downloadFromCandidates(candidates: string[], targetPath: string):
} catch (error) {
lastError = error;
logger.warn(`Update-Download Kandidat ${index + 1}/${candidates.length} endgültig fehlgeschlagen: ${compactErrorText(error)}`);
if (index < candidates.length - 1 && isRecoverableDownloadError(error)) {
if (index < candidates.length - 1 && shouldTryNextDownloadCandidate(error)) {
continue;
}
break;
+27 -2
View File
@@ -9,6 +9,12 @@ function safeDecodeURIComponent(value: string): string {
}
}
const WINDOWS_RESERVED_BASENAMES = new Set([
"con", "prn", "aux", "nul",
"com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9",
"lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9"
]);
export function compactErrorText(message: unknown, maxLen = 220): string {
const raw = String(message ?? "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
if (!raw) {
@@ -21,8 +27,27 @@ export function compactErrorText(message: unknown, maxLen = 220): string {
}
export function sanitizeFilename(name: string): string {
const cleaned = String(name || "").trim().replace(/\0/g, "").replace(/[\\/:*?"<>|]/g, " ").replace(/\s+/g, " ").trim();
return cleaned || "Paket";
const cleaned = String(name || "")
.replace(/\0/g, "")
.replace(/[\\/:*?"<>|]/g, " ")
.replace(/\s+/g, " ")
.trim();
let normalized = cleaned
.replace(/^[.\s]+/g, "")
.replace(/[.\s]+$/g, "")
.trim();
if (!normalized || normalized === "." || normalized === ".." || /^\.+$/.test(normalized)) {
return "Paket";
}
const parsed = path.parse(normalized);
if (WINDOWS_RESERVED_BASENAMES.has(parsed.name.toLowerCase())) {
normalized = `${parsed.name}_${parsed.ext}`;
}
return normalized || "Paket";
}
export function isHttpLink(value: string): boolean {