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",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.4.16",
|
"version": "1.4.17",
|
||||||
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
|
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"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");
|
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 {
|
function isFinishedStatus(status: DownloadStatus): boolean {
|
||||||
return status === "completed" || status === "failed" || status === "cancelled";
|
return status === "completed" || status === "failed" || status === "cancelled";
|
||||||
}
|
}
|
||||||
@ -304,7 +311,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
settings: this.settings,
|
settings: this.settings,
|
||||||
session: this.session,
|
session: cloneSession(this.session),
|
||||||
summary: this.summary,
|
summary: this.summary,
|
||||||
stats: this.getStats(now),
|
stats: this.getStats(now),
|
||||||
speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`,
|
speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`,
|
||||||
@ -494,6 +501,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
public clearAll(): void {
|
public clearAll(): void {
|
||||||
this.stop();
|
this.stop();
|
||||||
this.abortPostProcessing("clear_all");
|
this.abortPostProcessing("clear_all");
|
||||||
|
if (this.stateEmitTimer) {
|
||||||
|
clearTimeout(this.stateEmitTimer);
|
||||||
|
this.stateEmitTimer = null;
|
||||||
|
}
|
||||||
this.session.packageOrder = [];
|
this.session.packageOrder = [];
|
||||||
this.session.packages = {};
|
this.session.packages = {};
|
||||||
this.session.items = {};
|
this.session.items = {};
|
||||||
@ -1394,8 +1405,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parsed = path.parse(preferredPath);
|
const parsed = path.parse(preferredPath);
|
||||||
let index = 0;
|
const maxIndex = 10000;
|
||||||
while (true) {
|
for (let index = 0; index <= maxIndex; index += 1) {
|
||||||
const candidate = index === 0
|
const candidate = index === 0
|
||||||
? preferredPath
|
? preferredPath
|
||||||
: path.join(parsed.dir, `${parsed.name} (${index})${parsed.ext}`);
|
: path.join(parsed.dir, `${parsed.name} (${index})${parsed.ext}`);
|
||||||
@ -1408,8 +1419,11 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.claimedTargetPathByItem.set(itemId, candidate);
|
this.claimedTargetPathByItem.set(itemId, candidate);
|
||||||
return 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 {
|
private releaseTargetPath(itemId: string): void {
|
||||||
@ -1804,6 +1818,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (!item || !pkg || pkg.cancelled || !pkg.enabled) {
|
if (!item || !pkg || pkg.cancelled || !pkg.enabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this.activeTasks.has(itemId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
item.status = "validating";
|
item.status = "validating";
|
||||||
item.fullStatus = "Link wird umgewandelt";
|
item.fullStatus = "Link wird umgewandelt";
|
||||||
@ -1844,7 +1861,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
let freshRetryUsed = false;
|
let freshRetryUsed = false;
|
||||||
let stallRetries = 0;
|
let stallRetries = 0;
|
||||||
let genericErrorRetries = 0;
|
let genericErrorRetries = 0;
|
||||||
|
let unrestrictRetries = 0;
|
||||||
const maxGenericErrorRetries = Math.max(2, REQUEST_RETRIES);
|
const maxGenericErrorRetries = Math.max(2, REQUEST_RETRIES);
|
||||||
|
const maxUnrestrictRetries = Math.max(3, REQUEST_RETRIES);
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
const unrestricted = await this.debridService.unrestrictLink(item.url);
|
const unrestricted = await this.debridService.unrestrictLink(item.url);
|
||||||
@ -2039,6 +2058,23 @@ export class DownloadManager extends EventEmitter {
|
|||||||
continue;
|
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) {
|
if (genericErrorRetries < maxGenericErrorRetries) {
|
||||||
genericErrorRetries += 1;
|
genericErrorRetries += 1;
|
||||||
item.retries += 1;
|
item.retries += 1;
|
||||||
@ -2728,6 +2764,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
updateExtractingStatus("Entpacken 0%");
|
updateExtractingStatus("Entpacken 0%");
|
||||||
this.emitState();
|
this.emitState();
|
||||||
|
|
||||||
|
const extractTimeoutMs = 4 * 60 * 60 * 1000;
|
||||||
|
const extractDeadline = setTimeout(() => {
|
||||||
|
logger.error(`Post-Processing Extraction Timeout nach 4h: pkg=${pkg.name}`);
|
||||||
|
}, extractTimeoutMs);
|
||||||
try {
|
try {
|
||||||
const result = await extractPackageArchives({
|
const result = await extractPackageArchives({
|
||||||
packageDir: pkg.outputDir,
|
packageDir: pkg.outputDir,
|
||||||
@ -2754,6 +2794,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.emitState();
|
this.emitState();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
clearTimeout(extractDeadline);
|
||||||
logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`);
|
logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`);
|
||||||
if (result.failed > 0) {
|
if (result.failed > 0) {
|
||||||
const reason = compactErrorText(result.lastError || "Entpacken fehlgeschlagen");
|
const reason = compactErrorText(result.lastError || "Entpacken fehlgeschlagen");
|
||||||
@ -2783,6 +2824,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
pkg.status = "completed";
|
pkg.status = "completed";
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
clearTimeout(extractDeadline);
|
||||||
const reasonRaw = String(error || "");
|
const reasonRaw = String(error || "");
|
||||||
if (reasonRaw.includes("aborted:extract")) {
|
if (reasonRaw.includes("aborted:extract")) {
|
||||||
for (const entry of completedItems) {
|
for (const entry of completedItems) {
|
||||||
|
|||||||
@ -232,11 +232,16 @@ function readExtractResumeState(packageDir: string): Set<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function writeExtractResumeState(packageDir: string, completedArchives: Set<string>): void {
|
function writeExtractResumeState(packageDir: string, completedArchives: Set<string>): void {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(packageDir, { recursive: true });
|
||||||
const progressPath = extractProgressFilePath(packageDir);
|
const progressPath = extractProgressFilePath(packageDir);
|
||||||
const payload: ExtractResumeState = {
|
const payload: ExtractResumeState = {
|
||||||
completedArchives: Array.from(completedArchives).sort((a, b) => a.localeCompare(b))
|
completedArchives: Array.from(completedArchives).sort((a, b) => a.localeCompare(b))
|
||||||
};
|
};
|
||||||
fs.writeFileSync(progressPath, JSON.stringify(payload, null, 2), "utf8");
|
fs.writeFileSync(progressPath, JSON.stringify(payload, null, 2), "utf8");
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`ExtractResumeState schreiben fehlgeschlagen: ${String(error)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearExtractResumeState(packageDir: string): void {
|
function clearExtractResumeState(packageDir: string): void {
|
||||||
@ -582,8 +587,13 @@ function extractZipArchive(archivePath: string, targetDir: string, conflictMode:
|
|||||||
const mode = effectiveConflictMode(conflictMode);
|
const mode = effectiveConflictMode(conflictMode);
|
||||||
const zip = new AdmZip(archivePath);
|
const zip = new AdmZip(archivePath);
|
||||||
const entries = zip.getEntries();
|
const entries = zip.getEntries();
|
||||||
|
const resolvedTarget = path.resolve(targetDir);
|
||||||
for (const entry of entries) {
|
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) {
|
if (entry.isDirectory) {
|
||||||
fs.mkdirSync(outputPath, { recursive: true });
|
fs.mkdirSync(outputPath, { recursive: true });
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@ -5,6 +5,8 @@ let logFilePath = path.resolve(process.cwd(), "rd_downloader.log");
|
|||||||
let fallbackLogFilePath: string | null = null;
|
let fallbackLogFilePath: string | null = null;
|
||||||
const LOG_FLUSH_INTERVAL_MS = 120;
|
const LOG_FLUSH_INTERVAL_MS = 120;
|
||||||
const LOG_BUFFER_LIMIT_CHARS = 1_000_000;
|
const LOG_BUFFER_LIMIT_CHARS = 1_000_000;
|
||||||
|
const LOG_MAX_FILE_BYTES = 10 * 1024 * 1024;
|
||||||
|
let lastRotateCheckAt = 0;
|
||||||
|
|
||||||
let pendingLines: string[] = [];
|
let pendingLines: string[] = [];
|
||||||
let pendingChars = 0;
|
let pendingChars = 0;
|
||||||
@ -90,6 +92,29 @@ function scheduleFlush(immediate = false): void {
|
|||||||
}, LOG_FLUSH_INTERVAL_MS);
|
}, 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> {
|
async function flushAsync(): Promise<void> {
|
||||||
if (flushInFlight || pendingLines.length === 0) {
|
if (flushInFlight || pendingLines.length === 0) {
|
||||||
return;
|
return;
|
||||||
@ -101,6 +126,7 @@ async function flushAsync(): Promise<void> {
|
|||||||
pendingChars = 0;
|
pendingChars = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
rotateIfNeeded(logFilePath);
|
||||||
const primary = await appendChunk(logFilePath, chunk);
|
const primary = await appendChunk(logFilePath, chunk);
|
||||||
if (fallbackLogFilePath) {
|
if (fallbackLogFilePath) {
|
||||||
const fallback = await appendChunk(fallbackLogFilePath, chunk);
|
const fallback = await appendChunk(fallbackLogFilePath, chunk);
|
||||||
|
|||||||
@ -6,6 +6,20 @@ import { IPC_CHANNELS } from "../shared/ipc";
|
|||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { APP_NAME } from "./constants";
|
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 mainWindow: BrowserWindow | null = null;
|
||||||
let tray: Tray | null = null;
|
let tray: Tray | null = null;
|
||||||
let clipboardTimer: ReturnType<typeof setInterval> | 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(() => {
|
app.whenReady().then(() => {
|
||||||
registerIpcHandlers();
|
registerIpcHandlers();
|
||||||
mainWindow = createWindow();
|
mainWindow = createWindow();
|
||||||
@ -216,6 +240,10 @@ app.whenReady().then(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mainWindow.on("closed", () => {
|
||||||
|
mainWindow = null;
|
||||||
|
});
|
||||||
|
|
||||||
app.on("activate", () => {
|
app.on("activate", () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
mainWindow = createWindow();
|
mainWindow = createWindow();
|
||||||
|
|||||||
@ -226,7 +226,16 @@ export async function saveSessionAsync(paths: StoragePaths, session: SessionStat
|
|||||||
const payload = JSON.stringify({ ...session, updatedAt: Date.now() });
|
const payload = JSON.stringify({ ...session, updatedAt: Date.now() });
|
||||||
const tempPath = `${paths.sessionFile}.tmp`;
|
const tempPath = `${paths.sessionFile}.tmp`;
|
||||||
await fsp.writeFile(tempPath, payload, "utf8");
|
await fsp.writeFile(tempPath, payload, "utf8");
|
||||||
|
try {
|
||||||
await fsp.rename(tempPath, paths.sessionFile);
|
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) {
|
} catch (error) {
|
||||||
logger.error(`Async Session-Save fehlgeschlagen: ${String(error)}`);
|
logger.error(`Async Session-Save fehlgeschlagen: ${String(error)}`);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user