Harden renderer account IPC boundary

Move secret-bearing account and settings state behind main-process boundaries for Task 1B. Renderer snapshots now expose RendererSettings plus safe account metadata instead of full AppSettings, with provider tokens, passwords, API keys, archive passwords, and notification URLs excluded from renderer-bound state. Add write-only account create, replace, update-secret, and delete IPC commands, validate renderer settings updates against the safe shape, and keep account command results limited to safe settings, safe accounts, and stable account IDs.

Preserve existing account behavior while removing renderer secret access: blank replace secrets retain stored main-process secrets, Mega-Debrid API/Web pools stay mode-specific, Debrid-Link key metadata migrates by stable key ID, and delete/enable operations target stable account or provider identities. Remove obsolete renderer-side account status helpers from the old settings snapshot flow.

Add focused coverage for all supported renderer account kinds, secret-free UiSnapshot serialization, preload account command forwarding, malformed payload error sanitization, Mega-Debrid preferApi preservation, Debrid-Link key metadata migration, account edit safety, settings UI, debug server settings payloads, link export, and visual fixtures.
This commit is contained in:
Sucukdeluxe
2026-08-11 23:22:44 +02:00
parent 1cb381fa50
commit 26d62a9337
24 changed files with 2240 additions and 1700 deletions
+466
View File
@@ -0,0 +1,466 @@
import { getDebridLinkApiKeyId, parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
import {
getMegaDebridAccountId,
getMegaDebridAccountsForMode,
getMegaDebridCredentialsForMode,
getMegaDebridDisabledAccountIdsForMode,
mergeMegaDebridCredentialPools,
parseMegaDebridAccounts,
serializeMegaDebridAccounts,
type MegaDebridAccountMode
} from "../shared/mega-debrid-accounts";
import type { AccountCommand, AccountCredentialCheckInput, AppSettings, DebridProvider, RendererAccountKind } from "../shared/types";
export interface AppliedAccountCommand {
settings: AppSettings;
response: { accountId: string | null };
}
const ACCOUNT_KINDS = new Set<RendererAccountKind>([
"realdebrid-api",
"realdebrid-web",
"megadebrid-api",
"megadebrid-web",
"bestdebrid-api",
"bestdebrid-web",
"alldebrid-api",
"alldebrid-web",
"ddownload-login",
"onefichier-api",
"debridlink-api",
"linksnappy-login"
]);
const ACTION_FIELDS: Record<AccountCommand["action"], ReadonlySet<string>> = {
create: new Set(["action", "kind", "identity", "secret", "dailyLimitBytes"]),
replace: new Set(["action", "kind", "accountId", "identity", "secret", "dailyLimitBytes"]),
"update-secret": new Set(["action", "kind", "accountId", "secret"]),
delete: new Set(["action", "kind", "accountId"])
};
function invalid(): never {
throw new Error("Account-Payload ist ungültig");
}
function optionalString(value: unknown, maxLength: number): string | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== "string" || value.length > maxLength) {
invalid();
}
return value;
}
function requiredString(value: unknown, maxLength: number): string {
const result = optionalString(value, maxLength);
if (result === undefined || !result.trim()) {
invalid();
}
return result;
}
function optionalLimit(value: unknown): number | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
invalid();
}
return value;
}
export function validateAccountCommand(value: unknown): AccountCommand {
if (!value || typeof value !== "object" || Array.isArray(value)) {
invalid();
}
const raw = value as Record<string, unknown>;
const action = raw.action;
if (action !== "create" && action !== "replace" && action !== "update-secret" && action !== "delete") {
invalid();
}
if (Object.keys(raw).some((key) => !ACTION_FIELDS[action].has(key))) {
invalid();
}
if (typeof raw.kind !== "string" || !ACCOUNT_KINDS.has(raw.kind as RendererAccountKind)) {
invalid();
}
const kind = raw.kind as RendererAccountKind;
if (action === "create") {
return {
action,
kind,
identity: optionalString(raw.identity, 512),
secret: optionalString(raw.secret, 100_000),
dailyLimitBytes: optionalLimit(raw.dailyLimitBytes)
};
}
const accountId = requiredString(raw.accountId, 256).trim();
if (action === "replace") {
return {
action,
kind,
accountId,
identity: optionalString(raw.identity, 512),
secret: optionalString(raw.secret, 100_000),
dailyLimitBytes: optionalLimit(raw.dailyLimitBytes)
};
}
if (action === "update-secret") {
return {
action,
kind,
accountId,
secret: requiredString(raw.secret, 100_000)
};
}
return { action, kind, accountId };
}
export function validateAccountCredentialCheckInput(value: unknown): AccountCredentialCheckInput {
if (!value || typeof value !== "object" || Array.isArray(value)) invalid();
const raw = value as Record<string, unknown>;
if (Object.keys(raw).some((key) => !new Set(["kind", "accountId", "identity", "secret"]).has(key))) invalid();
if (raw.kind !== "megadebrid-api" && raw.kind !== "megadebrid-web" && raw.kind !== "debridlink-api") invalid();
return {
kind: raw.kind,
accountId: optionalString(raw.accountId, 256),
identity: optionalString(raw.identity, 512),
secret: optionalString(raw.secret, 100_000)
};
}
function withoutKeys<T>(record: Record<string, T>, ...keys: string[]): Record<string, T> {
const removed = new Set(keys);
return Object.fromEntries(Object.entries(record).filter(([key]) => !removed.has(key)));
}
function migrateIdList(values: readonly string[], oldId: string, newId: string): string[] {
const retained = values.filter((value) => value !== oldId && value !== newId);
if (values.includes(oldId)) {
retained.push(newId);
}
return retained;
}
function setLimit(record: Record<string, number>, id: string, value: number | undefined): Record<string, number> {
const result = { ...record };
if (value === undefined) {
return result;
}
if (value > 0) {
result[id] = value;
} else {
delete result[id];
}
return result;
}
function migrateLimit(record: Record<string, number>, oldId: string, newId: string, value: number | undefined): Record<string, number> {
const result = withoutKeys(record, oldId, newId);
const resolved = value === undefined ? record[oldId] : value;
if (resolved && resolved > 0) {
result[newId] = resolved;
}
return result;
}
function validateIdentity(identity: string): string {
const trimmed = identity.trim();
if (!trimmed || /[:\r\n]/.test(trimmed)) {
invalid();
}
return trimmed;
}
function validateSecret(secret: string): string {
if (!secret.trim() || /[\r\n]/.test(secret)) {
invalid();
}
return secret;
}
function megaMode(kind: RendererAccountKind): MegaDebridAccountMode {
return kind === "megadebrid-web" ? "web" : "api";
}
function writeMegaPools(settings: AppSettings, apiCredentials: string, webCredentials: string): AppSettings {
const mergedCredentials = mergeMegaDebridCredentialPools(apiCredentials, webCredentials);
const first = parseMegaDebridAccounts(mergedCredentials)[0];
return {
...settings,
megaCredentials: mergedCredentials,
megaLogin: first?.login || "",
megaPassword: first?.password || "",
megaDebridApiCredentials: apiCredentials,
megaDebridWebCredentials: webCredentials,
megaDebridApiEnabled: settings.megaDebridApiEnabled && Boolean(apiCredentials),
megaDebridWebEnabled: settings.megaDebridWebEnabled && Boolean(webCredentials)
};
}
function createMega(settings: AppSettings, command: Extract<AccountCommand, { action: "create" }>): AppliedAccountCommand {
const mode = megaMode(command.kind);
const identity = validateIdentity(command.identity || "");
const secret = validateSecret(command.secret || "");
const accounts = getMegaDebridAccountsForMode(settings, mode);
if (accounts.some((account) => account.login.toLowerCase() === identity.toLowerCase())) {
invalid();
}
const selectedCredentials = serializeMegaDebridAccounts([...accounts, { login: identity, password: secret }]);
const apiCredentials = mode === "api" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "api");
const webCredentials = mode === "web" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "web");
const accountId = getMegaDebridAccountId(identity);
const next = writeMegaPools(settings, apiCredentials, webCredentials);
if (mode === "api") {
next.megaDebridApiEnabled = true;
} else {
next.megaDebridWebEnabled = true;
}
next.megaDebridAccountDailyLimitBytes = setLimit(settings.megaDebridAccountDailyLimitBytes, accountId, command.dailyLimitBytes);
return { settings: next, response: { accountId } };
}
function replaceMega(settings: AppSettings, command: Extract<AccountCommand, { action: "replace" }>): AppliedAccountCommand {
const mode = megaMode(command.kind);
const accounts = getMegaDebridAccountsForMode(settings, mode);
const index = accounts.findIndex((account) => account.id === command.accountId);
if (index < 0) {
invalid();
}
const current = accounts[index];
const identity = command.identity === undefined ? current.login : validateIdentity(command.identity);
const secret = command.secret?.trim() ? validateSecret(command.secret) : current.password;
if (accounts.some((account, accountIndex) => accountIndex !== index && account.login.toLowerCase() === identity.toLowerCase())) {
invalid();
}
const accountId = getMegaDebridAccountId(identity);
const selectedCredentials = serializeMegaDebridAccounts(accounts.map((account, accountIndex) => accountIndex === index
? { login: identity, password: secret }
: { login: account.login, password: account.password }));
const apiCredentials = mode === "api" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "api");
const webCredentials = mode === "web" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "web");
const next = writeMegaPools(settings, apiCredentials, webCredentials);
const selectedDisabled = migrateIdList(getMegaDebridDisabledAccountIdsForMode(settings, mode), command.accountId, accountId);
if (mode === "api") {
next.megaDebridApiDisabledAccountIds = selectedDisabled;
} else {
next.megaDebridWebDisabledAccountIds = selectedDisabled;
}
next.megaDebridDisabledAccountIds = [...new Set([...next.megaDebridApiDisabledAccountIds, ...next.megaDebridWebDisabledAccountIds])];
next.megaDebridAccountDailyLimitBytes = migrateLimit(settings.megaDebridAccountDailyLimitBytes, command.accountId, accountId, command.dailyLimitBytes);
if (command.accountId !== accountId) {
next.megaDebridAccountDailyUsageBytes = withoutKeys(settings.megaDebridAccountDailyUsageBytes, command.accountId, accountId);
next.megaDebridAccountTotalUsageBytes = withoutKeys(settings.megaDebridAccountTotalUsageBytes, command.accountId, accountId);
next.debridAccountStatuses = withoutKeys(settings.debridAccountStatuses, command.accountId, accountId);
}
return { settings: next, response: { accountId } };
}
function deleteMega(settings: AppSettings, command: Extract<AccountCommand, { action: "delete" }>): AppliedAccountCommand {
const mode = megaMode(command.kind);
const selected = getMegaDebridAccountsForMode(settings, mode);
if (!selected.some((account) => account.id === command.accountId)) {
invalid();
}
const selectedCredentials = serializeMegaDebridAccounts(selected.filter((account) => account.id !== command.accountId));
const apiCredentials = mode === "api" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "api");
const webCredentials = mode === "web" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "web");
const accountStillUsed = parseMegaDebridAccounts(mode === "api" ? webCredentials : apiCredentials).some((account) => account.id === command.accountId);
const next = writeMegaPools(settings, apiCredentials, webCredentials);
next.megaDebridApiDisabledAccountIds = mode === "api"
? settings.megaDebridApiDisabledAccountIds.filter((id) => id !== command.accountId)
: [...settings.megaDebridApiDisabledAccountIds];
next.megaDebridWebDisabledAccountIds = mode === "web"
? settings.megaDebridWebDisabledAccountIds.filter((id) => id !== command.accountId)
: [...settings.megaDebridWebDisabledAccountIds];
next.megaDebridDisabledAccountIds = [...new Set([...next.megaDebridApiDisabledAccountIds, ...next.megaDebridWebDisabledAccountIds])];
if (!accountStillUsed) {
next.megaDebridAccountDailyLimitBytes = withoutKeys(settings.megaDebridAccountDailyLimitBytes, command.accountId);
next.megaDebridAccountDailyUsageBytes = withoutKeys(settings.megaDebridAccountDailyUsageBytes, command.accountId);
next.megaDebridAccountTotalUsageBytes = withoutKeys(settings.megaDebridAccountTotalUsageBytes, command.accountId);
next.debridAccountStatuses = withoutKeys(settings.debridAccountStatuses, command.accountId);
}
const remaining = getMegaDebridAccountsForMode(next, mode)[0]?.id || null;
return { settings: next, response: { accountId: remaining } };
}
function createDebridLink(settings: AppSettings, command: Extract<AccountCommand, { action: "create" }>): AppliedAccountCommand {
const secret = validateSecret(command.secret || "");
if (/[,\r\n]/.test(secret)) {
invalid();
}
const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys);
if (keys.some((key) => key.token === secret)) {
invalid();
}
const accountId = getDebridLinkApiKeyId(secret);
return {
settings: {
...settings,
debridLinkApiKeys: [...keys.map((key) => key.token), secret].join("\n"),
debridLinkApiKeyDailyLimitBytes: setLimit(settings.debridLinkApiKeyDailyLimitBytes, accountId, command.dailyLimitBytes)
},
response: { accountId }
};
}
function replaceDebridLink(settings: AppSettings, command: Extract<AccountCommand, { action: "replace" }>): AppliedAccountCommand {
const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys);
const index = keys.findIndex((key) => key.id === command.accountId);
if (index < 0) {
invalid();
}
const secret = command.secret?.trim() ? validateSecret(command.secret) : keys[index].token;
if (/[,\r\n]/.test(secret) || keys.some((key, keyIndex) => keyIndex !== index && key.token === secret)) {
invalid();
}
const accountId = getDebridLinkApiKeyId(secret);
const tokens = keys.map((key, keyIndex) => keyIndex === index ? secret : key.token);
const idChanged = accountId !== command.accountId;
return {
settings: {
...settings,
debridLinkApiKeys: tokens.join("\n"),
debridLinkDisabledKeyIds: idChanged ? migrateIdList(settings.debridLinkDisabledKeyIds, command.accountId, accountId) : [...settings.debridLinkDisabledKeyIds],
debridLinkApiKeyDailyLimitBytes: migrateLimit(settings.debridLinkApiKeyDailyLimitBytes, command.accountId, accountId, command.dailyLimitBytes),
debridLinkApiKeyDailyUsageBytes: idChanged ? withoutKeys(settings.debridLinkApiKeyDailyUsageBytes, command.accountId, accountId) : { ...settings.debridLinkApiKeyDailyUsageBytes },
debridLinkApiKeyTotalUsageBytes: idChanged ? withoutKeys(settings.debridLinkApiKeyTotalUsageBytes, command.accountId, accountId) : { ...settings.debridLinkApiKeyTotalUsageBytes },
debridAccountStatuses: idChanged ? withoutKeys(settings.debridAccountStatuses, command.accountId, accountId) : { ...settings.debridAccountStatuses }
},
response: { accountId }
};
}
function deleteDebridLink(settings: AppSettings, command: Extract<AccountCommand, { action: "delete" }>): AppliedAccountCommand {
const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys);
if (!keys.some((key) => key.id === command.accountId)) {
invalid();
}
const remaining = keys.filter((key) => key.id !== command.accountId);
return {
settings: {
...settings,
debridLinkApiKeys: remaining.map((key) => key.token).join("\n"),
debridLinkDisabledKeyIds: settings.debridLinkDisabledKeyIds.filter((id) => id !== command.accountId),
debridLinkApiKeyDailyLimitBytes: withoutKeys(settings.debridLinkApiKeyDailyLimitBytes, command.accountId),
debridLinkApiKeyDailyUsageBytes: withoutKeys(settings.debridLinkApiKeyDailyUsageBytes, command.accountId),
debridLinkApiKeyTotalUsageBytes: withoutKeys(settings.debridLinkApiKeyTotalUsageBytes, command.accountId),
debridAccountStatuses: withoutKeys(settings.debridAccountStatuses, command.accountId)
},
response: { accountId: remaining[0]?.id || null }
};
}
function singleProvider(kind: RendererAccountKind): DebridProvider {
if (kind.startsWith("realdebrid")) return "realdebrid";
if (kind.startsWith("bestdebrid")) return "bestdebrid";
if (kind.startsWith("alldebrid")) return "alldebrid";
if (kind === "ddownload-login") return "ddownload";
if (kind === "onefichier-api") return "onefichier";
if (kind === "linksnappy-login") return "linksnappy";
invalid();
}
function singleConfigured(settings: AppSettings, kind: RendererAccountKind): boolean {
if (kind === "realdebrid-api") return Boolean(settings.token.trim());
if (kind === "realdebrid-web") return settings.realDebridUseWebLogin;
if (kind === "bestdebrid-api") return Boolean(settings.bestToken.trim());
if (kind === "bestdebrid-web") return settings.bestDebridUseWebLogin;
if (kind === "alldebrid-api") return Boolean(settings.allDebridToken.trim());
if (kind === "alldebrid-web") return settings.allDebridUseWebLogin;
if (kind === "ddownload-login") return Boolean(settings.ddownloadLogin.trim() && settings.ddownloadPassword);
if (kind === "onefichier-api") return Boolean(settings.oneFichierApiKey.trim());
if (kind === "linksnappy-login") return Boolean(settings.linkSnappyLogin.trim() && settings.linkSnappyPassword);
return false;
}
function setSingle(settings: AppSettings, kind: RendererAccountKind, identity: string | undefined, secret: string | undefined): AppSettings {
if (kind === "realdebrid-api") return { ...settings, token: validateSecret(secret || ""), realDebridUseWebLogin: false };
if (kind === "realdebrid-web") return { ...settings, token: "", realDebridUseWebLogin: true };
if (kind === "bestdebrid-api") return { ...settings, bestToken: validateSecret(secret || ""), bestDebridUseWebLogin: false };
if (kind === "bestdebrid-web") return { ...settings, bestToken: "", bestDebridUseWebLogin: true };
if (kind === "alldebrid-api") return { ...settings, allDebridToken: validateSecret(secret || ""), allDebridUseWebLogin: false };
if (kind === "alldebrid-web") return { ...settings, allDebridToken: "", allDebridUseWebLogin: true };
if (kind === "ddownload-login") return { ...settings, ddownloadLogin: validateIdentity(identity || ""), ddownloadPassword: validateSecret(secret || "") };
if (kind === "onefichier-api") return { ...settings, oneFichierApiKey: validateSecret(secret || "") };
if (kind === "linksnappy-login") return { ...settings, linkSnappyLogin: validateIdentity(identity || ""), linkSnappyPassword: validateSecret(secret || "") };
invalid();
}
function replaceSingle(settings: AppSettings, command: Extract<AccountCommand, { action: "replace" }>): AppliedAccountCommand {
if (!singleConfigured(settings, command.kind) || command.accountId !== `svc-${singleProvider(command.kind)}`) {
invalid();
}
let identity = command.identity;
let secret = command.secret?.trim() ? command.secret : undefined;
if (command.kind === "realdebrid-api") secret ||= settings.token;
if (command.kind === "bestdebrid-api") secret ||= settings.bestToken;
if (command.kind === "alldebrid-api") secret ||= settings.allDebridToken;
if (command.kind === "ddownload-login") {
identity = identity?.trim() ? identity : settings.ddownloadLogin;
secret ||= settings.ddownloadPassword;
}
if (command.kind === "onefichier-api") secret ||= settings.oneFichierApiKey;
if (command.kind === "linksnappy-login") {
identity = identity?.trim() ? identity : settings.linkSnappyLogin;
secret ||= settings.linkSnappyPassword;
}
const provider = singleProvider(command.kind);
const next = setSingle(settings, command.kind, identity, secret);
next.providerDailyLimitBytes = setLimit(settings.providerDailyLimitBytes as Record<string, number>, provider, command.dailyLimitBytes);
return { settings: next, response: { accountId: command.accountId } };
}
function deleteSingle(settings: AppSettings, command: Extract<AccountCommand, { action: "delete" }>): AppliedAccountCommand {
const provider = singleProvider(command.kind);
if (!singleConfigured(settings, command.kind) || command.accountId !== `svc-${provider}`) {
invalid();
}
let next = { ...settings };
if (provider === "realdebrid") next = { ...next, token: "", realDebridUseWebLogin: false };
if (provider === "bestdebrid") next = { ...next, bestToken: "", bestDebridUseWebLogin: false };
if (provider === "alldebrid") next = { ...next, allDebridToken: "", allDebridUseWebLogin: false };
if (provider === "ddownload") next = { ...next, ddownloadLogin: "", ddownloadPassword: "" };
if (provider === "onefichier") next = { ...next, oneFichierApiKey: "" };
if (provider === "linksnappy") next = { ...next, linkSnappyLogin: "", linkSnappyPassword: "" };
next.providerDailyLimitBytes = withoutKeys(settings.providerDailyLimitBytes as Record<string, number>, provider);
next.providerDailyUsageBytes = withoutKeys(settings.providerDailyUsageBytes as Record<string, number>, provider);
next.providerTotalUsageBytes = withoutKeys(settings.providerTotalUsageBytes as Record<string, number>, provider);
return { settings: next, response: { accountId: null } };
}
function createSingle(settings: AppSettings, command: Extract<AccountCommand, { action: "create" }>): AppliedAccountCommand {
if (singleConfigured(settings, command.kind)) {
invalid();
}
const provider = singleProvider(command.kind);
const next = setSingle(settings, command.kind, command.identity, command.secret);
next.providerDailyLimitBytes = setLimit(settings.providerDailyLimitBytes as Record<string, number>, provider, command.dailyLimitBytes);
return { settings: next, response: { accountId: `svc-${provider}` } };
}
export function applyAccountCommand(settings: AppSettings, command: AccountCommand): AppliedAccountCommand {
if (command.action === "update-secret") {
const replace: Extract<AccountCommand, { action: "replace" }> = {
action: "replace",
kind: command.kind,
accountId: command.accountId,
secret: command.secret
};
return applyAccountCommand(settings, replace);
}
if (command.kind === "megadebrid-api" || command.kind === "megadebrid-web") {
if (command.action === "create") return createMega(settings, command);
if (command.action === "replace") return replaceMega(settings, command);
return deleteMega(settings, command);
}
if (command.kind === "debridlink-api") {
if (command.action === "create") return createDebridLink(settings, command);
if (command.action === "replace") return replaceDebridLink(settings, command);
return deleteDebridLink(settings, command);
}
if (command.action === "create") return createSingle(settings, command);
if (command.action === "replace") return replaceSingle(settings, command);
return deleteSingle(settings, command);
}