feat(realdebrid): isolate browser account sessions

This commit is contained in:
Sucukdeluxe
2026-08-15 20:35:34 +02:00
parent af710610fd
commit 53cdac1ded
8 changed files with 605 additions and 93 deletions
+203 -29
View File
@@ -38,7 +38,8 @@ import { checkAllDebridAccounts, checkDebridLinkKey, checkMegaDebridAccount, che
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
import { getMegaDebridAccountsForMode } from "../shared/mega-debrid-accounts";
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
import { getRealDebridAccounts } from "../shared/real-debrid-accounts";
import { getRealDebridAccounts, isRealDebridWebAccountId, type RealDebridWebAccountEntry } from "../shared/real-debrid-accounts";
import type { RealDebridLoginRequest } from "../shared/preload-api";
import { applyAccountCommand, resolveStoredAccountSecret } from "./account-commands";
import { collectAccountStatusRedactionValues, sanitizeDebridAccountStatus, sanitizeDebridAccountStatuses } from "./account-status-sanitizer";
import { createRendererState } from "./renderer-state";
@@ -91,7 +92,13 @@ export class AppController {
private megaWebFallback: MegaWebFallback;
private realDebridWebFallback: RealDebridWebFallback;
private realDebridWebFallbacks = new Map<string, RealDebridWebFallback>();
private pendingRealDebridWebAccountIds = new Map<string, number>();
private realDebridWebGenerations = new Map<string, number>();
private realDebridWebAuthenticationTasks = new Map<string, Promise<void>>();
private allDebridWebFallback: AllDebridWebFallback;
@@ -139,16 +146,12 @@ export class AppController {
login: this.settings.megaLogin,
password: this.settings.megaPassword
}));
this.realDebridWebFallback = new RealDebridWebFallback(
() => this.settings.rememberToken,
() => { void this.refreshRealDebridWebStatus(); }
);
this.allDebridWebFallback = new AllDebridWebFallback(() => this.settings.rememberToken);
this.bestDebridWebFallback = new BestDebridWebFallback(() => this.settings.rememberToken);
this.manager = new DownloadManager(this.settings, session, this.storagePaths, {
megaWebUnrestrict: (link: string, signal?: AbortSignal, account?: { login: string; password: string }) => this.megaWebFallback.unrestrict(link, signal, account),
allDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.allDebridWebFallback.unrestrict(link, signal),
realDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.realDebridWebFallback.unrestrict(link, signal),
realDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.unrestrictWithFirstRealDebridWebAccount(link, signal),
bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal),
invalidateMegaSession: () => this.megaWebFallback.invalidateSession(),
protectEmptyClobber: loadResult.status === "empty-unreadable",
@@ -497,10 +500,14 @@ export class AppController {
changedKeys: Object.keys(sanitizedPatch),
accountChanges: diffAccountSummary(previousSettings, this.settings)
});
if (previousSettings.rememberToken && !this.settings.rememberToken) {
void this.realDebridWebFallback.clearSessions().catch((error) => {
logger.warn(`Real-Debrid Web-Session konnte nicht gelöscht werden: ${String(error)}`);
});
this.pruneRealDebridWebFallbacks(previousSettings, this.settings);
if (previousSettings.rememberToken && !this.settings.rememberToken) {
const accountIds = new Set([...this.settings.realDebridWebAccountIds, ...this.realDebridWebFallbacks.keys()]);
for (const accountId of accountIds) {
void this.cleanupRealDebridWebAccount(accountId, true).catch((error) => {
logger.warn(`Real-Debrid Web-Session konnte nicht gelöscht werden (${accountId}): ${String(error)}`);
});
}
void this.allDebridWebFallback.clearSessions().catch((error) => {
logger.warn(`AllDebrid Web-Session konnte nicht gelöscht werden: ${String(error)}`);
});
@@ -571,7 +578,7 @@ export class AppController {
account,
undefined,
Date.now(),
useWebLogin ? (signal) => this.realDebridWebFallback.probeLoginState(signal) : undefined
useWebLogin ? (signal) => this.getRealDebridWebFallback(account.id).probeLoginState(signal) : undefined
),
redactions
);
@@ -610,7 +617,7 @@ export class AppController {
return this.settings;
}
public resetDebridLinkApiKeyDailyUsage(keyId: string): AppSettings {
public resetDebridLinkApiKeyDailyUsage(keyId: string): AppSettings {
const liveSettings = this.manager.getSettings();
const nextSettings = normalizeSettings({
...liveSettings,
@@ -620,29 +627,192 @@ export class AppController {
saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings);
this.audit("INFO", "Debrid-Link-Key-Tagesnutzung zurückgesetzt", { keyId });
return this.settings;
}
public async openRealDebridLoginWindow(): Promise<void> {
this.audit("INFO", "Real-Debrid Login-Fenster geöffnet");
await this.realDebridWebFallback.openLoginWindow();
return this.settings;
}
private async refreshRealDebridWebStatus(): Promise<void> {
const account = getRealDebridAccounts(this.settings).find((entry) => entry.kind === "web");
if (!account) {
private getRealDebridWebPartition(accountId: string): string {
return accountId === "rdw_legacy"
? "persist:realdebrid-web"
: `persist:realdebrid-web-${accountId}`;
}
private getRealDebridWebFallback(accountId: string): RealDebridWebFallback {
if (!isRealDebridWebAccountId(accountId)) {
throw new Error("Account-Payload ist ungültig");
}
const existing = this.realDebridWebFallbacks.get(accountId);
if (existing) {
return existing;
}
const fallback = new RealDebridWebFallback(
this.getRealDebridWebPartition(accountId),
() => this.settings.rememberToken,
() => this.queueRealDebridWebAuthentication(accountId),
() => this.handleRealDebridWebWindowClosed(accountId)
);
this.realDebridWebFallbacks.set(accountId, fallback);
return fallback;
}
private queueRealDebridWebAuthentication(accountId: string): void {
if (this.realDebridWebAuthenticationTasks.has(accountId)) {
return;
}
const generation = this.realDebridWebGenerations.get(accountId) || 0;
const task = this.refreshRealDebridWebStatus(accountId, generation)
.catch((error) => logger.warn(`Real-Debrid Web-Status konnte nicht aktualisiert werden (${accountId}): ${String(error)}`))
.finally(() => {
if (this.realDebridWebAuthenticationTasks.get(accountId) === task) {
this.realDebridWebAuthenticationTasks.delete(accountId);
}
});
this.realDebridWebAuthenticationTasks.set(accountId, task);
}
private handleRealDebridWebWindowClosed(accountId: string): void {
if (!this.pendingRealDebridWebAccountIds.has(accountId)) {
return;
}
void Promise.resolve().then(async () => {
const authenticationTask = this.realDebridWebAuthenticationTasks.get(accountId);
if (authenticationTask) {
await authenticationTask;
}
if (!this.pendingRealDebridWebAccountIds.has(accountId)
|| this.settings.realDebridWebAccountIds.includes(accountId)) {
return;
}
await this.cleanupRealDebridWebAccount(accountId, true);
}).catch((error) => {
logger.warn(`Abgebrochener Real-Debrid Web-Login konnte nicht bereinigt werden (${accountId}): ${String(error)}`);
});
}
private async cleanupRealDebridWebAccount(accountId: string, clearStorage: boolean): Promise<void> {
const nextGeneration = (this.realDebridWebGenerations.get(accountId) || 0) + 1;
this.realDebridWebGenerations.set(accountId, nextGeneration);
this.pendingRealDebridWebAccountIds.delete(accountId);
const existing = this.realDebridWebFallbacks.get(accountId);
this.realDebridWebFallbacks.delete(accountId);
const fallback = existing || new RealDebridWebFallback(
this.getRealDebridWebPartition(accountId),
() => this.settings.rememberToken
);
if (clearStorage) {
await fallback.clearSessions();
} else {
fallback.dispose();
}
}
private pruneRealDebridWebFallbacks(previous: AppSettings, current: AppSettings): void {
const currentIds = new Set(current.realDebridWebAccountIds);
for (const accountId of previous.realDebridWebAccountIds) {
if (currentIds.has(accountId)) {
continue;
}
void this.cleanupRealDebridWebAccount(accountId, true).catch((error) => {
logger.warn(`Real-Debrid Web-Session konnte nicht gelöscht werden (${accountId}): ${String(error)}`);
});
}
}
private async unrestrictWithFirstRealDebridWebAccount(link: string, signal?: AbortSignal) {
const account = getRealDebridAccounts(this.settings).find((entry) => entry.kind === "web" && entry.enabled);
return account ? this.unrestrictRealDebridWebAccount(account.id, link, signal) : null;
}
public async openRealDebridLoginWindow(request: RealDebridLoginRequest): Promise<void> {
const accountId = String(request.accountId || "").trim();
if (!isRealDebridWebAccountId(accountId)) {
throw new Error("Account-Payload ist ungültig");
}
const existing = getRealDebridAccounts(this.settings).find((entry) => entry.id === accountId && entry.kind === "web");
if (request.create && existing) {
throw new Error("Account-Payload ist ungültig");
}
if (!request.create && !existing && accountId !== "rdw_legacy") {
throw new Error("Account wurde nicht gefunden");
}
if (!existing) {
const generation = (this.realDebridWebGenerations.get(accountId) || 0) + 1;
this.realDebridWebGenerations.set(accountId, generation);
this.pendingRealDebridWebAccountIds.set(accountId, generation);
}
this.audit("INFO", "Real-Debrid Login-Fenster geöffnet", { accountId, create: !existing });
try {
await this.getRealDebridWebFallback(accountId).openLoginWindow();
} catch (error) {
if (!existing) {
await this.cleanupRealDebridWebAccount(accountId, true);
}
throw error;
}
}
public probeRealDebridWebAccount(accountId: string, signal?: AbortSignal) {
return this.getRealDebridWebFallback(accountId).probeLoginState(signal);
}
public unrestrictRealDebridWebAccount(accountId: string, link: string, signal?: AbortSignal) {
return this.getRealDebridWebFallback(accountId).unrestrict(link, signal);
}
public async clearRealDebridWebAccount(accountId: string): Promise<void> {
await this.cleanupRealDebridWebAccount(accountId, true);
}
private async refreshRealDebridWebStatus(accountId: string, generation = this.realDebridWebGenerations.get(accountId) || 0): Promise<void> {
const account = getRealDebridAccounts(this.settings)
.filter((entry): entry is RealDebridWebAccountEntry => entry.kind === "web")
.find((entry) => entry.id === accountId);
const checkedAccount: RealDebridWebAccountEntry = account || {
id: accountId,
kind: "web",
index: this.settings.realDebridWebAccountIds.length,
label: `Browser-Login ${this.settings.realDebridWebAccountIds.length + 1}`,
maskedLogin: "Geschützter Browser-Login",
enabled: true
};
const status = sanitizeDebridAccountStatus(
await checkRealDebridAccount(
account,
checkedAccount,
undefined,
Date.now(),
(signal) => this.realDebridWebFallback.probeLoginState(signal)
(signal) => this.getRealDebridWebFallback(accountId).probeLoginState(signal)
),
collectAccountStatusRedactionValues(this.settings)
);
if (!status.valid) {
return;
}
if ((this.realDebridWebGenerations.get(accountId) || 0) !== generation) {
return;
}
const currentAccount = getRealDebridAccounts(this.settings).find((entry) => entry.id === accountId && entry.kind === "web");
if (account && !currentAccount) {
return;
}
if (!account) {
if (this.pendingRealDebridWebAccountIds.get(accountId) !== generation) {
return;
}
const applied = applyAccountCommand(this.settings, {
action: "create",
kind: "realdebrid-web",
identity: accountId,
secret: "",
dailyLimitBytes: 0
});
this.pendingRealDebridWebAccountIds.delete(accountId);
this.updateSettings(applied.settings);
}
this.manager.applyDebridAccountStatuses([status]);
const fallback = this.realDebridWebFallbacks.get(accountId);
if (fallback) {
this.realDebridWebFallbacks.delete(accountId);
fallback.dispose();
}
}
public async openAllDebridLoginWindow(): Promise<void> {
@@ -679,7 +849,7 @@ export class AppController {
await checkAllDebridAccounts(
this.settings,
undefined,
(_accountId, signal) => this.realDebridWebFallback.probeLoginState(signal),
(accountId, signal) => this.getRealDebridWebFallback(accountId).probeLoginState(signal),
scope
),
collectAccountStatusRedactionValues(this.settings)
@@ -1075,10 +1245,14 @@ export class AppController {
stopDebugServer();
abortActiveUpdateDownload();
cancelPendingAsyncSaves();
this.manager.prepareForShutdown();
this.megaWebFallback.dispose();
this.realDebridWebFallback.dispose();
this.allDebridWebFallback.dispose();
this.manager.prepareForShutdown();
this.megaWebFallback.dispose();
for (const fallback of this.realDebridWebFallbacks.values()) {
fallback.dispose();
}
this.realDebridWebFallbacks.clear();
this.pendingRealDebridWebAccountIds.clear();
this.allDebridWebFallback.dispose();
this.bestDebridWebFallback.dispose();
this.shutdownLogStorage();
this.audit("INFO", "App beendet");
+4 -3
View File
@@ -21,6 +21,7 @@ import { createRendererSettings } from "./renderer-state";
import { validateRendererSettingsUpdate } from "./renderer-settings";
import { applyMainWindowSecurity, createMainWindowWebPreferences, MAIN_WINDOW_EXTERNAL_HOSTS, openAllowedExternalUrl } from "./browser-security";
import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security";
import { validateRealDebridLoginRequest } from "../shared/preload-api";
function validateString(value: unknown, name: string): string {
if (typeof value !== "string") {
@@ -815,9 +816,9 @@ function registerIpcHandlers(): void {
}
});
handleTrusted(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN, async () => {
await controller.openRealDebridLoginWindow();
});
handleTrusted(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN, async (_event: IpcMainInvokeEvent, rawRequest: unknown) => {
await controller.openRealDebridLoginWindow(validateRealDebridLoginRequest(rawRequest));
});
handleTrusted(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN, async () => {
await controller.openAllDebridLoginWindow();
+108 -49
View File
@@ -9,8 +9,7 @@ const RD_LOGIN_URL = RD_BASE_URL;
const RD_APITOKEN_URL = `${RD_BASE_URL}/apitoken`;
const RD_UNRESTRICT_API = `${API_BASE_URL}/unrestrict/link`;
const RD_USER_API = `${API_BASE_URL}/user`;
const RD_PERSISTENT_PARTITION = "persist:realdebrid-web";
const RD_TRANSIENT_PARTITION = "realdebrid-web";
const RD_PARTITION_PATTERN = /^persist:realdebrid-web(?:-rdw_[A-Za-z0-9_-]{1,96})?$/;
const RD_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36";
type GenerateOutcome =
@@ -134,12 +133,32 @@ export class RealDebridWebFallback {
private onAuthenticated?: () => void;
public constructor(getRememberSession: () => boolean, onAuthenticated?: () => void) {
private onClosed?: () => void;
private persistentPartition: string;
private transientPartition: string;
private lifecycleGeneration = 0;
private programmaticClosures = new WeakSet<BrowserWindow>();
private disposed = false;
public constructor(partition: string, getRememberSession: () => boolean, onAuthenticated?: () => void, onClosed?: () => void) {
const normalizedPartition = String(partition || "").trim();
if (!RD_PARTITION_PATTERN.test(normalizedPartition)) {
throw new Error("Real-Debrid Web-Partition ist ungültig");
}
this.persistentPartition = normalizedPartition;
this.transientPartition = normalizedPartition.slice("persist:".length);
this.getRememberSession = getRememberSession;
this.onAuthenticated = onAuthenticated;
this.onClosed = onClosed;
}
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
this.throwIfDisposed();
const overallSignal = withTimeoutSignal(signal, 10 * 60 * 1000);
return this.runExclusive(async () => {
throwIfAborted(overallSignal);
@@ -156,6 +175,7 @@ export class RealDebridWebFallback {
}
public async openLoginWindow(): Promise<void> {
this.throwIfDisposed();
const window = await this.ensureLoginWindow();
if (window.isMinimized()) {
window.restore();
@@ -166,6 +186,7 @@ export class RealDebridWebFallback {
}
public async probeLoginState(signal?: AbortSignal): Promise<RealDebridLoginState> {
this.throwIfDisposed();
let token: string | null = null;
try {
token = await this.extractApiToken(signal);
@@ -219,11 +240,12 @@ export class RealDebridWebFallback {
}
}
public async clearSessions(): Promise<void> {
this.disposeLoginWindow();
this.cachedToken = "";
public async clearSessions(): Promise<void> {
this.disposed = true;
this.disposeLoginWindow();
this.cachedToken = "";
this.cachedTokenAt = 0;
for (const partition of [RD_PERSISTENT_PARTITION, RD_TRANSIENT_PARTITION]) {
for (const partition of [this.persistentPartition, this.transientPartition]) {
const currentSession = session.fromPartition(partition);
try {
await currentSession.clearStorageData({
@@ -238,21 +260,32 @@ export class RealDebridWebFallback {
}
}
public dispose(): void {
this.disposeLoginWindow();
public dispose(): void {
this.disposed = true;
this.disposeLoginWindow();
this.cachedToken = "";
this.cachedTokenAt = 0;
}
private getPartition(): string {
return this.getRememberSession() ? RD_PERSISTENT_PARTITION : RD_TRANSIENT_PARTITION;
}
private getPartition(): string {
return this.getRememberSession() ? this.persistentPartition : this.transientPartition;
}
private throwIfDisposed(): void {
if (this.disposed) {
throw new Error("Real-Debrid Web-Sitzung wurde geschlossen");
}
}
private disposeLoginWindow(): void {
const current = this.loginWindow;
this.loginWindow = null;
this.loginWindowPartition = "";
if (current && !current.isDestroyed()) {
current.close();
}
private disposeLoginWindow(): void {
this.lifecycleGeneration += 1;
const current = this.loginWindow;
this.loginWindow = null;
this.loginWindowPartition = "";
if (current && !current.isDestroyed()) {
this.programmaticClosures.add(current);
current.close();
}
}
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
@@ -297,28 +330,45 @@ export class RealDebridWebFallback {
});
window.setMenuBarVisibility(false);
window.webContents.setUserAgent(RD_USER_AGENT);
const primeFromWindow = (): void => {
void this.primeTokenFromWindow(window);
const windowGeneration = this.lifecycleGeneration;
const primeFromWindow = (): void => {
void this.primeTokenFromWindow(window, windowGeneration);
};
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;
this.loginWindowPartition = "";
}
});
window.webContents.on("did-finish-load", primeFromWindow);
window.webContents.on("did-navigate", primeFromWindow);
window.webContents.on("did-navigate-in-page", primeFromWindow);
let closingTokenProbe: Promise<void> = Promise.resolve();
window.on("close", () => {
if (!this.programmaticClosures.has(window)) {
closingTokenProbe = this.primeTokenFromWindow(window, windowGeneration);
}
});
window.on("closed", () => {
if (this.loginWindow === window) {
this.loginWindow = null;
this.loginWindowPartition = "";
}
if (!this.programmaticClosures.has(window)) {
void closingTokenProbe.finally(() => this.onClosed?.());
}
});
this.loginWindow = window;
this.loginWindowPartition = partition;
await window.loadURL(RD_LOGIN_URL);
return window;
try {
await window.loadURL(RD_LOGIN_URL);
} catch (error) {
if (this.loginWindow === window) {
this.disposeLoginWindow();
}
throw error;
}
return window;
}
private rememberToken(token: string): string {
private rememberToken(token: string, generation = this.lifecycleGeneration): string | null {
if (this.disposed || generation !== this.lifecycleGeneration) {
return null;
}
const changed = token !== this.cachedToken;
this.cachedToken = token;
this.cachedTokenAt = Date.now();
@@ -339,7 +389,7 @@ export class RealDebridWebFallback {
return window;
}
private async extractApiTokenFromWindow(window: BrowserWindow, signal?: AbortSignal): Promise<string | null> {
private async extractApiTokenFromWindow(window: BrowserWindow, signal?: AbortSignal, generation = this.lifecycleGeneration): Promise<string | null> {
throwIfAborted(signal);
try {
@@ -390,8 +440,8 @@ export class RealDebridWebFallback {
})();
`, true);
const token = String(rawResult || "").trim();
if (token) {
return this.rememberToken(token);
if (token && generation === this.lifecycleGeneration && !this.programmaticClosures.has(window)) {
return this.rememberToken(token, generation);
}
} catch {
}
@@ -399,15 +449,20 @@ export class RealDebridWebFallback {
return null;
}
private async primeTokenFromWindow(window: BrowserWindow): Promise<void> {
try {
await this.extractApiTokenFromWindow(window);
private async primeTokenFromWindow(window: BrowserWindow, generation = this.lifecycleGeneration): Promise<void> {
try {
await this.extractApiTokenFromWindow(window, undefined, generation);
} catch {
}
}
private async extractApiToken(signal?: AbortSignal): Promise<string | null> {
throwIfAborted(signal);
private async extractApiToken(signal?: AbortSignal): Promise<string | null> {
throwIfAborted(signal);
const generation = this.lifecycleGeneration;
if (this.disposed) {
return null;
}
if (this.cachedToken && Date.now() - this.cachedTokenAt < 30 * 60 * 1000) {
return this.cachedToken;
@@ -430,15 +485,19 @@ export class RealDebridWebFallback {
},
signal: withTimeoutSignal(signal, 30_000)
});
const html = await response.text();
const html = await response.text();
if (this.disposed || generation !== this.lifecycleGeneration) {
return null;
}
if (!response.ok || response.status === 403) {
return null;
}
const token = extractPrivateTokenFromHtml(html);
if (token) {
return this.rememberToken(token);
const token = extractPrivateTokenFromHtml(html);
if (token) {
return this.rememberToken(token, generation);
}
return null;