Release v1.4.27 with bug audit hardening fixes
This commit is contained in:
@@ -8,7 +8,7 @@ export const APP_VERSION: string = packageJson.version;
|
||||
export const API_BASE_URL = "https://api.real-debrid.com/rest/1.0";
|
||||
|
||||
export const DCRYPT_UPLOAD_URL = "https://dcrypt.it/decrypt/upload";
|
||||
export const DLC_SERVICE_URL = "http://service.jdownloader.org/dlcrypt/service.php?srcType=dlc&destType=pylo&data={KEY}";
|
||||
export const DLC_SERVICE_URL = "https://service.jdownloader.org/dlcrypt/service.php?srcType=dlc&destType=pylo&data={KEY}";
|
||||
export const DLC_AES_KEY = Buffer.from("cb99b5cbc24db398", "utf8");
|
||||
export const DLC_AES_IV = Buffer.from("9bc24cb995cb8db3", "utf8");
|
||||
|
||||
|
||||
+24
-5
@@ -5,6 +5,8 @@ import { DCRYPT_UPLOAD_URL, DLC_AES_IV, DLC_AES_KEY, DLC_SERVICE_URL } from "./c
|
||||
import { compactErrorText, inferPackageNameFromLinks, isHttpLink, sanitizeFilename, uniquePreserveOrder } from "./utils";
|
||||
import { ParsedPackageInput } from "../shared/types";
|
||||
|
||||
const MAX_DLC_FILE_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
function decodeDcryptPayload(responseText: string): unknown {
|
||||
let text = String(responseText || "").trim();
|
||||
const m = text.match(/<textarea[^>]*>([\s\S]*?)<\/textarea>/i);
|
||||
@@ -62,6 +64,14 @@ function decryptRcPayload(base64Rc: string): Buffer {
|
||||
return Buffer.concat([decipher.update(rcBytes), decipher.final()]);
|
||||
}
|
||||
|
||||
function readDlcFileWithLimit(filePath: string): Buffer {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size <= 0 || stat.size > MAX_DLC_FILE_BYTES) {
|
||||
throw new Error(`DLC-Datei ungültig oder zu groß (${Math.floor(stat.size)} B)`);
|
||||
}
|
||||
return fs.readFileSync(filePath);
|
||||
}
|
||||
|
||||
function parsePackagesFromDlcXml(xml: string): ParsedPackageInput[] {
|
||||
const packages: ParsedPackageInput[] = [];
|
||||
const packageRegex = /<package\s+[^>]*name="([^"]*)"[^>]*>([\s\S]*?)<\/package>/gi;
|
||||
@@ -104,7 +114,7 @@ function parsePackagesFromDlcXml(xml: string): ParsedPackageInput[] {
|
||||
}
|
||||
|
||||
async function decryptDlcLocal(filePath: string): Promise<ParsedPackageInput[]> {
|
||||
const content = fs.readFileSync(filePath, "ascii").trim();
|
||||
const content = readDlcFileWithLimit(filePath).toString("ascii").trim();
|
||||
if (content.length < 89) {
|
||||
return [];
|
||||
}
|
||||
@@ -129,10 +139,19 @@ async function decryptDlcLocal(filePath: string): Promise<ParsedPackageInput[]>
|
||||
decipher.setAutoPadding(false);
|
||||
let decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||
|
||||
const pad = decrypted[decrypted.length - 1];
|
||||
if (pad > 0 && pad <= 16) {
|
||||
decrypted = decrypted.subarray(0, decrypted.length - pad);
|
||||
if (decrypted.length === 0) {
|
||||
throw new Error("DLC-Entschlüsselung lieferte keine Daten");
|
||||
}
|
||||
const pad = decrypted[decrypted.length - 1];
|
||||
if (pad <= 0 || pad > 16 || pad > decrypted.length) {
|
||||
throw new Error("Ungültiges DLC-Padding");
|
||||
}
|
||||
for (let index = 1; index <= pad; index += 1) {
|
||||
if (decrypted[decrypted.length - index] !== pad) {
|
||||
throw new Error("Ungültiges DLC-Padding");
|
||||
}
|
||||
}
|
||||
decrypted = decrypted.subarray(0, decrypted.length - pad);
|
||||
|
||||
const xmlData = Buffer.from(decrypted.toString("utf8"), "base64").toString("utf8");
|
||||
return parsePackagesFromDlcXml(xmlData);
|
||||
@@ -140,7 +159,7 @@ async function decryptDlcLocal(filePath: string): Promise<ParsedPackageInput[]>
|
||||
|
||||
async function decryptDlcViaDcrypt(filePath: string): Promise<ParsedPackageInput[]> {
|
||||
const fileName = path.basename(filePath);
|
||||
const blob = new Blob([fs.readFileSync(filePath)]);
|
||||
const blob = new Blob([new Uint8Array(readDlcFileWithLimit(filePath))]);
|
||||
const form = new FormData();
|
||||
form.set("dlcfile", blob, fileName);
|
||||
|
||||
|
||||
+29
-10
@@ -50,6 +50,27 @@ function retryDelay(attempt: number): number {
|
||||
return Math.min(5000, 400 * 2 ** attempt);
|
||||
}
|
||||
|
||||
function readHttpStatusFromErrorText(text: string): number {
|
||||
const match = String(text || "").match(/HTTP\s+(\d{3})/i);
|
||||
return match ? Number(match[1]) : 0;
|
||||
}
|
||||
|
||||
function isRetryableErrorText(text: string): boolean {
|
||||
const status = readHttpStatusFromErrorText(text);
|
||||
if (status === 429 || status >= 500) {
|
||||
return true;
|
||||
}
|
||||
const lower = String(text || "").toLowerCase();
|
||||
return lower.includes("timeout")
|
||||
|| lower.includes("network")
|
||||
|| lower.includes("fetch failed")
|
||||
|| lower.includes("aborted")
|
||||
|| lower.includes("econnreset")
|
||||
|| lower.includes("enotfound")
|
||||
|| lower.includes("etimedout")
|
||||
|| lower.includes("html statt json");
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
@@ -286,15 +307,12 @@ async function resolveRapidgatorFilename(link: string): Promise<string> {
|
||||
|
||||
function buildBestDebridRequests(link: string, token: string): BestDebridRequest[] {
|
||||
const linkParam = encodeURIComponent(link);
|
||||
const authParam = encodeURIComponent(token);
|
||||
const safeToken = String(token || "").trim();
|
||||
const useAuthHeader = Boolean(safeToken);
|
||||
return [
|
||||
{
|
||||
url: `${BEST_DEBRID_API_BASE}/generateLink?link=${linkParam}`,
|
||||
useAuthHeader: true
|
||||
},
|
||||
{
|
||||
url: `${BEST_DEBRID_API_BASE}/generateLink?auth=${authParam}&link=${linkParam}`,
|
||||
useAuthHeader: false
|
||||
useAuthHeader
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -402,7 +420,7 @@ class BestDebridClient {
|
||||
throw new Error("BestDebrid Antwort ohne Download-Link");
|
||||
} catch (error) {
|
||||
lastError = compactErrorText(error);
|
||||
if (attempt >= REQUEST_RETRIES) {
|
||||
if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) {
|
||||
break;
|
||||
}
|
||||
await sleep(retryDelay(attempt));
|
||||
@@ -490,7 +508,8 @@ class AllDebridClient {
|
||||
chunkResolved = true;
|
||||
break;
|
||||
} catch (error) {
|
||||
if (attempt >= REQUEST_RETRIES) {
|
||||
const errorText = compactErrorText(error);
|
||||
if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(errorText)) {
|
||||
throw error;
|
||||
}
|
||||
await sleep(retryDelay(attempt));
|
||||
@@ -579,7 +598,7 @@ class AllDebridClient {
|
||||
};
|
||||
} catch (error) {
|
||||
lastError = compactErrorText(error);
|
||||
if (attempt >= REQUEST_RETRIES) {
|
||||
if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) {
|
||||
break;
|
||||
}
|
||||
await sleep(retryDelay(attempt));
|
||||
@@ -738,7 +757,7 @@ export class DebridService {
|
||||
return Boolean(this.settings.token.trim());
|
||||
}
|
||||
if (provider === "megadebrid") {
|
||||
return Boolean(this.settings.megaLogin.trim() && this.settings.megaPassword.trim());
|
||||
return Boolean(this.settings.megaLogin.trim() && this.settings.megaPassword.trim() && this.options.megaWebUnrestrict);
|
||||
}
|
||||
if (provider === "alldebrid") {
|
||||
return Boolean(this.settings.allDebridToken.trim());
|
||||
|
||||
@@ -655,6 +655,9 @@ export class DownloadManager extends EventEmitter {
|
||||
this.reservedTargetPaths.clear();
|
||||
this.claimedTargetPathByItem.clear();
|
||||
this.itemContributedBytes.clear();
|
||||
this.speedEvents = [];
|
||||
this.speedEventsHead = 0;
|
||||
this.speedBytesLastWindow = 0;
|
||||
this.packagePostProcessTasks.clear();
|
||||
this.packagePostProcessAbortControllers.clear();
|
||||
this.hybridExtractRequeue.clear();
|
||||
@@ -798,11 +801,17 @@ export class DownloadManager extends EventEmitter {
|
||||
active.abortController.abort("cancel");
|
||||
}
|
||||
this.releaseTargetPath(itemId);
|
||||
this.runItemIds.delete(itemId);
|
||||
this.runOutcomes.delete(itemId);
|
||||
this.itemContributedBytes.delete(itemId);
|
||||
delete this.session.items[itemId];
|
||||
this.itemCount = Math.max(0, this.itemCount - 1);
|
||||
}
|
||||
delete this.session.packages[packageId];
|
||||
this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId);
|
||||
this.runPackageIds.delete(packageId);
|
||||
this.runCompletedPackages.delete(packageId);
|
||||
this.hybridExtractRequeue.delete(packageId);
|
||||
this.persistSoon();
|
||||
this.emitState(true);
|
||||
return { skipped: true, overwritten: false };
|
||||
@@ -846,6 +855,11 @@ export class DownloadManager extends EventEmitter {
|
||||
item.fullStatus = "Wartet";
|
||||
item.updatedAt = nowMs();
|
||||
item.targetPath = path.join(pkg.outputDir, sanitizeFilename(item.fileName || filenameFromUrl(item.url)));
|
||||
this.runOutcomes.delete(itemId);
|
||||
this.itemContributedBytes.delete(itemId);
|
||||
if (this.session.running) {
|
||||
this.runItemIds.add(itemId);
|
||||
}
|
||||
}
|
||||
pkg.status = "queued";
|
||||
pkg.updatedAt = nowMs();
|
||||
@@ -1294,6 +1308,7 @@ export class DownloadManager extends EventEmitter {
|
||||
this.session.reconnectReason = "";
|
||||
this.speedEvents = [];
|
||||
this.speedBytesLastWindow = 0;
|
||||
this.speedEventsHead = 0;
|
||||
this.lastGlobalProgressBytes = 0;
|
||||
this.lastGlobalProgressAt = nowMs();
|
||||
this.summary = null;
|
||||
@@ -1319,6 +1334,7 @@ export class DownloadManager extends EventEmitter {
|
||||
this.consecutiveReconnects = 0;
|
||||
this.speedEvents = [];
|
||||
this.speedBytesLastWindow = 0;
|
||||
this.speedEventsHead = 0;
|
||||
this.lastGlobalProgressBytes = 0;
|
||||
this.lastGlobalProgressAt = nowMs();
|
||||
this.globalSpeedLimitQueue = Promise.resolve();
|
||||
@@ -1326,7 +1342,13 @@ export class DownloadManager extends EventEmitter {
|
||||
this.summary = null;
|
||||
this.persistSoon();
|
||||
this.emitState(true);
|
||||
this.ensureScheduler();
|
||||
void this.ensureScheduler().catch((error) => {
|
||||
logger.error(`Scheduler abgestürzt: ${compactErrorText(error)}`);
|
||||
this.session.running = false;
|
||||
this.session.paused = false;
|
||||
this.persistSoon();
|
||||
this.emitState(true);
|
||||
});
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
@@ -1396,6 +1418,7 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
this.speedEvents = [];
|
||||
this.speedBytesLastWindow = 0;
|
||||
this.speedEventsHead = 0;
|
||||
this.runItemIds.clear();
|
||||
this.runPackageIds.clear();
|
||||
this.runOutcomes.clear();
|
||||
@@ -1599,6 +1622,9 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
private recordSpeed(bytes: number): void {
|
||||
const now = nowMs();
|
||||
if (bytes > 0 && this.consecutiveReconnects > 0) {
|
||||
this.consecutiveReconnects = 0;
|
||||
}
|
||||
const bucket = now - (now % 120);
|
||||
const last = this.speedEvents[this.speedEvents.length - 1];
|
||||
if (last && last.at === bucket) {
|
||||
@@ -3363,7 +3389,9 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
} catch (error) {
|
||||
const reasonRaw = String(error || "");
|
||||
if (reasonRaw.includes("aborted:extract") || reasonRaw.includes("extract_timeout")) {
|
||||
const isExtractAbort = reasonRaw.includes("aborted:extract") || reasonRaw.includes("extract_timeout");
|
||||
let timeoutHandled = false;
|
||||
if (isExtractAbort) {
|
||||
if (timedOut) {
|
||||
const timeoutReason = `Entpacken Timeout nach ${Math.ceil(extractTimeoutMs / 1000)}s`;
|
||||
logger.error(`Post-Processing Entpacken Timeout: pkg=${pkg.name}`);
|
||||
@@ -3373,6 +3401,7 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
pkg.status = "failed";
|
||||
pkg.updatedAt = nowMs();
|
||||
timeoutHandled = true;
|
||||
} else {
|
||||
for (const entry of completedItems) {
|
||||
if (/^Entpacken/i.test(entry.fullStatus || "")) {
|
||||
@@ -3386,13 +3415,15 @@ export class DownloadManager extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const reason = compactErrorText(error);
|
||||
logger.error(`Post-Processing Entpacken Exception: pkg=${pkg.name}, reason=${reason}`);
|
||||
for (const entry of completedItems) {
|
||||
entry.fullStatus = `Entpack-Fehler: ${reason}`;
|
||||
entry.updatedAt = nowMs();
|
||||
if (!timeoutHandled) {
|
||||
const reason = compactErrorText(error);
|
||||
logger.error(`Post-Processing Entpacken Exception: pkg=${pkg.name}, reason=${reason}`);
|
||||
for (const entry of completedItems) {
|
||||
entry.fullStatus = `Entpack-Fehler: ${reason}`;
|
||||
entry.updatedAt = nowMs();
|
||||
}
|
||||
pkg.status = "failed";
|
||||
}
|
||||
pkg.status = "failed";
|
||||
} finally {
|
||||
clearTimeout(extractDeadline);
|
||||
if (signal) {
|
||||
@@ -3500,6 +3531,9 @@ export class DownloadManager extends EventEmitter {
|
||||
this.reservedTargetPaths.clear();
|
||||
this.claimedTargetPathByItem.clear();
|
||||
this.itemContributedBytes.clear();
|
||||
this.speedEvents = [];
|
||||
this.speedEventsHead = 0;
|
||||
this.speedBytesLastWindow = 0;
|
||||
this.globalSpeedLimitQueue = Promise.resolve();
|
||||
this.globalSpeedLimitNextAt = 0;
|
||||
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
|
||||
|
||||
+66
-13
@@ -18,6 +18,7 @@ let resolveExtractorCommandInFlight: Promise<string> | null = null;
|
||||
|
||||
const EXTRACTOR_RETRY_AFTER_MS = 30_000;
|
||||
const DEFAULT_ZIP_ENTRY_MEMORY_LIMIT_MB = 256;
|
||||
const EXTRACTOR_PROBE_TIMEOUT_MS = 8_000;
|
||||
|
||||
export interface ExtractOptions {
|
||||
packageDir: string;
|
||||
@@ -63,6 +64,10 @@ export function pathSetKey(filePath: string): string {
|
||||
return process.platform === "win32" ? filePath.toLowerCase() : filePath;
|
||||
}
|
||||
|
||||
function archiveNameKey(fileName: string): string {
|
||||
return process.platform === "win32" ? String(fileName || "").toLowerCase() : String(fileName || "");
|
||||
}
|
||||
|
||||
function archiveSortKey(filePath: string): string {
|
||||
const fileName = path.basename(filePath).toLowerCase();
|
||||
return fileName
|
||||
@@ -244,7 +249,7 @@ function readExtractResumeState(packageDir: string, packageId?: string): Set<str
|
||||
try {
|
||||
const payload = JSON.parse(fs.readFileSync(progressPath, "utf8")) as Partial<ExtractResumeState>;
|
||||
const names = Array.isArray(payload.completedArchives) ? payload.completedArchives : [];
|
||||
return new Set(names.map((value) => String(value || "").trim()).filter(Boolean));
|
||||
return new Set(names.map((value) => archiveNameKey(String(value || "").trim())).filter(Boolean));
|
||||
} catch {
|
||||
return new Set<string>();
|
||||
}
|
||||
@@ -255,7 +260,9 @@ function writeExtractResumeState(packageDir: string, completedArchives: Set<stri
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
const progressPath = extractProgressFilePath(packageDir, packageId);
|
||||
const payload: ExtractResumeState = {
|
||||
completedArchives: Array.from(completedArchives).sort((a, b) => a.localeCompare(b))
|
||||
completedArchives: Array.from(completedArchives)
|
||||
.map((name) => archiveNameKey(name))
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
};
|
||||
fs.writeFileSync(progressPath, JSON.stringify(payload, null, 2), "utf8");
|
||||
} catch (error) {
|
||||
@@ -457,10 +464,24 @@ function runExtractCommand(
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (code === 0 || code === 1) {
|
||||
if (code === 0) {
|
||||
finish({ ok: true, missingCommand: false, aborted: false, timedOut: false, errorText: "" });
|
||||
return;
|
||||
}
|
||||
if (code === 1) {
|
||||
const lowered = output.toLowerCase();
|
||||
const warningOnly = !lowered.includes("crc failed")
|
||||
&& !lowered.includes("checksum error")
|
||||
&& !lowered.includes("wrong password")
|
||||
&& !lowered.includes("cannot open")
|
||||
&& !lowered.includes("fatal error")
|
||||
&& !lowered.includes("unexpected end of archive")
|
||||
&& !lowered.includes("error:");
|
||||
if (warningOnly) {
|
||||
finish({ ok: true, missingCommand: false, aborted: false, timedOut: false, errorText: "" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const cleaned = cleanErrorText(output);
|
||||
finish({
|
||||
ok: false,
|
||||
@@ -521,7 +542,7 @@ async function resolveExtractorCommandInternal(): Promise<string> {
|
||||
continue;
|
||||
}
|
||||
const probeArgs = command.toLowerCase().includes("winrar") ? ["-?"] : ["?"];
|
||||
const probe = await runExtractCommand(command, probeArgs);
|
||||
const probe = await runExtractCommand(command, probeArgs, undefined, undefined, EXTRACTOR_PROBE_TIMEOUT_MS);
|
||||
if (!probe.missingCommand) {
|
||||
resolvedExtractorCommand = command;
|
||||
resolveFailureReason = "";
|
||||
@@ -634,13 +655,35 @@ async function runExternalExtract(
|
||||
throw new Error(lastError || "Entpacken fehlgeschlagen");
|
||||
}
|
||||
|
||||
function extractZipArchive(archivePath: string, targetDir: string, conflictMode: ConflictMode): void {
|
||||
function isZipSafetyGuardError(error: unknown): boolean {
|
||||
const text = String(error || "").toLowerCase();
|
||||
return text.includes("zip-eintrag zu groß")
|
||||
|| text.includes("zip-eintrag komprimiert zu groß")
|
||||
|| text.includes("zip-eintrag ohne sichere groessenangabe")
|
||||
|| text.includes("path traversal");
|
||||
}
|
||||
|
||||
function shouldFallbackToExternalZip(error: unknown): boolean {
|
||||
if (isZipSafetyGuardError(error)) {
|
||||
return false;
|
||||
}
|
||||
const text = String(error || "").toLowerCase();
|
||||
if (text.includes("aborted:extract") || text.includes("extract_aborted")) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function extractZipArchive(archivePath: string, targetDir: string, conflictMode: ConflictMode, signal?: AbortSignal): void {
|
||||
const mode = effectiveConflictMode(conflictMode);
|
||||
const memoryLimitBytes = zipEntryMemoryLimitBytes();
|
||||
const zip = new AdmZip(archivePath);
|
||||
const entries = zip.getEntries();
|
||||
const resolvedTarget = path.resolve(targetDir);
|
||||
for (const entry of entries) {
|
||||
if (signal?.aborted) {
|
||||
throw new Error("aborted:extract");
|
||||
}
|
||||
const outputPath = path.resolve(targetDir, entry.entryName);
|
||||
if (!outputPath.startsWith(resolvedTarget + path.sep) && outputPath !== resolvedTarget) {
|
||||
logger.warn(`ZIP-Eintrag übersprungen (Path Traversal): ${entry.entryName}`);
|
||||
@@ -700,10 +743,16 @@ function extractZipArchive(archivePath: string, targetDir: string, conflictMode:
|
||||
candidate = path.join(parsed.dir, `${parsed.name} (${n})${parsed.ext}`);
|
||||
n += 1;
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
throw new Error("aborted:extract");
|
||||
}
|
||||
fs.writeFileSync(candidate, entry.getData());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
throw new Error("aborted:extract");
|
||||
}
|
||||
fs.writeFileSync(outputPath, entry.getData());
|
||||
}
|
||||
}
|
||||
@@ -945,7 +994,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
||||
let passwordCandidates = archivePasswords(options.passwordList || "");
|
||||
const resumeCompleted = readExtractResumeState(options.packageDir, options.packageId);
|
||||
const resumeCompletedAtStart = resumeCompleted.size;
|
||||
const allCandidateNames = new Set(allCandidates.map((archivePath) => path.basename(archivePath)));
|
||||
const allCandidateNames = new Set(allCandidates.map((archivePath) => archiveNameKey(path.basename(archivePath))));
|
||||
for (const archiveName of Array.from(resumeCompleted.values())) {
|
||||
if (!allCandidateNames.has(archiveName)) {
|
||||
resumeCompleted.delete(archiveName);
|
||||
@@ -957,13 +1006,13 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
||||
clearExtractResumeState(options.packageDir, options.packageId);
|
||||
}
|
||||
|
||||
const pendingCandidates = candidates.filter((archivePath) => !resumeCompleted.has(path.basename(archivePath)));
|
||||
const pendingCandidates = candidates.filter((archivePath) => !resumeCompleted.has(archiveNameKey(path.basename(archivePath))));
|
||||
let extracted = candidates.length - pendingCandidates.length;
|
||||
let failed = 0;
|
||||
let lastError = "";
|
||||
const extractedArchives = new Set<string>();
|
||||
for (const archivePath of candidates) {
|
||||
if (resumeCompleted.has(path.basename(archivePath))) {
|
||||
if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) {
|
||||
extractedArchives.add(archivePath);
|
||||
}
|
||||
}
|
||||
@@ -1002,7 +1051,8 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
||||
if (options.signal?.aborted) {
|
||||
throw new Error("aborted:extract");
|
||||
}
|
||||
const archiveName = path.basename(archivePath);
|
||||
const archiveName = path.basename(archivePath);
|
||||
const archiveResumeKey = archiveNameKey(archiveName);
|
||||
const archiveStartedAt = Date.now();
|
||||
let archivePercent = 0;
|
||||
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, 0);
|
||||
@@ -1023,16 +1073,19 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
||||
passwordCandidates = prioritizePassword(passwordCandidates, usedPassword);
|
||||
} catch (error) {
|
||||
if (isNoExtractorError(String(error))) {
|
||||
extractZipArchive(archivePath, options.targetDir, options.conflictMode);
|
||||
extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
extractZipArchive(archivePath, options.targetDir, options.conflictMode);
|
||||
extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal);
|
||||
archivePercent = 100;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (!shouldFallbackToExternalZip(error)) {
|
||||
throw error;
|
||||
}
|
||||
const usedPassword = await runExternalExtract(archivePath, options.targetDir, "overwrite", passwordCandidates, (value) => {
|
||||
archivePercent = Math.max(archivePercent, value);
|
||||
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
||||
@@ -1049,7 +1102,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
||||
}
|
||||
extracted += 1;
|
||||
extractedArchives.add(archivePath);
|
||||
resumeCompleted.add(archiveName);
|
||||
resumeCompleted.add(archiveResumeKey);
|
||||
writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId);
|
||||
logger.info(`Entpacken erfolgreich: ${path.basename(archivePath)}`);
|
||||
archivePercent = 100;
|
||||
|
||||
+16
-2
@@ -41,7 +41,17 @@ export function readHashManifest(packageDir: string): Map<string, ParsedHashEntr
|
||||
return map;
|
||||
}
|
||||
|
||||
for (const entry of fs.readdirSync(packageDir, { withFileTypes: true })) {
|
||||
const manifestFiles = fs.readdirSync(packageDir, { withFileTypes: true })
|
||||
.filter((entry) => {
|
||||
if (!entry.isFile()) {
|
||||
return false;
|
||||
}
|
||||
const ext = path.extname(entry.name).toLowerCase();
|
||||
return patterns.some(([pattern]) => pattern === ext);
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
|
||||
|
||||
for (const entry of manifestFiles) {
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
@@ -70,7 +80,11 @@ export function readHashManifest(packageDir: string): Map<string, ParsedHashEntr
|
||||
...parsed,
|
||||
algorithm: hit[1]
|
||||
};
|
||||
map.set(parsed.fileName.toLowerCase(), normalized);
|
||||
const key = parsed.fileName.toLowerCase();
|
||||
if (map.has(key)) {
|
||||
continue;
|
||||
}
|
||||
map.set(key, normalized);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
|
||||
+10
-5
@@ -5,6 +5,7 @@ import { AppController } from "./app-controller";
|
||||
import { IPC_CHANNELS } from "../shared/ipc";
|
||||
import { logger } from "./logger";
|
||||
import { APP_NAME } from "./constants";
|
||||
import { extractHttpLinksFromText } from "./utils";
|
||||
|
||||
/* ── IPC validation helpers ────────────────────────────────────── */
|
||||
function validateString(value: unknown, name: string): string {
|
||||
@@ -39,6 +40,7 @@ let tray: Tray | null = null;
|
||||
let clipboardTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let lastClipboardText = "";
|
||||
const controller = new AppController();
|
||||
const CLIPBOARD_MAX_TEXT_CHARS = 50_000;
|
||||
|
||||
function isDevMode(): boolean {
|
||||
return process.env.NODE_ENV === "development";
|
||||
@@ -115,21 +117,24 @@ function destroyTray(): void {
|
||||
}
|
||||
|
||||
function extractLinksFromText(text: string): string[] {
|
||||
const matches = text.match(/https?:\/\/[^\s<>"']+/gi);
|
||||
return matches ? Array.from(new Set(matches)) : [];
|
||||
return extractHttpLinksFromText(text);
|
||||
}
|
||||
|
||||
function normalizeClipboardText(text: string): string {
|
||||
return String(text || "").slice(0, CLIPBOARD_MAX_TEXT_CHARS);
|
||||
}
|
||||
|
||||
function startClipboardWatcher(): void {
|
||||
if (clipboardTimer) {
|
||||
return;
|
||||
}
|
||||
lastClipboardText = clipboard.readText().slice(0, 50000);
|
||||
lastClipboardText = normalizeClipboardText(clipboard.readText());
|
||||
clipboardTimer = setInterval(() => {
|
||||
const text = clipboard.readText();
|
||||
const text = normalizeClipboardText(clipboard.readText());
|
||||
if (text === lastClipboardText || !text.trim()) {
|
||||
return;
|
||||
}
|
||||
lastClipboardText = text.slice(0, 50000);
|
||||
lastClipboardText = text;
|
||||
const links = extractLinksFromText(text);
|
||||
if (links.length > 0 && mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.CLIPBOARD_DETECTED, links);
|
||||
|
||||
+19
-3
@@ -16,7 +16,18 @@ function retryDelay(attempt: number): number {
|
||||
return Math.min(5000, 400 * 2 ** attempt);
|
||||
}
|
||||
|
||||
function parseErrorBody(status: number, body: string): string {
|
||||
function looksLikeHtmlResponse(contentType: string, body: string): boolean {
|
||||
const type = String(contentType || "").toLowerCase();
|
||||
if (type.includes("text/html") || type.includes("application/xhtml+xml")) {
|
||||
return true;
|
||||
}
|
||||
return /^\s*<(!doctype\s+html|html\b)/i.test(String(body || ""));
|
||||
}
|
||||
|
||||
function parseErrorBody(status: number, body: string, contentType: string): string {
|
||||
if (looksLikeHtmlResponse(contentType, body)) {
|
||||
return `Real-Debrid lieferte HTML statt JSON (HTTP ${status})`;
|
||||
}
|
||||
const clean = compactErrorText(body);
|
||||
return clean || `HTTP ${status}`;
|
||||
}
|
||||
@@ -45,8 +56,9 @@ export class RealDebridClient {
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
const contentType = String(response.headers.get("content-type") || "");
|
||||
if (!response.ok) {
|
||||
const parsed = parseErrorBody(response.status, text);
|
||||
const parsed = parseErrorBody(response.status, text, contentType);
|
||||
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
|
||||
await sleep(retryDelay(attempt));
|
||||
continue;
|
||||
@@ -54,11 +66,15 @@ export class RealDebridClient {
|
||||
throw new Error(parsed);
|
||||
}
|
||||
|
||||
if (looksLikeHtmlResponse(contentType, text)) {
|
||||
throw new Error("Real-Debrid lieferte HTML statt JSON");
|
||||
}
|
||||
|
||||
let payload: Record<string, unknown>;
|
||||
try {
|
||||
payload = JSON.parse(text) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new Error(`Ungültige JSON-Antwort: ${text.slice(0, 120)}`);
|
||||
throw new Error("Ungültige JSON-Antwort von Real-Debrid");
|
||||
}
|
||||
const directUrl = String(payload.download || payload.link || "").trim();
|
||||
if (!directUrl) {
|
||||
|
||||
+189
-36
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { AppSettings, BandwidthScheduleEntry, SessionState } from "../shared/types";
|
||||
import { AppSettings, BandwidthScheduleEntry, DebridProvider, DownloadItem, DownloadStatus, PackageEntry, SessionState } from "../shared/types";
|
||||
import { defaultSettings } from "./constants";
|
||||
import { logger } from "./logger";
|
||||
|
||||
@@ -12,6 +12,10 @@ const VALID_CONFLICT_MODES = new Set(["overwrite", "skip", "rename", "ask"]);
|
||||
const VALID_FINISHED_POLICIES = new Set(["never", "immediate", "on_start", "package_done"]);
|
||||
const VALID_SPEED_MODES = new Set(["global", "per_download"]);
|
||||
const VALID_THEMES = new Set(["dark", "light"]);
|
||||
const VALID_DOWNLOAD_STATUSES = new Set<DownloadStatus>([
|
||||
"queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled"
|
||||
]);
|
||||
const VALID_ITEM_PROVIDERS = new Set<DebridProvider>(["realdebrid", "megadebrid", "bestdebrid", "alldebrid"]);
|
||||
|
||||
function asText(value: unknown): string {
|
||||
return String(value ?? "").trim();
|
||||
@@ -148,24 +152,171 @@ function ensureBaseDir(baseDir: string): void {
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
}
|
||||
|
||||
export function loadSettings(paths: StoragePaths): AppSettings {
|
||||
ensureBaseDir(paths.baseDir);
|
||||
if (!fs.existsSync(paths.configFile)) {
|
||||
return defaultSettings();
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function readSettingsFile(filePath: string): AppSettings | null {
|
||||
try {
|
||||
// Safe: parsed is spread into a fresh object with defaults first, and normalizeSettings
|
||||
// validates every field, so prototype pollution via __proto__ / constructor is not a concern.
|
||||
const parsed = JSON.parse(fs.readFileSync(paths.configFile, "utf8")) as AppSettings;
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as AppSettings;
|
||||
const merged = normalizeSettings({
|
||||
...defaultSettings(),
|
||||
...parsed
|
||||
});
|
||||
return sanitizeCredentialPersistence(merged);
|
||||
} catch (error) {
|
||||
logger.error(`Konfiguration konnte nicht geladen werden: ${String(error)}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLoadedSession(raw: unknown): SessionState {
|
||||
const fallback = emptySession();
|
||||
const parsed = asRecord(raw);
|
||||
if (!parsed) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const itemsById: Record<string, DownloadItem> = {};
|
||||
const rawItems = asRecord(parsed.items) ?? {};
|
||||
for (const [entryId, rawItem] of Object.entries(rawItems)) {
|
||||
const item = asRecord(rawItem);
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
const id = asText(item.id) || entryId;
|
||||
const packageId = asText(item.packageId);
|
||||
const url = asText(item.url);
|
||||
if (!id || !packageId || !url) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const statusRaw = asText(item.status) as DownloadStatus;
|
||||
const status: DownloadStatus = VALID_DOWNLOAD_STATUSES.has(statusRaw) ? statusRaw : "queued";
|
||||
const providerRaw = asText(item.provider) as DebridProvider;
|
||||
|
||||
itemsById[id] = {
|
||||
id,
|
||||
packageId,
|
||||
url,
|
||||
provider: VALID_ITEM_PROVIDERS.has(providerRaw) ? providerRaw : null,
|
||||
status,
|
||||
retries: clampNumber(item.retries, 0, 0, 1_000_000),
|
||||
speedBps: clampNumber(item.speedBps, 0, 0, 10_000_000_000),
|
||||
downloadedBytes: clampNumber(item.downloadedBytes, 0, 0, 10_000_000_000_000),
|
||||
totalBytes: item.totalBytes == null ? null : clampNumber(item.totalBytes, 0, 0, 10_000_000_000_000),
|
||||
progressPercent: clampNumber(item.progressPercent, 0, 0, 100),
|
||||
fileName: asText(item.fileName) || "download.bin",
|
||||
targetPath: asText(item.targetPath),
|
||||
resumable: item.resumable === undefined ? true : Boolean(item.resumable),
|
||||
attempts: clampNumber(item.attempts, 0, 0, 10_000),
|
||||
lastError: asText(item.lastError),
|
||||
fullStatus: asText(item.fullStatus),
|
||||
createdAt: clampNumber(item.createdAt, now, 0, Number.MAX_SAFE_INTEGER),
|
||||
updatedAt: clampNumber(item.updatedAt, now, 0, Number.MAX_SAFE_INTEGER)
|
||||
};
|
||||
}
|
||||
|
||||
const packagesById: Record<string, PackageEntry> = {};
|
||||
const rawPackages = asRecord(parsed.packages) ?? {};
|
||||
for (const [entryId, rawPkg] of Object.entries(rawPackages)) {
|
||||
const pkg = asRecord(rawPkg);
|
||||
if (!pkg) {
|
||||
continue;
|
||||
}
|
||||
const id = asText(pkg.id) || entryId;
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
const statusRaw = asText(pkg.status) as DownloadStatus;
|
||||
const status: DownloadStatus = VALID_DOWNLOAD_STATUSES.has(statusRaw) ? statusRaw : "queued";
|
||||
const rawItemIds = Array.isArray(pkg.itemIds) ? pkg.itemIds : [];
|
||||
packagesById[id] = {
|
||||
id,
|
||||
name: asText(pkg.name) || "Paket",
|
||||
outputDir: asText(pkg.outputDir),
|
||||
extractDir: asText(pkg.extractDir),
|
||||
status,
|
||||
itemIds: rawItemIds
|
||||
.map((value) => asText(value))
|
||||
.filter((value) => value.length > 0),
|
||||
cancelled: Boolean(pkg.cancelled),
|
||||
enabled: pkg.enabled === undefined ? true : Boolean(pkg.enabled),
|
||||
createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER),
|
||||
updatedAt: clampNumber(pkg.updatedAt, now, 0, Number.MAX_SAFE_INTEGER)
|
||||
};
|
||||
}
|
||||
|
||||
for (const [itemId, item] of Object.entries(itemsById)) {
|
||||
if (!packagesById[item.packageId]) {
|
||||
delete itemsById[itemId];
|
||||
}
|
||||
}
|
||||
|
||||
for (const pkg of Object.values(packagesById)) {
|
||||
pkg.itemIds = pkg.itemIds.filter((itemId) => {
|
||||
const item = itemsById[itemId];
|
||||
return Boolean(item) && item.packageId === pkg.id;
|
||||
});
|
||||
}
|
||||
|
||||
const rawOrder = Array.isArray(parsed.packageOrder) ? parsed.packageOrder : [];
|
||||
const packageOrder = rawOrder
|
||||
.map((entry) => asText(entry))
|
||||
.filter((id) => id in packagesById);
|
||||
for (const packageId of Object.keys(packagesById)) {
|
||||
if (!packageOrder.includes(packageId)) {
|
||||
packageOrder.push(packageId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...fallback,
|
||||
version: clampNumber(parsed.version, fallback.version, 1, 10),
|
||||
packageOrder,
|
||||
packages: packagesById,
|
||||
items: itemsById,
|
||||
runStartedAt: clampNumber(parsed.runStartedAt, 0, 0, Number.MAX_SAFE_INTEGER),
|
||||
totalDownloadedBytes: clampNumber(parsed.totalDownloadedBytes, 0, 0, Number.MAX_SAFE_INTEGER),
|
||||
summaryText: asText(parsed.summaryText),
|
||||
reconnectUntil: clampNumber(parsed.reconnectUntil, 0, 0, Number.MAX_SAFE_INTEGER),
|
||||
reconnectReason: asText(parsed.reconnectReason),
|
||||
paused: Boolean(parsed.paused),
|
||||
running: Boolean(parsed.running),
|
||||
updatedAt: clampNumber(parsed.updatedAt, now, 0, Number.MAX_SAFE_INTEGER)
|
||||
};
|
||||
}
|
||||
|
||||
export function loadSettings(paths: StoragePaths): AppSettings {
|
||||
ensureBaseDir(paths.baseDir);
|
||||
if (!fs.existsSync(paths.configFile)) {
|
||||
return defaultSettings();
|
||||
}
|
||||
const loaded = readSettingsFile(paths.configFile);
|
||||
if (loaded) {
|
||||
return loaded;
|
||||
}
|
||||
|
||||
const backupFile = `${paths.configFile}.bak`;
|
||||
const backupLoaded = fs.existsSync(backupFile) ? readSettingsFile(backupFile) : null;
|
||||
if (backupLoaded) {
|
||||
logger.warn("Konfiguration defekt, Backup-Datei wird verwendet");
|
||||
try {
|
||||
const payload = JSON.stringify(backupLoaded, null, 2);
|
||||
const tempPath = `${paths.configFile}.tmp`;
|
||||
fs.writeFileSync(tempPath, payload, "utf8");
|
||||
syncRenameWithExdevFallback(tempPath, paths.configFile);
|
||||
} catch {
|
||||
// ignore restore write failure
|
||||
}
|
||||
return backupLoaded;
|
||||
}
|
||||
|
||||
logger.error("Konfiguration konnte nicht geladen werden (auch Backup fehlgeschlagen)");
|
||||
return defaultSettings();
|
||||
}
|
||||
|
||||
function syncRenameWithExdevFallback(tempPath: string, targetPath: string): void {
|
||||
@@ -221,14 +372,8 @@ export function loadSession(paths: StoragePaths): SessionState {
|
||||
return emptySession();
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(paths.sessionFile, "utf8")) as Partial<SessionState>;
|
||||
const session: SessionState = {
|
||||
...emptySession(),
|
||||
...parsed,
|
||||
packages: parsed.packages ?? {},
|
||||
items: parsed.items ?? {},
|
||||
packageOrder: parsed.packageOrder ?? []
|
||||
};
|
||||
const parsed = JSON.parse(fs.readFileSync(paths.sessionFile, "utf8")) as unknown;
|
||||
const session = normalizeLoadedSession(parsed);
|
||||
|
||||
// Reset transient fields that may be stale from a previous crash
|
||||
const ACTIVE_STATUSES = new Set(["downloading", "validating", "extracting", "integrity_check", "paused", "reconnect_wait"]);
|
||||
@@ -257,29 +402,32 @@ export function saveSession(paths: StoragePaths, session: SessionState): void {
|
||||
}
|
||||
|
||||
let asyncSaveRunning = false;
|
||||
let asyncSaveQueued: { paths: StoragePaths; session: SessionState } | null = null;
|
||||
let asyncSaveQueued: { paths: StoragePaths; payload: string } | null = null;
|
||||
|
||||
export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise<void> {
|
||||
async function writeSessionPayload(paths: StoragePaths, payload: string): Promise<void> {
|
||||
await fs.promises.mkdir(paths.baseDir, { recursive: true });
|
||||
const tempPath = `${paths.sessionFile}.tmp`;
|
||||
await fsp.writeFile(tempPath, payload, "utf8");
|
||||
try {
|
||||
await fsp.rename(tempPath, paths.sessionFile);
|
||||
} catch (renameError: unknown) {
|
||||
if (renameError && typeof renameError === "object" && "code" in renameError && (renameError as NodeJS.ErrnoException).code === "EXDEV") {
|
||||
await fsp.copyFile(tempPath, paths.sessionFile);
|
||||
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
||||
} else {
|
||||
throw renameError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSessionPayloadAsync(paths: StoragePaths, payload: string): Promise<void> {
|
||||
if (asyncSaveRunning) {
|
||||
asyncSaveQueued = { paths, session };
|
||||
asyncSaveQueued = { paths, payload };
|
||||
return;
|
||||
}
|
||||
asyncSaveRunning = true;
|
||||
try {
|
||||
await fs.promises.mkdir(paths.baseDir, { recursive: true });
|
||||
const payload = JSON.stringify({ ...session, updatedAt: Date.now() });
|
||||
const tempPath = `${paths.sessionFile}.tmp`;
|
||||
await fsp.writeFile(tempPath, payload, "utf8");
|
||||
try {
|
||||
await fsp.rename(tempPath, paths.sessionFile);
|
||||
} catch (renameError: unknown) {
|
||||
if (renameError && typeof renameError === "object" && "code" in renameError && (renameError as NodeJS.ErrnoException).code === "EXDEV") {
|
||||
await fsp.copyFile(tempPath, paths.sessionFile);
|
||||
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
||||
} else {
|
||||
throw renameError;
|
||||
}
|
||||
}
|
||||
await writeSessionPayload(paths, payload);
|
||||
} catch (error) {
|
||||
logger.error(`Async Session-Save fehlgeschlagen: ${String(error)}`);
|
||||
} finally {
|
||||
@@ -287,7 +435,12 @@ export async function saveSessionAsync(paths: StoragePaths, session: SessionStat
|
||||
if (asyncSaveQueued) {
|
||||
const queued = asyncSaveQueued;
|
||||
asyncSaveQueued = null;
|
||||
void saveSessionAsync(queued.paths, queued.session);
|
||||
void saveSessionPayloadAsync(queued.paths, queued.payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise<void> {
|
||||
const payload = JSON.stringify({ ...session, updatedAt: Date.now() });
|
||||
await saveSessionPayloadAsync(paths, payload);
|
||||
}
|
||||
|
||||
+97
-9
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
import { Readable } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
@@ -20,6 +21,7 @@ const UPDATE_USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`;
|
||||
type ReleaseAsset = {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
digest: string;
|
||||
};
|
||||
|
||||
export function normalizeUpdateRepo(repo: string): string {
|
||||
@@ -28,6 +30,17 @@ export function normalizeUpdateRepo(repo: string): string {
|
||||
return DEFAULT_UPDATE_REPO;
|
||||
}
|
||||
|
||||
const isValidRepoPart = (value: string): boolean => {
|
||||
const part = String(value || "").trim();
|
||||
if (!part || part === "." || part === "..") {
|
||||
return false;
|
||||
}
|
||||
if (part.includes("..")) {
|
||||
return false;
|
||||
}
|
||||
return /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(part);
|
||||
};
|
||||
|
||||
const normalizeParts = (input: string): string => {
|
||||
const cleaned = input
|
||||
.replace(/^https?:\/\/(?:www\.)?github\.com\//i, "")
|
||||
@@ -37,7 +50,11 @@ export function normalizeUpdateRepo(repo: string): string {
|
||||
.replace(/^\/+|\/+$/g, "");
|
||||
const parts = cleaned.split("/").filter(Boolean);
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0]}/${parts[1]}`;
|
||||
const owner = parts[0];
|
||||
const repository = parts[1];
|
||||
if (isValidRepoPart(owner) && isValidRepoPart(repository)) {
|
||||
return `${owner}/${repository}`;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
@@ -70,6 +87,31 @@ function timeoutController(ms: number): { signal: AbortSignal; clear: () => void
|
||||
};
|
||||
}
|
||||
|
||||
async function readJsonWithTimeout(response: Response, timeoutMs: number): Promise<Record<string, unknown> | null> {
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
void response.body?.cancel().catch(() => undefined);
|
||||
reject(new Error(`timeout:${timeoutMs}`));
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
const payload = await Promise.race([
|
||||
response.json().catch(() => null) as Promise<unknown>,
|
||||
timeoutPromise
|
||||
]);
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
return null;
|
||||
}
|
||||
return payload as Record<string, unknown>;
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -116,7 +158,8 @@ function readReleaseAssets(payload: Record<string, unknown>): ReleaseAsset[] {
|
||||
return assets
|
||||
.map((asset) => ({
|
||||
name: String(asset.name || ""),
|
||||
browser_download_url: String(asset.browser_download_url || "")
|
||||
browser_download_url: String(asset.browser_download_url || ""),
|
||||
digest: String(asset.digest || "").trim()
|
||||
}))
|
||||
.filter((asset) => asset.name && asset.browser_download_url);
|
||||
}
|
||||
@@ -145,10 +188,15 @@ function parseReleasePayload(payload: Record<string, unknown>, fallback: UpdateC
|
||||
latestTag,
|
||||
releaseUrl,
|
||||
setupAssetUrl: setup?.browser_download_url || "",
|
||||
setupAssetName: setup?.name || ""
|
||||
setupAssetName: setup?.name || "",
|
||||
setupAssetDigest: setup?.digest || ""
|
||||
};
|
||||
}
|
||||
|
||||
function isDraftOrPrereleaseRelease(payload: Record<string, unknown>): boolean {
|
||||
return Boolean(payload.draft) || Boolean(payload.prerelease);
|
||||
}
|
||||
|
||||
async function fetchReleasePayload(safeRepo: string, endpoint: string): Promise<{ ok: boolean; status: number; payload: Record<string, unknown> | null }> {
|
||||
const timeout = timeoutController(RELEASE_FETCH_TIMEOUT_MS);
|
||||
let response: Response;
|
||||
@@ -164,7 +212,7 @@ async function fetchReleasePayload(safeRepo: string, endpoint: string): Promise<
|
||||
timeout.clear();
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => null) as Record<string, unknown> | null;
|
||||
const payload = await readJsonWithTimeout(response, RELEASE_FETCH_TIMEOUT_MS);
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
@@ -245,7 +293,40 @@ function deriveUpdateFileName(check: UpdateCheckResult, url: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveSetupAssetFromApi(safeRepo: string, tagHint: string): Promise<{ setupAssetUrl: string; setupAssetName: string } | null> {
|
||||
function normalizeSha256Digest(raw: string): string {
|
||||
const text = String(raw || "").trim();
|
||||
const prefixed = text.match(/^sha256:([a-fA-F0-9]{64})$/i);
|
||||
if (prefixed) {
|
||||
return prefixed[1].toLowerCase();
|
||||
}
|
||||
const plain = text.match(/^([a-fA-F0-9]{64})$/);
|
||||
return plain ? plain[1].toLowerCase() : "";
|
||||
}
|
||||
|
||||
async function sha256File(filePath: string): Promise<string> {
|
||||
const hash = crypto.createHash("sha256");
|
||||
const stream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 });
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
stream.on("data", (chunk: string | Buffer) => {
|
||||
hash.update(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
||||
});
|
||||
stream.on("error", reject);
|
||||
stream.on("end", () => resolve(hash.digest("hex").toLowerCase()));
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyDownloadedInstaller(targetPath: string, expectedDigestRaw: string): Promise<void> {
|
||||
const expectedDigest = normalizeSha256Digest(expectedDigestRaw);
|
||||
if (!expectedDigest) {
|
||||
throw new Error("Update-Asset ohne gültigen SHA256-Digest");
|
||||
}
|
||||
const actualDigest = await sha256File(targetPath);
|
||||
if (actualDigest !== expectedDigest) {
|
||||
throw new Error("Update-Integritätsprüfung fehlgeschlagen (SHA256 mismatch)");
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveSetupAssetFromApi(safeRepo: string, tagHint: string): Promise<{ setupAssetUrl: string; setupAssetName: string; setupAssetDigest: string } | null> {
|
||||
const endpointCandidates = uniqueStrings([
|
||||
tagHint ? `releases/tags/${encodeURIComponent(tagHint)}` : "",
|
||||
"releases/latest"
|
||||
@@ -257,13 +338,17 @@ async function resolveSetupAssetFromApi(safeRepo: string, tagHint: string): Prom
|
||||
if (!release.ok || !release.payload) {
|
||||
continue;
|
||||
}
|
||||
if (isDraftOrPrereleaseRelease(release.payload)) {
|
||||
continue;
|
||||
}
|
||||
const setup = pickSetupAsset(readReleaseAssets(release.payload));
|
||||
if (!setup) {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
setupAssetUrl: setup.browser_download_url,
|
||||
setupAssetName: setup.name
|
||||
setupAssetName: setup.name,
|
||||
setupAssetDigest: setup.digest
|
||||
};
|
||||
} catch {
|
||||
// ignore and continue with next endpoint candidate
|
||||
@@ -433,16 +518,18 @@ export async function installLatestUpdate(repo: string, prechecked?: UpdateCheck
|
||||
let effectiveCheck: UpdateCheckResult = {
|
||||
...check,
|
||||
setupAssetUrl: String(check.setupAssetUrl || ""),
|
||||
setupAssetName: String(check.setupAssetName || "")
|
||||
setupAssetName: String(check.setupAssetName || ""),
|
||||
setupAssetDigest: String(check.setupAssetDigest || "")
|
||||
};
|
||||
|
||||
if (!effectiveCheck.setupAssetUrl) {
|
||||
if (!effectiveCheck.setupAssetUrl || !effectiveCheck.setupAssetDigest) {
|
||||
const refreshed = await resolveSetupAssetFromApi(safeRepo, effectiveCheck.latestTag);
|
||||
if (refreshed) {
|
||||
effectiveCheck = {
|
||||
...effectiveCheck,
|
||||
setupAssetUrl: refreshed.setupAssetUrl,
|
||||
setupAssetName: refreshed.setupAssetName
|
||||
setupAssetName: refreshed.setupAssetName,
|
||||
setupAssetDigest: refreshed.setupAssetDigest
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -457,6 +544,7 @@ export async function installLatestUpdate(repo: string, prechecked?: UpdateCheck
|
||||
|
||||
try {
|
||||
await downloadFromCandidates(candidates, targetPath);
|
||||
await verifyDownloadedInstaller(targetPath, String(effectiveCheck.setupAssetDigest || ""));
|
||||
const child = spawn(targetPath, [], {
|
||||
detached: true,
|
||||
stdio: "ignore"
|
||||
|
||||
@@ -63,6 +63,33 @@ export function isHttpLink(value: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export function extractHttpLinksFromText(text: string): string[] {
|
||||
const matches = String(text || "").match(/https?:\/\/[^\s<>"']+/gi) ?? [];
|
||||
const seen = new Set<string>();
|
||||
const links: string[] = [];
|
||||
|
||||
for (const match of matches) {
|
||||
let candidate = String(match || "").trim();
|
||||
while (candidate.length > 0 && /[)\],.!?;:]+$/.test(candidate)) {
|
||||
if (candidate.endsWith(")")) {
|
||||
const openCount = (candidate.match(/\(/g) || []).length;
|
||||
const closeCount = (candidate.match(/\)/g) || []).length;
|
||||
if (closeCount <= openCount) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
candidate = candidate.slice(0, -1);
|
||||
}
|
||||
if (!candidate || !isHttpLink(candidate) || seen.has(candidate)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(candidate);
|
||||
links.push(candidate);
|
||||
}
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
export function humanSize(bytes: number): string {
|
||||
const value = Number(bytes);
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
@@ -84,6 +111,9 @@ export function humanSize(bytes: number): string {
|
||||
export function filenameFromUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return "download.bin";
|
||||
}
|
||||
const queryName = parsed.searchParams.get("filename")
|
||||
|| parsed.searchParams.get("file")
|
||||
|| parsed.searchParams.get("name")
|
||||
|
||||
+19
-7
@@ -128,6 +128,7 @@ export function App(): ReactElement {
|
||||
const [activeCollectorTab, setActiveCollectorTab] = useState(collectorTabs[0].id);
|
||||
const activeCollectorTabRef = useRef(activeCollectorTab);
|
||||
const activeTabRef = useRef<Tab>(tab);
|
||||
const packageOrderRef = useRef<string[]>([]);
|
||||
const draggedPackageIdRef = useRef<string | null>(null);
|
||||
const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
|
||||
const [downloadSearch, setDownloadSearch] = useState("");
|
||||
@@ -150,6 +151,10 @@ export function App(): ReactElement {
|
||||
activeTabRef.current = tab;
|
||||
}, [tab]);
|
||||
|
||||
useEffect(() => {
|
||||
packageOrderRef.current = snapshot.session.packageOrder;
|
||||
}, [snapshot.session.packageOrder]);
|
||||
|
||||
const showToast = (message: string, timeoutMs = 2200): void => {
|
||||
setStatusToast(message);
|
||||
if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); }
|
||||
@@ -647,24 +652,28 @@ export function App(): ReactElement {
|
||||
};
|
||||
|
||||
const movePackage = useCallback((packageId: string, direction: "up" | "down") => {
|
||||
const order = [...snapshot.session.packageOrder];
|
||||
const currentOrder = packageOrderRef.current;
|
||||
const order = [...currentOrder];
|
||||
const idx = order.indexOf(packageId);
|
||||
if (idx < 0) { return; }
|
||||
const target = direction === "up" ? idx - 1 : idx + 1;
|
||||
if (target < 0 || target >= order.length) { return; }
|
||||
[order[idx], order[target]] = [order[target], order[idx]];
|
||||
packageOrderRef.current = order;
|
||||
void window.rd.reorderPackages(order);
|
||||
}, [snapshot.session.packageOrder]);
|
||||
}, []);
|
||||
|
||||
const reorderPackagesByDrop = useCallback((draggedPackageId: string, targetPackageId: string) => {
|
||||
const nextOrder = reorderPackageOrderByDrop(snapshot.session.packageOrder, draggedPackageId, targetPackageId);
|
||||
const unchanged = nextOrder.length === snapshot.session.packageOrder.length
|
||||
&& nextOrder.every((id, index) => id === snapshot.session.packageOrder[index]);
|
||||
const currentOrder = packageOrderRef.current;
|
||||
const nextOrder = reorderPackageOrderByDrop(currentOrder, draggedPackageId, targetPackageId);
|
||||
const unchanged = nextOrder.length === currentOrder.length
|
||||
&& nextOrder.every((id, index) => id === currentOrder[index]);
|
||||
if (unchanged) {
|
||||
return;
|
||||
}
|
||||
packageOrderRef.current = nextOrder;
|
||||
void window.rd.reorderPackages(nextOrder);
|
||||
}, [snapshot.session.packageOrder]);
|
||||
}, []);
|
||||
|
||||
const addCollectorTab = (): void => {
|
||||
const id = `tab-${nextCollectorId++}`;
|
||||
@@ -888,7 +897,9 @@ export function App(): ReactElement {
|
||||
onClick={() => {
|
||||
const nextDescending = !downloadsSortDescending;
|
||||
setDownloadsSortDescending(nextDescending);
|
||||
const sorted = sortPackageOrderByName(snapshot.session.packageOrder, snapshot.session.packages, nextDescending);
|
||||
const baseOrder = packageOrderRef.current.length > 0 ? packageOrderRef.current : snapshot.session.packageOrder;
|
||||
const sorted = sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDescending);
|
||||
packageOrderRef.current = sorted;
|
||||
void window.rd.reorderPackages(sorted);
|
||||
}}
|
||||
>
|
||||
@@ -1021,6 +1032,7 @@ export function App(): ReactElement {
|
||||
</div>
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoExtract} onChange={(e) => setBool("autoExtract", e.target.checked)} /> Auto-Extract</label>
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoRename4sf4sj} onChange={(e) => setBool("autoRename4sf4sj", e.target.checked)} /> Auto-Rename (4SF/4SJ)</label>
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.createExtractSubfolder} onChange={(e) => setBool("createExtractSubfolder", e.target.checked)} /> Entpackte Dateien in Paket-Unterordner speichern</label>
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.hybridExtract} onChange={(e) => setBool("hybridExtract", e.target.checked)} /> Hybrid-Extract</label>
|
||||
<label>Passwortliste (eine Zeile pro Passwort)</label>
|
||||
<textarea
|
||||
|
||||
+16
-1
@@ -278,12 +278,22 @@ body,
|
||||
|
||||
.downloads-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.downloads-toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.downloads-toolbar .search-input {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: min(360px, 100%);
|
||||
background: var(--field);
|
||||
@@ -729,6 +739,11 @@ td {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.downloads-toolbar .search-input {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.settings-toolbar-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
+2
-1
@@ -19,7 +19,7 @@ export type DebridFallbackProvider = DebridProvider | "none";
|
||||
export type AppTheme = "dark" | "light";
|
||||
|
||||
export interface BandwidthScheduleEntry {
|
||||
id?: string;
|
||||
id: string;
|
||||
startHour: number;
|
||||
endHour: number;
|
||||
speedLimitKbps: number;
|
||||
@@ -200,6 +200,7 @@ export interface UpdateCheckResult {
|
||||
releaseUrl: string;
|
||||
setupAssetUrl?: string;
|
||||
setupAssetName?: string;
|
||||
setupAssetDigest?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user