Strip every comment from the source (parsed with the TypeScript compiler so strings, template literals, regex literals and JSX are never touched), and drop internal/working artifacts that do not belong in the public repository (design mockups, internal analysis docs, a stray backup file and an old log). No functional change: build is green, the full test suite passes.
130 lines
3.9 KiB
TypeScript
130 lines
3.9 KiB
TypeScript
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]);
|
|
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;
|
|
}
|
|
|
|
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 };
|
|
}
|