real-debrid-downloader/src/main/container.ts
Sucukdeluxe a1c8f42435 Comprehensive bugfix release v1.6.45
Fix ~70 issues across the entire codebase including security fixes,
error handling improvements, test stabilization, and code quality.

- Fix TLS race condition with reference-counted acquire/release
- Bind debug server to 127.0.0.1 instead of 0.0.0.0
- Add overall timeout to MegaWebFallback
- Stream update installer to disk instead of RAM buffering
- Add path traversal protection in JVM extractor
- Cache DdownloadClient with credential-based invalidation
- Add .catch() to all fire-and-forget IPC calls
- Wrap app startup, clipboard, session-log in try/catch
- Add timeouts to container.ts fetch calls
- Fix variable shadowing, tsconfig path, line endings
- Stabilize tests with proper cleanup and timing tolerance
- Fix installer privileges, scripts, and afterPack null checks
- Delete obsolete _upload_release.mjs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 03:53:28 +01:00

320 lines
10 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { DCRYPT_PASTE_URL, DCRYPT_UPLOAD_URL, DLC_AES_IV, DLC_AES_KEY, DLC_SERVICE_URL } from "./constants";
import { compactErrorText, inferPackageNameFromLinks, isHttpLink, sanitizeFilename, uniquePreserveOrder } from "./utils";
import { ParsedPackageInput } from "../shared/types";
const MAX_DLC_FILE_BYTES = 8 * 1024 * 1024;
function isContainerSizeValidationError(error: unknown): boolean {
const text = compactErrorText(error);
return /zu groß/i.test(text) || /DLC-Datei ungültig oder zu groß/i.test(text);
}
function decodeDcryptPayload(responseText: string): unknown {
let text = String(responseText || "").trim();
const m = text.match(/<textarea[^>]*>([\s\S]*?)<\/textarea>/i);
if (m) {
text = m[1].replace(/&quot;/g, '"').replace(/&amp;/g, "&").trim();
}
if (!text) {
return "";
}
try {
return JSON.parse(text);
} catch {
return text;
}
}
function extractUrlsRecursive(data: unknown): string[] {
if (typeof data === "string") {
const found = data.match(/https?:\/\/[^\s"'<>]+/gi) ?? [];
return uniquePreserveOrder(found.filter((url) => isHttpLink(url)));
}
if (Array.isArray(data)) {
return uniquePreserveOrder(data.flatMap((item) => extractUrlsRecursive(item)));
}
if (data && typeof data === "object") {
return uniquePreserveOrder(Object.values(data as Record<string, unknown>).flatMap((value) => extractUrlsRecursive(value)));
}
return [];
}
function groupLinksByName(links: string[]): ParsedPackageInput[] {
const unique = uniquePreserveOrder(links.filter((link) => isHttpLink(link)));
const grouped = new Map<string, string[]>();
for (const link of unique) {
const name = sanitizeFilename(inferPackageNameFromLinks([link]) || "Paket");
const current = grouped.get(name) ?? [];
current.push(link);
grouped.set(name, current);
}
return Array.from(grouped.entries()).map(([name, packageLinks]) => ({ name, links: packageLinks }));
}
function extractPackagesFromPayload(payload: unknown): ParsedPackageInput[] {
const urls = extractUrlsRecursive(payload);
if (urls.length === 0) {
return [];
}
return groupLinksByName(urls);
}
function decryptRcPayload(base64Rc: string): Buffer {
const rcBytes = Buffer.from(base64Rc, "base64");
const decipher = crypto.createDecipheriv("aes-128-cbc", DLC_AES_KEY, DLC_AES_IV);
decipher.setAutoPadding(false);
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;
for (let m = packageRegex.exec(xml); m; m = packageRegex.exec(xml)) {
const encodedName = m[1] || "";
const packageBody = m[2] || "";
let packageName = "";
if (encodedName) {
try {
packageName = Buffer.from(encodedName, "base64").toString("utf8");
} catch {
packageName = encodedName;
}
}
const links: string[] = [];
const fileNames: string[] = [];
const fileRegex = /<file>([\s\S]*?)<\/file>/gi;
for (let fm = fileRegex.exec(packageBody); fm; fm = fileRegex.exec(packageBody)) {
const fileBody = fm[1] || "";
const urlMatch = fileBody.match(/<url>(.*?)<\/url>/i);
if (!urlMatch) {
continue;
}
try {
const url = Buffer.from((urlMatch[1] || "").trim(), "base64").toString("utf8").trim();
if (!isHttpLink(url)) {
continue;
}
let fileName = "";
const fnMatch = fileBody.match(/<filename>(.*?)<\/filename>/i);
if (fnMatch?.[1]) {
try {
fileName = Buffer.from(fnMatch[1].trim(), "base64").toString("utf8").trim();
} catch {
// ignore
}
}
links.push(url);
fileNames.push(sanitizeFilename(fileName));
} catch {
// skip broken entries
}
}
if (links.length === 0) {
const urlRegex = /<url>(.*?)<\/url>/gi;
for (let um = urlRegex.exec(packageBody); um; um = urlRegex.exec(packageBody)) {
try {
const url = Buffer.from((um[1] || "").trim(), "base64").toString("utf8").trim();
if (isHttpLink(url)) {
links.push(url);
}
} catch {
// skip broken entries
}
}
}
const uniqueLinks = uniquePreserveOrder(links);
const hasFileNames = fileNames.some((fn) => fn.length > 0);
if (uniqueLinks.length > 0) {
const pkg: ParsedPackageInput = {
name: sanitizeFilename(packageName || inferPackageNameFromLinks(uniqueLinks) || `Paket-${packages.length + 1}`),
links: uniqueLinks
};
if (hasFileNames) {
pkg.fileNames = fileNames;
}
packages.push(pkg);
}
}
return packages;
}
async function decryptDlcLocal(filePath: string): Promise<ParsedPackageInput[]> {
const content = readDlcFileWithLimit(filePath).toString("ascii").trim();
if (content.length < 89) {
return [];
}
const dlcKey = content.slice(-88);
const dlcData = content.slice(0, -88);
const rcUrl = DLC_SERVICE_URL.replace("{KEY}", encodeURIComponent(dlcKey));
const rcResponse = await fetch(rcUrl, { method: "GET", signal: AbortSignal.timeout(30000) });
if (!rcResponse.ok) {
return [];
}
const rcText = await rcResponse.text();
const rcMatch = rcText.match(/<rc>(.*?)<\/rc>/i);
if (!rcMatch) {
return [];
}
const realKey = decryptRcPayload(rcMatch[1]).subarray(0, 16);
const encrypted = Buffer.from(dlcData, "base64");
const decipher = crypto.createDecipheriv("aes-128-cbc", realKey, realKey);
decipher.setAutoPadding(false);
let decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
if (decrypted.length === 0) {
return [];
}
const pad = decrypted[decrypted.length - 1];
if (pad > 0 && pad <= 16 && pad <= decrypted.length) {
let validPad = true;
for (let index = 1; index <= pad; index += 1) {
if (decrypted[decrypted.length - index] !== pad) {
validPad = false;
break;
}
}
if (validPad) {
decrypted = decrypted.subarray(0, decrypted.length - pad);
}
}
const xmlData = Buffer.from(decrypted.toString("utf8"), "base64").toString("utf8");
return parsePackagesFromDlcXml(xmlData);
}
function extractLinksFromResponse(text: string): string[] {
const payload = decodeDcryptPayload(text);
let links = extractUrlsRecursive(payload);
if (links.length === 0) {
links = extractUrlsRecursive(text);
}
return uniquePreserveOrder(links.filter((l) => isHttpLink(l)));
}
async function tryDcryptUpload(fileContent: Buffer, fileName: string): Promise<string[] | null> {
const blob = new Blob([new Uint8Array(fileContent)]);
const form = new FormData();
form.set("dlcfile", blob, fileName);
const response = await fetch(DCRYPT_UPLOAD_URL, {
method: "POST",
body: form,
signal: AbortSignal.timeout(30000)
});
if (response.status === 413) {
return null;
}
const text = await response.text();
if (!response.ok) {
throw new Error(compactErrorText(text));
}
return extractLinksFromResponse(text);
}
async function tryDcryptPaste(fileContent: Buffer): Promise<string[] | null> {
const form = new FormData();
form.set("content", fileContent.toString("ascii"));
const response = await fetch(DCRYPT_PASTE_URL, {
method: "POST",
body: form,
signal: AbortSignal.timeout(30000)
});
if (response.status === 413) {
return null;
}
const text = await response.text();
if (!response.ok) {
throw new Error(compactErrorText(text));
}
return extractLinksFromResponse(text);
}
async function decryptDlcViaDcrypt(filePath: string): Promise<ParsedPackageInput[]> {
const fileContent = readDlcFileWithLimit(filePath);
const fileName = path.basename(filePath);
const packageName = sanitizeFilename(path.basename(filePath, ".dlc")) || "Paket";
let links = await tryDcryptUpload(fileContent, fileName);
if (links === null) {
links = await tryDcryptPaste(fileContent);
}
if (links === null) {
throw new Error("DLC-Datei zu groß für dcrypt.it");
}
if (links.length === 0) {
return [];
}
return [{ name: packageName, links }];
}
export async function importDlcContainers(filePaths: string[]): Promise<ParsedPackageInput[]> {
const out: ParsedPackageInput[] = [];
const failures: string[] = [];
let sawDlc = false;
for (const filePath of filePaths) {
if (path.extname(filePath).toLowerCase() !== ".dlc") {
continue;
}
sawDlc = true;
let packages: ParsedPackageInput[] = [];
let fileFailed = false;
let fileFailureReasons: string[] = [];
try {
packages = await decryptDlcLocal(filePath);
} catch (error) {
if (isContainerSizeValidationError(error)) {
failures.push(`${path.basename(filePath)}: ${compactErrorText(error)}`);
continue;
}
fileFailed = true;
fileFailureReasons.push(`lokal: ${compactErrorText(error)}`);
packages = [];
}
if (packages.length === 0) {
try {
packages = await decryptDlcViaDcrypt(filePath);
} catch (error) {
if (isContainerSizeValidationError(error)) {
failures.push(`${path.basename(filePath)}: ${compactErrorText(error)}`);
continue;
}
fileFailed = true;
fileFailureReasons.push(`dcrypt: ${compactErrorText(error)}`);
packages = [];
}
}
if (packages.length === 0 && fileFailed) {
failures.push(`${path.basename(filePath)}: ${fileFailureReasons.join("; ")}`);
}
out.push(...packages);
}
if (out.length === 0 && sawDlc && failures.length > 0) {
const details = failures.slice(0, 2).join(" | ");
const suffix = failures.length > 2 ? ` (+${failures.length - 2} weitere)` : "";
throw new Error(`DLC konnte nicht importiert werden: ${details}${suffix}`);
}
return out;
}