Fix Real-Debrid web login session reuse
This commit is contained in:
parent
3774511654
commit
e86c9576e7
@ -79,6 +79,31 @@ function looksLikeHtmlResponse(text: string): boolean {
|
|||||||
return trimmed.startsWith("<!") || trimmed.startsWith("<html") || trimmed.startsWith("<HTML");
|
return trimmed.startsWith("<!") || trimmed.startsWith("<html") || trimmed.startsWith("<HTML");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function extractPrivateTokenFromHtml(html: string): string | null {
|
||||||
|
const normalized = String(html || "");
|
||||||
|
if (!normalized.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patterns = [
|
||||||
|
/private_token['"]\]\[0\]\.value\s*=\s*['"]([^'"]+)['"]/i,
|
||||||
|
/getElementsByName\(\s*['"]private_token['"]\s*\)\s*\[\s*0\s*\]\.value\s*=\s*['"]([^'"]+)['"]/i,
|
||||||
|
/querySelector(?:All)?\(\s*['"][^'"]*private_token[^'"]*['"]\s*\)(?:\s*\[\s*0\s*\])?\.value\s*=\s*['"]([^'"]+)['"]/i,
|
||||||
|
/name=['"]private_token['"][^>]*value=['"]([^'"]+)['"]/i,
|
||||||
|
/value=['"]([^'"]+)['"][^>]*name=['"]private_token['"]/i
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = normalized.match(pattern);
|
||||||
|
const token = match?.[1]?.trim();
|
||||||
|
if (token) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export class RealDebridWebFallback {
|
export class RealDebridWebFallback {
|
||||||
private queue: Promise<unknown> = Promise.resolve();
|
private queue: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
@ -119,6 +144,7 @@ export class RealDebridWebFallback {
|
|||||||
}
|
}
|
||||||
window.show();
|
window.show();
|
||||||
window.focus();
|
window.focus();
|
||||||
|
void this.primeTokenFromWindow(window);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async clearSessions(): Promise<void> {
|
public async clearSessions(): Promise<void> {
|
||||||
@ -161,7 +187,7 @@ export class RealDebridWebFallback {
|
|||||||
|
|
||||||
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||||
const queuedAt = Date.now();
|
const queuedAt = Date.now();
|
||||||
const queueWaitTimeoutMs = 90_000;
|
const queueWaitTimeoutMs = 10 * 60 * 1000 + 30_000;
|
||||||
const guardedJob = async (): Promise<T> => {
|
const guardedJob = async (): Promise<T> => {
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
const waited = Date.now() - queuedAt;
|
const waited = Date.now() - queuedAt;
|
||||||
@ -201,6 +227,15 @@ export class RealDebridWebFallback {
|
|||||||
});
|
});
|
||||||
window.setMenuBarVisibility(false);
|
window.setMenuBarVisibility(false);
|
||||||
window.webContents.setUserAgent(RD_USER_AGENT);
|
window.webContents.setUserAgent(RD_USER_AGENT);
|
||||||
|
const primeFromWindow = (): void => {
|
||||||
|
void this.primeTokenFromWindow(window);
|
||||||
|
};
|
||||||
|
window.webContents.on("did-finish-load", primeFromWindow);
|
||||||
|
window.webContents.on("did-navigate", primeFromWindow);
|
||||||
|
window.webContents.on("did-navigate-in-page", primeFromWindow);
|
||||||
|
window.on("close", () => {
|
||||||
|
void this.primeTokenFromWindow(window);
|
||||||
|
});
|
||||||
window.on("closed", () => {
|
window.on("closed", () => {
|
||||||
if (this.loginWindow === window) {
|
if (this.loginWindow === window) {
|
||||||
this.loginWindow = null;
|
this.loginWindow = null;
|
||||||
@ -213,6 +248,92 @@ export class RealDebridWebFallback {
|
|||||||
return window;
|
return window;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private rememberToken(token: string): string {
|
||||||
|
this.cachedToken = token;
|
||||||
|
this.cachedTokenAt = Date.now();
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getActiveLoginWindow(): BrowserWindow | null {
|
||||||
|
const window = this.loginWindow;
|
||||||
|
if (!window || window.isDestroyed()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (this.loginWindowPartition !== this.getPartition()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async extractApiTokenFromWindow(window: BrowserWindow, signal?: AbortSignal): Promise<string | null> {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawResult = await window.webContents.executeJavaScript(`
|
||||||
|
(async () => {
|
||||||
|
const readTokenFromHtml = (html) => {
|
||||||
|
const text = String(html || "");
|
||||||
|
const patterns = [
|
||||||
|
/private_token['"]\\]\\[0\\]\\.value\\s*=\\s*['"]([^'"]+)['"]/i,
|
||||||
|
/getElementsByName\\(\\s*['"]private_token['"]\\s*\\)\\s*\\[\\s*0\\s*\\]\\.value\\s*=\\s*['"]([^'"]+)['"]/i,
|
||||||
|
/querySelector(?:All)?\\(\\s*['"][^'"]*private_token[^'"]*['"]\\s*\\)(?:\\s*\\[\\s*0\\s*\\])?\\.value\\s*=\\s*['"]([^'"]+)['"]/i,
|
||||||
|
/name=['"]private_token['"][^>]*value=['"]([^'"]+)['"]/i,
|
||||||
|
/value=['"]([^'"]+)['"][^>]*name=['"]private_token['"]/i
|
||||||
|
];
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = text.match(pattern);
|
||||||
|
if (match && match[1]) {
|
||||||
|
return String(match[1]).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const directInput = document.querySelector('input[name="private_token"]');
|
||||||
|
if (directInput instanceof HTMLInputElement && directInput.value.trim()) {
|
||||||
|
return directInput.value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = document.documentElement ? document.documentElement.outerHTML : "";
|
||||||
|
const directToken = readTokenFromHtml(html);
|
||||||
|
if (directToken) {
|
||||||
|
return directToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(${JSON.stringify(RD_APITOKEN_URL)}, {
|
||||||
|
credentials: "include",
|
||||||
|
cache: "no-store",
|
||||||
|
headers: {
|
||||||
|
"X-Requested-With": "XMLHttpRequest"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const tokenHtml = await response.text();
|
||||||
|
return readTokenFromHtml(tokenHtml);
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
`, true);
|
||||||
|
const token = String(rawResult || "").trim();
|
||||||
|
if (token) {
|
||||||
|
return this.rememberToken(token);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore window scraping errors and fall back to session fetch
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async primeTokenFromWindow(window: BrowserWindow): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.extractApiTokenFromWindow(window);
|
||||||
|
} catch {
|
||||||
|
// ignore best-effort token warmup failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async extractApiToken(signal?: AbortSignal): Promise<string | null> {
|
private async extractApiToken(signal?: AbortSignal): Promise<string | null> {
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
|
|
||||||
@ -221,6 +342,14 @@ export class RealDebridWebFallback {
|
|||||||
return this.cachedToken;
|
return this.cachedToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeLoginWindow = this.getActiveLoginWindow();
|
||||||
|
if (activeLoginWindow) {
|
||||||
|
const windowToken = await this.extractApiTokenFromWindow(activeLoginWindow, signal);
|
||||||
|
if (windowToken) {
|
||||||
|
return windowToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const currentSession = session.fromPartition(this.getPartition());
|
const currentSession = session.fromPartition(this.getPartition());
|
||||||
const response = await currentSession.fetch(RD_APITOKEN_URL, {
|
const response = await currentSession.fetch(RD_APITOKEN_URL, {
|
||||||
headers: {
|
headers: {
|
||||||
@ -236,21 +365,9 @@ export class RealDebridWebFallback {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Real-Debrid sets the token via inline JS:
|
const token = extractPrivateTokenFromHtml(html);
|
||||||
// document.querySelectorAll('input[name=private_token]')[0].value = 'TOKEN_HERE';
|
if (token) {
|
||||||
const tokenMatch = html.match(/private_token['"]\]\[0\]\.value\s*=\s*'([^']+)'/);
|
return this.rememberToken(token);
|
||||||
if (tokenMatch && tokenMatch[1]) {
|
|
||||||
this.cachedToken = tokenMatch[1];
|
|
||||||
this.cachedTokenAt = Date.now();
|
|
||||||
return this.cachedToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: look for the token in an input value attribute
|
|
||||||
const inputMatch = html.match(/name=['"]private_token['"][^>]*value=['"]([^'"]+)['"]/);
|
|
||||||
if (inputMatch && inputMatch[1]) {
|
|
||||||
this.cachedToken = inputMatch[1];
|
|
||||||
this.cachedTokenAt = Date.now();
|
|
||||||
return this.cachedToken;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
140
tests/realdebrid-web.test.ts
Normal file
140
tests/realdebrid-web.test.ts
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const {
|
||||||
|
mockSessionFetch,
|
||||||
|
mockClearStorageData,
|
||||||
|
mockClearCache,
|
||||||
|
mockFromPartition,
|
||||||
|
mockBrowserWindow,
|
||||||
|
mockBrowserWindowCtor,
|
||||||
|
mockExecuteJavaScript,
|
||||||
|
mockLoadURL,
|
||||||
|
mockShow,
|
||||||
|
mockFocus
|
||||||
|
} = vi.hoisted(() => {
|
||||||
|
const sessionFetch = vi.fn();
|
||||||
|
const clearStorageData = vi.fn();
|
||||||
|
const clearCache = vi.fn();
|
||||||
|
const fromPartition = vi.fn();
|
||||||
|
const executeJavaScript = vi.fn();
|
||||||
|
const loadURL = vi.fn(async () => {});
|
||||||
|
const show = vi.fn();
|
||||||
|
const focus = vi.fn();
|
||||||
|
const webContentsEvents: Record<string, (...args: unknown[]) => void> = {};
|
||||||
|
const windowEvents: Record<string, (...args: unknown[]) => void> = {};
|
||||||
|
let destroyed = false;
|
||||||
|
|
||||||
|
const browserWindow = {
|
||||||
|
isDestroyed: vi.fn(() => destroyed),
|
||||||
|
isMinimized: vi.fn(() => false),
|
||||||
|
restore: vi.fn(),
|
||||||
|
show,
|
||||||
|
focus,
|
||||||
|
close: vi.fn(() => {
|
||||||
|
destroyed = true;
|
||||||
|
windowEvents.closed?.();
|
||||||
|
}),
|
||||||
|
setMenuBarVisibility: vi.fn(),
|
||||||
|
loadURL,
|
||||||
|
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||||
|
windowEvents[event] = handler;
|
||||||
|
return browserWindow;
|
||||||
|
}),
|
||||||
|
webContents: {
|
||||||
|
setUserAgent: vi.fn(),
|
||||||
|
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||||
|
webContentsEvents[event] = handler;
|
||||||
|
}),
|
||||||
|
executeJavaScript
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const BrowserWindowCtor = vi.fn(() => {
|
||||||
|
destroyed = false;
|
||||||
|
return browserWindow;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
mockSessionFetch: sessionFetch,
|
||||||
|
mockClearStorageData: clearStorageData,
|
||||||
|
mockClearCache: clearCache,
|
||||||
|
mockFromPartition: fromPartition,
|
||||||
|
mockBrowserWindow: browserWindow,
|
||||||
|
mockBrowserWindowCtor: BrowserWindowCtor,
|
||||||
|
mockExecuteJavaScript: executeJavaScript,
|
||||||
|
mockLoadURL: loadURL,
|
||||||
|
mockShow: show,
|
||||||
|
mockFocus: focus
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("electron", () => ({
|
||||||
|
session: {
|
||||||
|
fromPartition: mockFromPartition
|
||||||
|
},
|
||||||
|
BrowserWindow: mockBrowserWindowCtor
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { RealDebridWebFallback, extractPrivateTokenFromHtml } from "../src/main/realdebrid-web";
|
||||||
|
|
||||||
|
describe("realdebrid-web", () => {
|
||||||
|
const mockSession = {
|
||||||
|
fetch: mockSessionFetch,
|
||||||
|
clearStorageData: mockClearStorageData,
|
||||||
|
clearCache: mockClearCache
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFromPartition.mockReturnValue(mockSession);
|
||||||
|
mockExecuteJavaScript.mockReset();
|
||||||
|
mockLoadURL.mockClear();
|
||||||
|
mockShow.mockClear();
|
||||||
|
mockFocus.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockFromPartition.mockReturnValue(mockSession);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts private tokens from current Real-Debrid HTML patterns", () => {
|
||||||
|
expect(extractPrivateTokenFromHtml("document.querySelectorAll('input[name=private_token]')[0].value = 'abc123';"))
|
||||||
|
.toBe("abc123");
|
||||||
|
expect(extractPrivateTokenFromHtml("<input type=\"text\" name=\"private_token\" value=\"def456\">"))
|
||||||
|
.toBe("def456");
|
||||||
|
expect(extractPrivateTokenFromHtml("<input value=\"ghi789\" name=\"private_token\">"))
|
||||||
|
.toBe("ghi789");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the already logged-in browser window to warm the token cache before unrestricting", async () => {
|
||||||
|
const apiFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
download: "https://cdn.real-debrid.example/file.bin",
|
||||||
|
filename: "file.bin",
|
||||||
|
filesize: 12345
|
||||||
|
}), { status: 200 }));
|
||||||
|
vi.stubGlobal("fetch", apiFetch);
|
||||||
|
|
||||||
|
mockExecuteJavaScript.mockResolvedValue("token-from-window");
|
||||||
|
|
||||||
|
const fallback = new RealDebridWebFallback(() => true);
|
||||||
|
await fallback.openLoginWindow();
|
||||||
|
|
||||||
|
const result = await fallback.unrestrict("https://rapidgator.net/file/abc");
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
directUrl: "https://cdn.real-debrid.example/file.bin",
|
||||||
|
fileName: "file.bin",
|
||||||
|
fileSize: 12345,
|
||||||
|
retriesUsed: 0
|
||||||
|
});
|
||||||
|
expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockLoadURL).toHaveBeenCalledWith("https://real-debrid.com");
|
||||||
|
expect(mockShow).toHaveBeenCalled();
|
||||||
|
expect(mockFocus).toHaveBeenCalled();
|
||||||
|
expect(mockSessionFetch).not.toHaveBeenCalled();
|
||||||
|
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(apiFetch.mock.calls[0]?.[0]).toBe("https://api.real-debrid.com/rest/1.0/unrestrict/link");
|
||||||
|
expect(mockBrowserWindow.webContents.executeJavaScript).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user