Release v1.4.17 with security fixes, stability hardening and retry improvements
- Fix ZIP path traversal vulnerability (reject entries escaping target dir) - Add single instance lock (prevent data corruption from multiple instances) - Add unhandled exception/rejection handlers (prevent silent crashes) - Fix mainWindow reference cleanup on close - Add second-instance handler to focus existing window - Fix claimTargetPath infinite loop (add 10k iteration bound) - Add duplicate startItem guard (prevent concurrent downloads of same item) - Clone session in getSnapshot to prevent live-reference mutation bugs - Clear stateEmitTimer on clearAll to prevent dangling timer emissions - Add extraction timeout safety (4h deadline with logging) - Add dedicated unrestrict retry system with longer backoff for Mega-Debrid errors - Add log rotation (10MB max, keeps one .old backup) - Fix writeExtractResumeState missing mkdir (prevents crash on deleted dirs) - Fix saveSessionAsync EXDEV cross-device rename with copy fallback Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ea6301d326
commit
d4dd266f6b
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "1.4.16",
|
||||
"version": "1.4.17",
|
||||
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
|
||||
"main": "build/main/main/main.js",
|
||||
"author": "Sucukdeluxe",
|
||||
|
||||
@ -134,6 +134,13 @@ function isFetchFailure(errorText: string): boolean {
|
||||
return text.includes("fetch failed") || text.includes("socket hang up") || text.includes("econnreset") || text.includes("network error");
|
||||
}
|
||||
|
||||
function isUnrestrictFailure(errorText: string): boolean {
|
||||
const text = String(errorText || "").toLowerCase();
|
||||
return text.includes("unrestrict") || text.includes("mega-web") || text.includes("mega-debrid")
|
||||
|| text.includes("bestdebrid") || text.includes("alldebrid") || text.includes("kein debrid")
|
||||
|| text.includes("session") || text.includes("login");
|
||||
}
|
||||
|
||||
function isFinishedStatus(status: DownloadStatus): boolean {
|
||||
return status === "completed" || status === "failed" || status === "cancelled";
|
||||
}
|
||||
@ -304,7 +311,7 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
return {
|
||||
settings: this.settings,
|
||||
session: this.session,
|
||||
session: cloneSession(this.session),
|
||||
summary: this.summary,
|
||||
stats: this.getStats(now),
|
||||
speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`,
|
||||
@ -494,6 +501,10 @@ export class DownloadManager extends EventEmitter {
|
||||
public clearAll(): void {
|
||||
this.stop();
|
||||
this.abortPostProcessing("clear_all");
|
||||
if (this.stateEmitTimer) {
|
||||
clearTimeout(this.stateEmitTimer);
|
||||
this.stateEmitTimer = null;
|
||||
}
|
||||
this.session.packageOrder = [];
|
||||
this.session.packages = {};
|
||||
this.session.items = {};
|
||||
@ -1394,8 +1405,8 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
|
||||
const parsed = path.parse(preferredPath);
|
||||
let index = 0;
|
||||
while (true) {
|
||||
const maxIndex = 10000;
|
||||
for (let index = 0; index <= maxIndex; index += 1) {
|
||||
const candidate = index === 0
|
||||
? preferredPath
|
||||
: path.join(parsed.dir, `${parsed.name} (${index})${parsed.ext}`);
|
||||
@ -1408,8 +1419,11 @@ export class DownloadManager extends EventEmitter {
|
||||
this.claimedTargetPathByItem.set(itemId, candidate);
|
||||
return candidate;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
logger.error(`claimTargetPath: Limit erreicht für ${preferredPath}`);
|
||||
this.reservedTargetPaths.set(pathKey(preferredPath), itemId);
|
||||
this.claimedTargetPathByItem.set(itemId, preferredPath);
|
||||
return preferredPath;
|
||||
}
|
||||
|
||||
private releaseTargetPath(itemId: string): void {
|
||||
@ -1804,6 +1818,9 @@ export class DownloadManager extends EventEmitter {
|
||||
if (!item || !pkg || pkg.cancelled || !pkg.enabled) {
|
||||
return;
|
||||
}
|
||||
if (this.activeTasks.has(itemId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
item.status = "validating";
|
||||
item.fullStatus = "Link wird umgewandelt";
|
||||
@ -1844,7 +1861,9 @@ export class DownloadManager extends EventEmitter {
|
||||
let freshRetryUsed = false;
|
||||
let stallRetries = 0;
|
||||
let genericErrorRetries = 0;
|
||||
let unrestrictRetries = 0;
|
||||
const maxGenericErrorRetries = Math.max(2, REQUEST_RETRIES);
|
||||
const maxUnrestrictRetries = Math.max(3, REQUEST_RETRIES);
|
||||
while (true) {
|
||||
try {
|
||||
const unrestricted = await this.debridService.unrestrictLink(item.url);
|
||||
@ -2039,6 +2058,23 @@ export class DownloadManager extends EventEmitter {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isUnrestrictFailure(errorText) && unrestrictRetries < maxUnrestrictRetries) {
|
||||
unrestrictRetries += 1;
|
||||
item.retries += 1;
|
||||
item.status = "queued";
|
||||
item.fullStatus = `Unrestrict-Fehler, Retry ${unrestrictRetries}/${maxUnrestrictRetries}`;
|
||||
item.lastError = errorText;
|
||||
item.attempts = 0;
|
||||
item.speedBps = 0;
|
||||
item.updatedAt = nowMs();
|
||||
active.abortController = new AbortController();
|
||||
active.abortReason = "none";
|
||||
this.persistSoon();
|
||||
this.emitState();
|
||||
await sleep(Math.min(8000, 2000 * unrestrictRetries));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (genericErrorRetries < maxGenericErrorRetries) {
|
||||
genericErrorRetries += 1;
|
||||
item.retries += 1;
|
||||
@ -2728,6 +2764,10 @@ export class DownloadManager extends EventEmitter {
|
||||
updateExtractingStatus("Entpacken 0%");
|
||||
this.emitState();
|
||||
|
||||
const extractTimeoutMs = 4 * 60 * 60 * 1000;
|
||||
const extractDeadline = setTimeout(() => {
|
||||
logger.error(`Post-Processing Extraction Timeout nach 4h: pkg=${pkg.name}`);
|
||||
}, extractTimeoutMs);
|
||||
try {
|
||||
const result = await extractPackageArchives({
|
||||
packageDir: pkg.outputDir,
|
||||
@ -2754,6 +2794,7 @@ 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");
|
||||
@ -2783,6 +2824,7 @@ 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) {
|
||||
|
||||
@ -232,11 +232,16 @@ function readExtractResumeState(packageDir: string): Set<string> {
|
||||
}
|
||||
|
||||
function writeExtractResumeState(packageDir: string, completedArchives: Set<string>): void {
|
||||
const progressPath = extractProgressFilePath(packageDir);
|
||||
const payload: ExtractResumeState = {
|
||||
completedArchives: Array.from(completedArchives).sort((a, b) => a.localeCompare(b))
|
||||
};
|
||||
fs.writeFileSync(progressPath, JSON.stringify(payload, null, 2), "utf8");
|
||||
try {
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
const progressPath = extractProgressFilePath(packageDir);
|
||||
const payload: ExtractResumeState = {
|
||||
completedArchives: Array.from(completedArchives).sort((a, b) => a.localeCompare(b))
|
||||
};
|
||||
fs.writeFileSync(progressPath, JSON.stringify(payload, null, 2), "utf8");
|
||||
} catch (error) {
|
||||
logger.warn(`ExtractResumeState schreiben fehlgeschlagen: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function clearExtractResumeState(packageDir: string): void {
|
||||
@ -582,8 +587,13 @@ function extractZipArchive(archivePath: string, targetDir: string, conflictMode:
|
||||
const mode = effectiveConflictMode(conflictMode);
|
||||
const zip = new AdmZip(archivePath);
|
||||
const entries = zip.getEntries();
|
||||
const resolvedTarget = path.resolve(targetDir);
|
||||
for (const entry of entries) {
|
||||
const outputPath = path.join(targetDir, entry.entryName);
|
||||
const outputPath = path.resolve(targetDir, entry.entryName);
|
||||
if (!outputPath.startsWith(resolvedTarget + path.sep) && outputPath !== resolvedTarget) {
|
||||
logger.warn(`ZIP-Eintrag übersprungen (Path Traversal): ${entry.entryName}`);
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
fs.mkdirSync(outputPath, { recursive: true });
|
||||
continue;
|
||||
|
||||
@ -5,6 +5,8 @@ let logFilePath = path.resolve(process.cwd(), "rd_downloader.log");
|
||||
let fallbackLogFilePath: string | null = null;
|
||||
const LOG_FLUSH_INTERVAL_MS = 120;
|
||||
const LOG_BUFFER_LIMIT_CHARS = 1_000_000;
|
||||
const LOG_MAX_FILE_BYTES = 10 * 1024 * 1024;
|
||||
let lastRotateCheckAt = 0;
|
||||
|
||||
let pendingLines: string[] = [];
|
||||
let pendingChars = 0;
|
||||
@ -90,6 +92,29 @@ function scheduleFlush(immediate = false): void {
|
||||
}, LOG_FLUSH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function rotateIfNeeded(filePath: string): void {
|
||||
try {
|
||||
const now = Date.now();
|
||||
if (now - lastRotateCheckAt < 60_000) {
|
||||
return;
|
||||
}
|
||||
lastRotateCheckAt = now;
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size < LOG_MAX_FILE_BYTES) {
|
||||
return;
|
||||
}
|
||||
const backup = `${filePath}.old`;
|
||||
try {
|
||||
fs.rmSync(backup, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
fs.renameSync(filePath, backup);
|
||||
} catch {
|
||||
// ignore - file may not exist yet
|
||||
}
|
||||
}
|
||||
|
||||
async function flushAsync(): Promise<void> {
|
||||
if (flushInFlight || pendingLines.length === 0) {
|
||||
return;
|
||||
@ -101,6 +126,7 @@ async function flushAsync(): Promise<void> {
|
||||
pendingChars = 0;
|
||||
|
||||
try {
|
||||
rotateIfNeeded(logFilePath);
|
||||
const primary = await appendChunk(logFilePath, chunk);
|
||||
if (fallbackLogFilePath) {
|
||||
const fallback = await appendChunk(fallbackLogFilePath, chunk);
|
||||
|
||||
@ -6,6 +6,20 @@ import { IPC_CHANNELS } from "../shared/ipc";
|
||||
import { logger } from "./logger";
|
||||
import { APP_NAME } from "./constants";
|
||||
|
||||
/* ── Single Instance Lock ───────────────────────────────────────── */
|
||||
const gotLock = app.requestSingleInstanceLock();
|
||||
if (!gotLock) {
|
||||
app.quit();
|
||||
}
|
||||
|
||||
/* ── Unhandled error protection ─────────────────────────────────── */
|
||||
process.on("uncaughtException", (error) => {
|
||||
logger.error(`Uncaught Exception: ${String(error?.stack || error)}`);
|
||||
});
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
logger.error(`Unhandled Rejection: ${String(reason)}`);
|
||||
});
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let tray: Tray | null = null;
|
||||
let clipboardTimer: ReturnType<typeof setInterval> | null = null;
|
||||
@ -202,6 +216,16 @@ function registerIpcHandlers(): void {
|
||||
};
|
||||
}
|
||||
|
||||
app.on("second-instance", () => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore();
|
||||
}
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
|
||||
app.whenReady().then(() => {
|
||||
registerIpcHandlers();
|
||||
mainWindow = createWindow();
|
||||
@ -216,6 +240,10 @@ app.whenReady().then(() => {
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
mainWindow = createWindow();
|
||||
|
||||
@ -226,7 +226,16 @@ export async function saveSessionAsync(paths: StoragePaths, session: SessionStat
|
||||
const payload = JSON.stringify({ ...session, updatedAt: Date.now() });
|
||||
const tempPath = `${paths.sessionFile}.tmp`;
|
||||
await fsp.writeFile(tempPath, payload, "utf8");
|
||||
await fsp.rename(tempPath, paths.sessionFile);
|
||||
try {
|
||||
await fsp.rename(tempPath, paths.sessionFile);
|
||||
} catch (renameError: unknown) {
|
||||
if ((renameError as NodeJS.ErrnoException).code === "EXDEV") {
|
||||
await fsp.copyFile(tempPath, paths.sessionFile);
|
||||
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
||||
} else {
|
||||
throw renameError;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Async Session-Save fehlgeschlagen: ${String(error)}`);
|
||||
} finally {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user