Compare commits
No commits in common. "dfab5e0cb42cd77a6e774ca19d31177a99ae0bc4" and "342b4180a165312430cb76170f14087808c773fb" have entirely different histories.
dfab5e0cb4
...
342b4180a1
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.7.155",
|
"version": "1.7.154",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import { APP_VERSION, REQUEST_RETRIES } from "./constants";
|
|||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { logAccountRotation } from "./account-rotation-log";
|
import { logAccountRotation } from "./account-rotation-log";
|
||||||
import { RealDebridClient, UnrestrictedLink } from "./realdebrid";
|
import { RealDebridClient, UnrestrictedLink } from "./realdebrid";
|
||||||
import { isMegaFileUrl, resolveMegaFilename } from "./mega-public-api";
|
|
||||||
import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils";
|
import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils";
|
||||||
|
|
||||||
const API_TIMEOUT_MS = 30000;
|
const API_TIMEOUT_MS = 30000;
|
||||||
@ -3577,29 +3576,6 @@ export class DebridService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mega.nz Pre-Resolve via Public API (kein Mega-Debrid-Quota-Verbrauch).
|
|
||||||
// Liefert echten Filename sobald Links in die Queue kommen, anstatt erst
|
|
||||||
// beim Unrestrict. Concurrency 4 — Mega's Public API ist tolerant gegen
|
|
||||||
// kleine Bursts.
|
|
||||||
const megaLinks = unresolved.filter((link) => !clean.has(link) && isMegaFileUrl(link));
|
|
||||||
if (megaLinks.length > 0) {
|
|
||||||
await runWithConcurrency(megaLinks, 4, async (link) => {
|
|
||||||
try {
|
|
||||||
const info = await resolveMegaFilename(link, signal);
|
|
||||||
if (info?.name) {
|
|
||||||
reportResolved(link, info.name);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const errorText = compactErrorText(error);
|
|
||||||
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
// Schluck — Public API kann fehlen oder rate-limiten; faellt auf
|
|
||||||
// den normalen Mega-Debrid Unrestrict-Pfad zurueck.
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const remaining = unresolved.filter((link) => !clean.has(link) && isRapidgatorLink(link));
|
const remaining = unresolved.filter((link) => !clean.has(link) && isRapidgatorLink(link));
|
||||||
await runWithConcurrency(remaining, 6, async (link) => {
|
await runWithConcurrency(remaining, 6, async (link) => {
|
||||||
const fromPage = await resolveRapidgatorFilename(link, signal);
|
const fromPage = await resolveRapidgatorFilename(link, signal);
|
||||||
|
|||||||
@ -1,148 +0,0 @@
|
|||||||
// Mega.nz Public API: Filename + Size aus Public-Link ohne Mega-Debrid-Account.
|
|
||||||
//
|
|
||||||
// Erlaubt Pre-Resolve von Filenames sobald Links in die Queue kommen — ohne
|
|
||||||
// Mega-Debrid-Quota anzufassen. Funktioniert fuer jeden public mega.nz Link
|
|
||||||
// (mit Decryption-Key im URL-Fragment).
|
|
||||||
//
|
|
||||||
// Protokoll: https://g.api.mega.co.nz/cs
|
|
||||||
// Request: POST [{"a":"g","g":1,"p":"<file-id>"}]
|
|
||||||
// Response: [{"s": <size>, "at": <base64url encrypted attributes>, ...}]
|
|
||||||
// Attribute-Decryption: AES-128-CBC, key = file-key[0..16], IV = 16x \0
|
|
||||||
// Plaintext startet mit "MEGA" gefolgt von JSON: {"n": "filename.mkv", ...}
|
|
||||||
//
|
|
||||||
// Datei-Key im URL-Fragment ist 32 Bytes (base64url-encoded). Bytes 0-15
|
|
||||||
// sind der AES-Schluessel, 16-23 der CTR-Nonce, 24-31 die Meta-MAC. Fuer
|
|
||||||
// Attribut-Decryption brauchen wir nur den AES-Teil.
|
|
||||||
|
|
||||||
import crypto from "node:crypto";
|
|
||||||
|
|
||||||
const MEGA_API_BASE = "https://g.api.mega.co.nz/cs";
|
|
||||||
const MEGA_API_TIMEOUT_MS = 12_000;
|
|
||||||
|
|
||||||
export interface MegaFileInfo {
|
|
||||||
name: string;
|
|
||||||
size: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NEW_FORMAT_RE = /^https?:\/\/mega\.(?:nz|co\.nz)\/file\/([A-Za-z0-9_-]+)#([A-Za-z0-9_-]+)/i;
|
|
||||||
const LEGACY_FORMAT_RE = /^https?:\/\/mega\.(?:nz|co\.nz)\/#!([A-Za-z0-9_-]+)!([A-Za-z0-9_-]+)/i;
|
|
||||||
|
|
||||||
export function isMegaFileUrl(url: string): boolean {
|
|
||||||
const s = String(url || "").trim();
|
|
||||||
return NEW_FORMAT_RE.test(s) || LEGACY_FORMAT_RE.test(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
function base64UrlDecode(s: string): Buffer | null {
|
|
||||||
let b64 = String(s || "").trim().replace(/-/g, "+").replace(/_/g, "/");
|
|
||||||
while (b64.length % 4 !== 0) b64 += "=";
|
|
||||||
try {
|
|
||||||
return Buffer.from(b64, "base64");
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ParsedMegaLink {
|
|
||||||
id: string;
|
|
||||||
rawKey: Buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseMegaUrl(url: string): ParsedMegaLink | null {
|
|
||||||
const s = String(url || "").trim();
|
|
||||||
const m = NEW_FORMAT_RE.exec(s) || LEGACY_FORMAT_RE.exec(s);
|
|
||||||
if (!m) return null;
|
|
||||||
const id = m[1];
|
|
||||||
const rawKey = base64UrlDecode(m[2]);
|
|
||||||
// Files: 32 Bytes (256 bit). Folders: 16 Bytes — wir behandeln nur Files.
|
|
||||||
if (!rawKey || rawKey.length !== 32) return null;
|
|
||||||
return { id, rawKey };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decryptMegaAttributes(encrypted: Buffer, aesKey: Buffer): Record<string, unknown> | null {
|
|
||||||
if (!Buffer.isBuffer(encrypted) || encrypted.length === 0 || encrypted.length % 16 !== 0) return null;
|
|
||||||
if (!Buffer.isBuffer(aesKey) || aesKey.length !== 16) return null;
|
|
||||||
let plain: Buffer;
|
|
||||||
try {
|
|
||||||
const decipher = crypto.createDecipheriv("aes-128-cbc", aesKey, Buffer.alloc(16));
|
|
||||||
decipher.setAutoPadding(false);
|
|
||||||
plain = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const text = plain.toString("utf8").replace(/\0+$/, "").trim();
|
|
||||||
if (!text.startsWith("MEGA{")) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(text.slice(4));
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function withTimeoutSignal(parent: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timer = setTimeout(() => controller.abort("mega-api-timeout"), timeoutMs);
|
|
||||||
if (parent) {
|
|
||||||
if (parent.aborted) {
|
|
||||||
controller.abort(parent.reason);
|
|
||||||
} else {
|
|
||||||
parent.addEventListener("abort", () => controller.abort(parent.reason), { once: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
controller.signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
|
|
||||||
return controller.signal;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveMegaFilename(
|
|
||||||
url: string,
|
|
||||||
signal?: AbortSignal
|
|
||||||
): Promise<MegaFileInfo | null> {
|
|
||||||
const parsed = parseMegaUrl(url);
|
|
||||||
if (!parsed) return null;
|
|
||||||
const aesKey = parsed.rawKey.subarray(0, 16);
|
|
||||||
|
|
||||||
const apiUrl = `${MEGA_API_BASE}?id=${Math.floor(Math.random() * 1e9)}`;
|
|
||||||
const body = JSON.stringify([{ a: "g", g: 1, p: parsed.id }]);
|
|
||||||
|
|
||||||
let response: Response;
|
|
||||||
try {
|
|
||||||
response = await fetch(apiUrl, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body,
|
|
||||||
signal: withTimeoutSignal(signal, MEGA_API_TIMEOUT_MS)
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!response.ok) return null;
|
|
||||||
|
|
||||||
let payload: unknown;
|
|
||||||
try {
|
|
||||||
payload = await response.json();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mega gibt entweder ein Array mit File-Infos oder eine numerische Error-ID
|
|
||||||
// zurueck (z.B. -9 ENOENT, -11 EACCESS, -14 EKEY, -16 EBLOCKED, -25 EOVERQUOTA).
|
|
||||||
if (typeof payload === "number") return null;
|
|
||||||
if (!Array.isArray(payload) || payload.length === 0) return null;
|
|
||||||
|
|
||||||
const first = payload[0];
|
|
||||||
if (typeof first === "number") return null;
|
|
||||||
if (!first || typeof first !== "object") return null;
|
|
||||||
|
|
||||||
const info = first as { s?: unknown; at?: unknown; e?: unknown };
|
|
||||||
if (typeof info.e === "number" && info.e !== 0) return null;
|
|
||||||
|
|
||||||
const size = typeof info.s === "number" && info.s > 0 ? info.s : 0;
|
|
||||||
if (typeof info.at !== "string" || !info.at.trim()) return null;
|
|
||||||
|
|
||||||
const encryptedAttrs = base64UrlDecode(info.at);
|
|
||||||
if (!encryptedAttrs) return null;
|
|
||||||
|
|
||||||
const attrs = decryptMegaAttributes(encryptedAttrs, aesKey);
|
|
||||||
if (!attrs || typeof attrs.n !== "string" || !attrs.n.trim()) return null;
|
|
||||||
|
|
||||||
return { name: attrs.n.trim(), size };
|
|
||||||
}
|
|
||||||
@ -1,180 +0,0 @@
|
|||||||
import crypto from "node:crypto";
|
|
||||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import {
|
|
||||||
decryptMegaAttributes,
|
|
||||||
isMegaFileUrl,
|
|
||||||
parseMegaUrl,
|
|
||||||
resolveMegaFilename
|
|
||||||
} from "../src/main/mega-public-api";
|
|
||||||
|
|
||||||
function base64Url(buf: Buffer): string {
|
|
||||||
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeRandomFileKey(): Buffer {
|
|
||||||
return crypto.randomBytes(32);
|
|
||||||
}
|
|
||||||
|
|
||||||
function encryptAttributes(jsonAttrs: Record<string, unknown>, aesKey: Buffer): string {
|
|
||||||
const plain = "MEGA" + JSON.stringify(jsonAttrs);
|
|
||||||
// Pad to 16-byte boundary with \0 (Mega convention).
|
|
||||||
const padded = Buffer.from(plain, "utf8");
|
|
||||||
const padLen = (16 - (padded.length % 16)) % 16;
|
|
||||||
const buf = Buffer.concat([padded, Buffer.alloc(padLen, 0)]);
|
|
||||||
const cipher = crypto.createCipheriv("aes-128-cbc", aesKey, Buffer.alloc(16));
|
|
||||||
cipher.setAutoPadding(false);
|
|
||||||
const enc = Buffer.concat([cipher.update(buf), cipher.final()]);
|
|
||||||
return base64Url(enc);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("mega-public-api", () => {
|
|
||||||
describe("isMegaFileUrl", () => {
|
|
||||||
it("recognizes new format", () => {
|
|
||||||
expect(isMegaFileUrl("https://mega.nz/file/pZl1wBRQ#BFx-HachDy4o9EgKy90IiLMsw3idHFGaDoJhajK5zzo")).toBe(true);
|
|
||||||
});
|
|
||||||
it("recognizes legacy format", () => {
|
|
||||||
expect(isMegaFileUrl("https://mega.nz/#!abc123!def456")).toBe(true);
|
|
||||||
});
|
|
||||||
it("recognizes mega.co.nz", () => {
|
|
||||||
expect(isMegaFileUrl("https://mega.co.nz/file/abc#xyz")).toBe(true);
|
|
||||||
});
|
|
||||||
it("rejects folder URLs", () => {
|
|
||||||
expect(isMegaFileUrl("https://mega.nz/folder/abc#xyz")).toBe(false);
|
|
||||||
});
|
|
||||||
it("rejects non-mega URLs", () => {
|
|
||||||
expect(isMegaFileUrl("https://example.com/file/abc#xyz")).toBe(false);
|
|
||||||
});
|
|
||||||
it("rejects garbage", () => {
|
|
||||||
expect(isMegaFileUrl("")).toBe(false);
|
|
||||||
expect(isMegaFileUrl("foo")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parseMegaUrl", () => {
|
|
||||||
it("parses new-format URL into id + 32-byte key", () => {
|
|
||||||
const url = "https://mega.nz/file/pZl1wBRQ#BFx-HachDy4o9EgKy90IiLMsw3idHFGaDoJhajK5zzo";
|
|
||||||
const parsed = parseMegaUrl(url);
|
|
||||||
expect(parsed).not.toBeNull();
|
|
||||||
expect(parsed?.id).toBe("pZl1wBRQ");
|
|
||||||
expect(parsed?.rawKey.length).toBe(32);
|
|
||||||
});
|
|
||||||
it("parses legacy-format URL", () => {
|
|
||||||
// Make a valid legacy URL with a 32-byte key.
|
|
||||||
const id = "abcDEF12";
|
|
||||||
const key = makeRandomFileKey();
|
|
||||||
const url = `https://mega.nz/#!${id}!${base64Url(key)}`;
|
|
||||||
const parsed = parseMegaUrl(url);
|
|
||||||
expect(parsed?.id).toBe(id);
|
|
||||||
expect(parsed?.rawKey.equals(key)).toBe(true);
|
|
||||||
});
|
|
||||||
it("rejects URL with folder key (16 bytes)", () => {
|
|
||||||
const url = `https://mega.nz/file/abc#${base64Url(crypto.randomBytes(16))}`;
|
|
||||||
expect(parseMegaUrl(url)).toBeNull();
|
|
||||||
});
|
|
||||||
it("rejects malformed URLs", () => {
|
|
||||||
expect(parseMegaUrl("not-a-url")).toBeNull();
|
|
||||||
expect(parseMegaUrl("https://mega.nz/file/abc")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("decryptMegaAttributes", () => {
|
|
||||||
it("round-trips encrypted Mega attributes", () => {
|
|
||||||
const aesKey = crypto.randomBytes(16);
|
|
||||||
const original = { n: "Test.S01E01.German.1080p.WEB.x264-DEMO.mkv", c: "ignored" };
|
|
||||||
const enc = encryptAttributes(original, aesKey);
|
|
||||||
const decoded = Buffer.from(enc + "=".repeat((4 - (enc.length % 4)) % 4), "base64");
|
|
||||||
const decrypted = decryptMegaAttributes(decoded, aesKey);
|
|
||||||
expect(decrypted).not.toBeNull();
|
|
||||||
expect(decrypted?.n).toBe(original.n);
|
|
||||||
});
|
|
||||||
it("returns null for wrong key", () => {
|
|
||||||
const aesKey = crypto.randomBytes(16);
|
|
||||||
const wrongKey = crypto.randomBytes(16);
|
|
||||||
const enc = encryptAttributes({ n: "x" }, aesKey);
|
|
||||||
const decoded = Buffer.from(enc + "=".repeat((4 - (enc.length % 4)) % 4), "base64");
|
|
||||||
expect(decryptMegaAttributes(decoded, wrongKey)).toBeNull();
|
|
||||||
});
|
|
||||||
it("returns null for non-multiple-of-16 input", () => {
|
|
||||||
const aesKey = crypto.randomBytes(16);
|
|
||||||
expect(decryptMegaAttributes(Buffer.alloc(15), aesKey)).toBeNull();
|
|
||||||
});
|
|
||||||
it("returns null for wrong key length", () => {
|
|
||||||
expect(decryptMegaAttributes(Buffer.alloc(16), Buffer.alloc(8))).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("resolveMegaFilename (mocked fetch)", () => {
|
|
||||||
let originalFetch: typeof fetch;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
originalFetch = global.fetch;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
global.fetch = originalFetch;
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns filename + size for a valid Mega response", async () => {
|
|
||||||
const fileKey = makeRandomFileKey();
|
|
||||||
const aesKey = fileKey.subarray(0, 16);
|
|
||||||
const url = `https://mega.nz/file/testId12#${base64Url(fileKey)}`;
|
|
||||||
const encrypted = encryptAttributes(
|
|
||||||
{ n: "Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT.mkv" },
|
|
||||||
aesKey
|
|
||||||
);
|
|
||||||
|
|
||||||
global.fetch = vi.fn().mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
async json() {
|
|
||||||
return [{ s: 1234567890, at: encrypted, msd: 1 }];
|
|
||||||
}
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
const result = await resolveMegaFilename(url);
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
expect(result?.name).toBe("Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT.mkv");
|
|
||||||
expect(result?.size).toBe(1234567890);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when Mega returns numeric error", async () => {
|
|
||||||
const fileKey = makeRandomFileKey();
|
|
||||||
const url = `https://mega.nz/file/blockedId#${base64Url(fileKey)}`;
|
|
||||||
global.fetch = vi.fn().mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
async json() {
|
|
||||||
return -9; // ENOENT — file not found
|
|
||||||
}
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
expect(await resolveMegaFilename(url)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when response is array with error code", async () => {
|
|
||||||
const fileKey = makeRandomFileKey();
|
|
||||||
const url = `https://mega.nz/file/blockedId#${base64Url(fileKey)}`;
|
|
||||||
global.fetch = vi.fn().mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
async json() {
|
|
||||||
return [-16]; // EBLOCKED
|
|
||||||
}
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
expect(await resolveMegaFilename(url)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when fetch throws", async () => {
|
|
||||||
const fileKey = makeRandomFileKey();
|
|
||||||
const url = `https://mega.nz/file/networkFail#${base64Url(fileKey)}`;
|
|
||||||
global.fetch = vi.fn().mockRejectedValue(new Error("network down"));
|
|
||||||
expect(await resolveMegaFilename(url)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for non-mega URL without making any fetch call", async () => {
|
|
||||||
const fetchSpy = vi.fn();
|
|
||||||
global.fetch = fetchSpy as unknown as typeof fetch;
|
|
||||||
expect(await resolveMegaFilename("https://example.com/file/abc#xyz")).toBeNull();
|
|
||||||
expect(fetchSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Loading…
Reference in New Issue
Block a user