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");
|
||||
}
|
||||
|
||||
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 {
|
||||
private queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
@ -119,6 +144,7 @@ export class RealDebridWebFallback {
|
||||
}
|
||||
window.show();
|
||||
window.focus();
|
||||
void this.primeTokenFromWindow(window);
|
||||
}
|
||||
|
||||
public async clearSessions(): Promise<void> {
|
||||
@ -161,7 +187,7 @@ export class RealDebridWebFallback {
|
||||
|
||||
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
const queuedAt = Date.now();
|
||||
const queueWaitTimeoutMs = 90_000;
|
||||
const queueWaitTimeoutMs = 10 * 60 * 1000 + 30_000;
|
||||
const guardedJob = async (): Promise<T> => {
|
||||
throwIfAborted(signal);
|
||||
const waited = Date.now() - queuedAt;
|
||||
@ -201,6 +227,15 @@ export class RealDebridWebFallback {
|
||||
});
|
||||
window.setMenuBarVisibility(false);
|
||||
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", () => {
|
||||
if (this.loginWindow === window) {
|
||||
this.loginWindow = null;
|
||||
@ -213,6 +248,92 @@ export class RealDebridWebFallback {
|
||||
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> {
|
||||
throwIfAborted(signal);
|
||||
|
||||
@ -221,6 +342,14 @@ export class RealDebridWebFallback {
|
||||
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 response = await currentSession.fetch(RD_APITOKEN_URL, {
|
||||
headers: {
|
||||
@ -236,21 +365,9 @@ export class RealDebridWebFallback {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Real-Debrid sets the token via inline JS:
|
||||
// document.querySelectorAll('input[name=private_token]')[0].value = 'TOKEN_HERE';
|
||||
const tokenMatch = html.match(/private_token['"]\]\[0\]\.value\s*=\s*'([^']+)'/);
|
||||
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;
|
||||
const token = extractPrivateTokenFromHtml(html);
|
||||
if (token) {
|
||||
return this.rememberToken(token);
|
||||
}
|
||||
|
||||
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