diff --git a/README.md b/README.md index dac32f7..2a7a615 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,8 @@ Runtime files are stored in Electron's `userData` directory, including: - `rd_session_state.json` - `rd_history.json` - `rd_downloader.log` +- `audit.log` +- `debug_ai_manifest.json` - `session-logs/session_*.txt` - `package-logs/package_*.txt` - `item-logs/item_*.txt` @@ -198,17 +200,24 @@ Enable it by creating these files in the same runtime folder that contains `rd_d - `debug_host.txt` (optional) Default is `127.0.0.1`. Set `0.0.0.0` only if you really want remote access and protect it with firewall, VPN, or reverse proxy. +After startup, the app also writes `debug_ai_manifest.json` into the same runtime folder. This file is meant for support tooling and AI agents: it lists all available endpoints, the auth method, the related runtime files, and the one remaining external value the assistant may still need from you for remote access: the server IP or DNS name. + Available endpoints after restart: - `GET /health` - `GET /meta` - `GET /host/diagnostics` - `GET /status` +- `GET /settings` +- `GET /accounts` +- `GET /stats` +- `GET /history?limit=50&status=completed` - `GET /packages?package=Release&includeItems=1` - `GET /items?status=downloading&package=Release` - `GET /session?package=Release` - `GET /log?lines=100&grep=keyword` - `GET /logs/main?lines=100&grep=keyword` +- `GET /logs/audit?lines=100&grep=keyword` - `GET /logs/session?lines=100&grep=keyword` - `GET /logs/package?package=Release&lines=100&grep=keyword` - `GET /logs/item?item=episode.part2.rar&lines=100&grep=keyword` @@ -223,12 +232,17 @@ Example from PowerShell: ```powershell Invoke-RestMethod "http://SERVER:9868/diagnostics?token=YOUR_TOKEN&package=Release" +Invoke-RestMethod "http://SERVER:9868/settings?token=YOUR_TOKEN" +Invoke-RestMethod "http://SERVER:9868/accounts?token=YOUR_TOKEN" +Invoke-RestMethod "http://SERVER:9868/stats?token=YOUR_TOKEN" +Invoke-RestMethod "http://SERVER:9868/history?token=YOUR_TOKEN&limit=20" +Invoke-RestMethod "http://SERVER:9868/logs/audit?token=YOUR_TOKEN&lines=200" Invoke-RestMethod "http://SERVER:9868/logs/package?token=YOUR_TOKEN&package=Release&lines=200" Invoke-RestMethod "http://SERVER:9868/logs/item?token=YOUR_TOKEN&item=episode.part2.rar&lines=200" Invoke-RestMethod "http://SERVER:9868/host/diagnostics?token=YOUR_TOKEN" ``` -This makes it easy to share one URL plus token during support, so current package status, session state, package/session logs, and host-side Windows crash hints can be inspected remotely. +This makes it easy to share one URL plus token during support, so current package status, session state, history, redacted account/settings state, audit actions, package/session/item logs, and host-side Windows crash hints can be inspected remotely. ## Troubleshooting diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index e8d424e..0adb9ea 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -36,6 +36,8 @@ import { addHistoryEntry, cancelPendingAsyncSaves, clearHistory, createStoragePa import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update"; import { startDebugServer, stopDebugServer } from "./debug-server"; import { encryptBackup, decryptBackup } from "./backup-crypto"; +import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log"; +import { buildAccountSummary, diffAccountSummary } from "./support-data"; function sanitizeSettingsPatch(partial: Partial): Partial { const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined); @@ -74,6 +76,7 @@ export class AppController { initSessionLog(this.storagePaths.baseDir); initPackageLogs(this.storagePaths.baseDir); initItemLogs(this.storagePaths.baseDir); + initAuditLog(this.storagePaths.baseDir); this.settings = loadSettings(this.storagePaths); const session = loadSession(this.storagePaths); this.megaWebFallback = new MegaWebFallback(() => ({ @@ -98,6 +101,10 @@ export class AppController { }); logger.info(`App gestartet v${APP_VERSION}`); logger.info(`Log-Datei: ${getLogFilePath()}`); + logAuditEvent("INFO", "App gestartet", { + appVersion: APP_VERSION, + runtimeDir: this.storagePaths.baseDir + }); startDebugServer(this.manager, this.storagePaths.baseDir); if (this.settings.autoResumeOnStart) { @@ -169,6 +176,14 @@ export class AppController { return this.settings; } + public getAuditLogPath(): string | null { + return getAuditLogPath(); + } + + private audit(level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record): void { + logAuditEvent(level, message, fields); + } + public updateSettings(partial: Partial): AppSettings { const sanitizedPatch = sanitizeSettingsPatch(partial); const previousSettings = this.settings; @@ -197,6 +212,10 @@ export class AppController { this.settings = nextSettings; saveSettings(this.storagePaths, this.settings); this.manager.setSettings(this.settings); + this.audit("INFO", "Einstellungen aktualisiert", { + 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)}`); @@ -220,6 +239,7 @@ export class AppController { this.settings = nextSettings; saveSettings(this.storagePaths, this.settings); this.manager.setSettings(this.settings); + this.audit("INFO", "Provider-Tagesnutzung zurückgesetzt", { provider }); return this.settings; } @@ -232,19 +252,27 @@ export class AppController { this.settings = nextSettings; 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 { + this.audit("INFO", "Real-Debrid Login-Fenster geöffnet"); await this.realDebridWebFallback.openLoginWindow(); } public async openAllDebridLoginWindow(): Promise { + this.audit("INFO", "AllDebrid Login-Fenster geöffnet"); await this.allDebridWebFallback.openLoginWindow(); } public async importBestDebridCookies(filePath: string): Promise { - return this.bestDebridWebFallback.importCookiesFromFile(filePath); + const imported = await this.bestDebridWebFallback.importCookiesFromFile(filePath); + this.audit("INFO", "BestDebrid Cookies importiert", { + filePath, + imported + }); + return imported; } public async getAllDebridHostInfo(host = "rapidgator"): Promise { @@ -293,9 +321,17 @@ export class AppController { public addLinks(payload: AddLinksPayload): { addedPackages: number; addedLinks: number; invalidCount: number } { const parsed = parseCollectorInput(payload.rawText, payload.packageName || this.settings.packageName); if (parsed.length === 0) { + this.audit("WARN", "Links hinzufügen ohne gültigen Inhalt", { + hasPackageName: Boolean(payload.packageName) + }); return { addedPackages: 0, addedLinks: 0, invalidCount: 1 }; } const result = this.manager.addPackages(parsed); + this.audit("INFO", "Links hinzugefügt", { + addedPackages: result.addedPackages, + addedLinks: result.addedLinks, + requestedPackages: parsed.length + }); return { ...result, invalidCount: 0 }; } @@ -307,6 +343,11 @@ export class AppController { ...(pkg.fileNames ? { fileNames: pkg.fileNames } : {}) })); const result = this.manager.addPackages(merged); + this.audit("INFO", "Container importiert", { + files: filePaths.length, + addedPackages: result.addedPackages, + addedLinks: result.addedLinks + }); return result; } @@ -319,58 +360,73 @@ export class AppController { } public clearAll(): void { + this.audit("WARN", "Queue komplett geleert"); this.manager.clearAll(); } public async start(): Promise { + this.audit("INFO", "Session-Start ausgelöst"); await this.manager.start(); } public async startPackages(packageIds: string[]): Promise { + this.audit("INFO", "Paket-Start ausgelöst", { packageIds }); await this.manager.startPackages(packageIds); } public async startItems(itemIds: string[]): Promise { + this.audit("INFO", "Item-Start ausgelöst", { itemIds }); await this.manager.startItems(itemIds); } public stop(): void { + this.audit("INFO", "Session-Stopp ausgelöst"); this.manager.stop(); } public togglePause(): boolean { - return this.manager.togglePause(); + const paused = this.manager.togglePause(); + this.audit("INFO", "Pause umgeschaltet", { paused }); + return paused; } public retryExtraction(packageId: string): void { + this.audit("INFO", "Extraktion manuell wiederholt", { packageId }); this.manager.retryExtraction(packageId); } public extractNow(packageId: string): void { + this.audit("INFO", "Jetzt entpacken ausgelöst", { packageId }); this.manager.extractNow(packageId); } public resetPackage(packageId: string): void { + this.audit("INFO", "Paket zurückgesetzt", { packageId }); this.manager.resetPackage(packageId); } public cancelPackage(packageId: string): void { + this.audit("WARN", "Paket abgebrochen", { packageId }); this.manager.cancelPackage(packageId); } public renamePackage(packageId: string, newName: string): void { + this.audit("INFO", "Paket umbenannt", { packageId, newName }); this.manager.renamePackage(packageId, newName); } public reorderPackages(packageIds: string[]): void { + this.audit("INFO", "Paketreihenfolge geändert", { packageIds }); this.manager.reorderPackages(packageIds); } public removeItem(itemId: string): void { + this.audit("WARN", "Item entfernt", { itemId }); this.manager.removeItem(itemId); } public togglePackage(packageId: string): void { + this.audit("INFO", "Paket aktiviert/deaktiviert", { packageId }); this.manager.togglePackage(packageId); } @@ -387,12 +443,14 @@ export class AppController { } public resetSessionStats(): void { + this.audit("INFO", "Session-Statistik zurückgesetzt"); this.manager.resetSessionStats(); } public resetDownloadStats(): void { this.manager.resetDownloadStats(); this.settings = this.manager.getSettings(); + this.audit("INFO", "Download-Statistik zurückgesetzt"); } public exportBackup(): Buffer { @@ -407,6 +465,11 @@ export class AppController { session, history }); + this.audit("INFO", "Backup exportiert", { + historyEntries: history.length, + sessionItems: Object.keys(session.items).length, + sessionPackages: Object.keys(session.packages).length + }); return encryptBackup(payload); } @@ -475,6 +538,10 @@ export class AppController { this.manager.skipShutdownPersist = true; this.manager.blockAllPersistence = true; logger.info("Backup wiederhergestellt (verschlüsseltes Format)"); + this.audit("WARN", "Backup importiert", { + historyEntries: Array.isArray(parsed.history) ? parsed.history.length : 0, + accountSummary: buildAccountSummary(this.settings) + }); return { restored: true, message: "Backup wiederhergestellt. Bitte App neustarten." }; } @@ -501,6 +568,8 @@ export class AppController { shutdownSessionLog(); shutdownPackageLogs(); shutdownItemLogs(); + this.audit("INFO", "App beendet"); + shutdownAuditLog(); logger.info("App beendet"); } @@ -509,26 +578,38 @@ export class AppController { } public clearHistory(): void { + this.audit("WARN", "Verlauf geleert"); clearHistory(this.storagePaths); } public setPackagePriority(packageId: string, priority: PackagePriority): void { + this.audit("INFO", "Paket-Priorität geändert", { packageId, priority }); this.manager.setPackagePriority(packageId, priority); } public skipItems(itemIds: string[]): void { + this.audit("INFO", "Items übersprungen", { itemIds }); this.manager.skipItems(itemIds); } public resetItems(itemIds: string[]): void { + this.audit("INFO", "Items zurückgesetzt", { itemIds }); this.manager.resetItems(itemIds); } public removeHistoryEntry(entryId: string): void { + this.audit("INFO", "Verlaufseintrag entfernt", { entryId }); removeHistoryEntry(this.storagePaths, entryId); } public addToHistory(entry: HistoryEntry): void { + this.audit("INFO", "Verlaufseintrag hinzugefügt", { + id: entry.id, + name: entry.name, + status: entry.status, + provider: entry.provider, + fileCount: entry.fileCount + }); addHistoryEntry(this.storagePaths, entry); } } diff --git a/src/main/audit-log.ts b/src/main/audit-log.ts new file mode 100644 index 0000000..93557ad --- /dev/null +++ b/src/main/audit-log.ts @@ -0,0 +1,80 @@ +import fs from "node:fs"; +import path from "node:path"; + +type AuditLevel = "INFO" | "WARN" | "ERROR"; + +let auditLogPath: string | null = null; + +function sanitizeFieldValue(value: unknown): string { + if (value === undefined || value === null) { + return ""; + } + if (typeof value === "string") { + return value.replace(/\r?\n/g, "\\n"); + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + try { + return JSON.stringify(value).replace(/\r?\n/g, "\\n"); + } catch { + return String(value); + } +} + +function formatFields(fields?: Record): string { + if (!fields) { + return ""; + } + const parts = Object.entries(fields) + .filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "") + .map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`); + return parts.length > 0 ? ` | ${parts.join(" | ")}` : ""; +} + +export function initAuditLog(baseDir: string): void { + auditLogPath = path.join(baseDir, "audit.log"); + try { + fs.mkdirSync(path.dirname(auditLogPath), { recursive: true }); + if (!fs.existsSync(auditLogPath)) { + fs.writeFileSync(auditLogPath, "", "utf8"); + } + fs.appendFileSync(auditLogPath, `=== Audit-Log Start: ${new Date().toISOString()} ===\n`, "utf8"); + } catch { + auditLogPath = null; + } +} + +export function logAuditEvent(level: AuditLevel, message: string, fields?: Record): void { + if (!auditLogPath) { + return; + } + try { + fs.appendFileSync( + auditLogPath, + `${new Date().toISOString()} [${level}] ${message}${formatFields(fields)}\n`, + "utf8" + ); + } catch { + // ignore write errors + } +} + +export function getAuditLogPath(): string | null { + if (!auditLogPath) { + return null; + } + return fs.existsSync(auditLogPath) ? auditLogPath : null; +} + +export function shutdownAuditLog(): void { + if (!auditLogPath) { + return; + } + try { + fs.appendFileSync(auditLogPath, `=== Audit-Log Ende: ${new Date().toISOString()} ===\n`, "utf8"); + } catch { + // ignore + } + auditLogPath = null; +} diff --git a/src/main/debug-server.ts b/src/main/debug-server.ts index fbb38d6..49f9284 100644 --- a/src/main/debug-server.ts +++ b/src/main/debug-server.ts @@ -2,10 +2,13 @@ import http from "node:http"; import fs from "node:fs"; import path from "node:path"; import { APP_VERSION } from "./constants"; +import { getAuditLogPath } from "./audit-log"; import { logger, getLogFilePath } from "./logger"; import { getItemLogPath as getPersistedItemLogPath } from "./item-log"; import { getSessionLogPath } from "./session-log"; import { getPackageLogPath as getPersistedPackageLogPath } from "./package-log"; +import { createStoragePaths, loadHistory, loadSettings } from "./storage"; +import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data"; import { getWindowsHostDiagnostics } from "./windows-host-diagnostics"; import type { DownloadManager } from "./download-manager"; import type { DownloadItem, PackageEntry, UiSnapshot } from "../shared/types"; @@ -13,6 +16,35 @@ import type { DownloadItem, PackageEntry, UiSnapshot } from "../shared/types"; const DEFAULT_PORT = 9868; const DEFAULT_HOST = "127.0.0.1"; const MAX_LOG_LINES = 10000; +const AI_MANIFEST_FILE = "debug_ai_manifest.json"; + +type DebugEndpointDescriptor = { + method: "GET"; + path: string; + queryExample?: string; + description: string; +}; + +const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [ + { method: "GET", path: "/health", description: "Basic health, uptime, and memory information." }, + { method: "GET", path: "/meta", description: "Lists runtime metadata and all available endpoints." }, + { method: "GET", path: "/host/diagnostics", description: "Returns Windows host crash and dump diagnostics." }, + { method: "GET", path: "/log", queryExample: "lines=100&grep=keyword", description: "Legacy alias for the main application log tail." }, + { method: "GET", path: "/logs/main", queryExample: "lines=100&grep=keyword", description: "Reads the main application log tail." }, + { method: "GET", path: "/logs/audit", queryExample: "lines=100&grep=keyword", description: "Reads the audit log for support-relevant UI and admin actions." }, + { method: "GET", path: "/logs/session", queryExample: "lines=100&grep=keyword", description: "Reads the session log tail." }, + { method: "GET", path: "/logs/package", queryExample: "package=Release&lines=100&grep=keyword", description: "Reads the package log for a specific package name or id." }, + { method: "GET", path: "/logs/item", queryExample: "item=episode.part2.rar&lines=100&grep=keyword", description: "Reads the item log for a specific file name or item id." }, + { method: "GET", path: "/settings", description: "Returns a redacted settings snapshot without raw secrets." }, + { method: "GET", path: "/accounts", description: "Returns a redacted account/provider configuration summary." }, + { method: "GET", path: "/stats", description: "Returns live session stats plus persisted all-time totals." }, + { method: "GET", path: "/history", queryExample: "limit=50&status=completed", description: "Returns history entries with optional filters." }, + { method: "GET", path: "/status", description: "Returns a live high-level status overview." }, + { method: "GET", path: "/packages", queryExample: "package=Release&includeItems=1", description: "Lists packages and optional per-item detail." }, + { method: "GET", path: "/items", queryExample: "status=downloading&package=Release", description: "Lists items and supports status/package filters." }, + { method: "GET", path: "/session", queryExample: "package=Release", description: "Returns session-wide or package-scoped item state." }, + { method: "GET", path: "/diagnostics", queryExample: "package=Release&lines=150", description: "Returns a combined support snapshot with logs, status, settings, accounts, stats, history, and host diagnostics." } +]; let server: http.Server | null = null; let manager: DownloadManager | null = null; @@ -21,6 +53,22 @@ let bindHost = DEFAULT_HOST; let bindPort = DEFAULT_PORT; let runtimeBaseDir = ""; +function getStoragePaths() { + return createStoragePaths(runtimeBaseDir); +} + +function readSupportSettings() { + return loadSettings(getStoragePaths()); +} + +function readSupportHistory() { + return loadHistory(getStoragePaths()); +} + +function getAiManifestPath(baseDir: string = runtimeBaseDir): string { + return path.join(baseDir, AI_MANIFEST_FILE); +} + function loadToken(baseDir: string): string { const tokenPath = path.join(baseDir, "debug_token.txt"); try { @@ -113,6 +161,78 @@ function filterLines(lines: string[], grep: string): string[] { return lines.filter((line) => line.toLowerCase().includes(pattern)); } +function formatEndpointSummary(endpoint: DebugEndpointDescriptor): string { + return `${endpoint.method} ${endpoint.path}${endpoint.queryExample ? `?${endpoint.queryExample}` : ""}`; +} + +function getEndpointSummaries(): string[] { + return DEBUG_ENDPOINTS.map((endpoint) => formatEndpointSummary(endpoint)); +} + +function buildAiManifest(baseDir: string): Record { + const remoteHostHint = bindHost === "0.0.0.0" + ? "Use the server IP or DNS name for remote access. Ask the user only for that host value if it is unknown." + : "If remote access is required and the bind host is local-only, switch debug_host.txt to 0.0.0.0 and reopen the firewall."; + + return { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + appVersion: APP_VERSION, + runtimeBaseDir: baseDir, + purpose: "Machine-readable support manifest for AI tools and remote troubleshooting.", + quickstart: [ + "Read debug_token.txt and debug_port.txt from this runtime folder.", + "If remote access is needed, ask the user only for the server IP or DNS name.", + "Call /meta first to confirm the server is reachable and to re-read the endpoint list.", + "Use /diagnostics for an overview, then drill into /logs/item, /logs/package, /status, /packages, /items, /settings, /accounts, /stats, or /history." + ], + auth: { + required: true, + methods: [ + "Authorization: Bearer ", + "?token=" + ], + tokenFile: path.join(baseDir, "debug_token.txt") + }, + runtimeFiles: { + hostFile: path.join(baseDir, "debug_host.txt"), + portFile: path.join(baseDir, "debug_port.txt"), + tokenFile: path.join(baseDir, "debug_token.txt"), + mainLogFile: getLogFilePath(), + auditLogFile: getAuditLogPath(), + sessionLogFile: getSessionLogPath(), + packageLogDir: path.join(baseDir, "package-logs"), + itemLogDir: path.join(baseDir, "item-logs"), + settingsFile: path.join(baseDir, "rd_downloader_config.json"), + sessionFile: path.join(baseDir, "rd_session_state.json"), + historyFile: path.join(baseDir, "rd_history.json") + }, + debugServer: { + enabled: Boolean(authToken), + host: bindHost, + port: bindPort, + localBaseUrl: `http://127.0.0.1:${bindPort}`, + remoteBaseUrlTemplate: `http://:${bindPort}`, + remoteHostHint + }, + askUserFor: [ + "Server IP or DNS name, if remote access is required and not already known." + ], + endpoints: DEBUG_ENDPOINTS.map((endpoint) => ({ + ...endpoint, + summary: formatEndpointSummary(endpoint) + })) + }; +} + +function writeAiManifest(baseDir: string): void { + try { + fs.writeFileSync(getAiManifestPath(baseDir), JSON.stringify(buildAiManifest(baseDir), null, 2), "utf8"); + } catch (error) { + logger.warn(`Debug-Server: KI-Support-Datei konnte nicht geschrieben werden: ${String(error)}`); + } +} + function summarizeItem(item: DownloadItem): Record { return { id: item.id, @@ -264,25 +384,14 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi host: bindHost, port: bindPort }, + supportFiles: { + aiManifest: getAiManifestPath() + }, logPaths: { main: getLogFilePath(), session: getSessionLogPath() }, - endpoints: [ - "GET /health", - "GET /meta", - "GET /host/diagnostics", - "GET /log?lines=100&grep=keyword", - "GET /logs/main?lines=100&grep=keyword", - "GET /logs/session?lines=100&grep=keyword", - "GET /logs/package?package=Release&lines=100&grep=keyword", - "GET /logs/item?item=episode.part2.rar&lines=100&grep=keyword", - "GET /status", - "GET /packages?package=Release&includeItems=1", - "GET /items?status=downloading&package=Release", - "GET /session?package=Release", - "GET /diagnostics?package=Release&lines=150" - ] + endpoints: getEndpointSummaries() }); return; } @@ -300,6 +409,19 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi return; } + if (pathname === "/logs/audit") { + const count = normalizeLinesParam(url.searchParams.get("lines"), 100); + const grep = url.searchParams.get("grep") || ""; + const logPath = getAuditLogPath(); + const lines = filterLines(readLogTailFromFile(logPath, count), grep); + jsonResponse(res, 200, { + path: logPath, + lines, + count: lines.length + }); + return; + } + if (pathname === "/logs/session") { const count = normalizeLinesParam(url.searchParams.get("lines"), 100); const grep = url.searchParams.get("grep") || ""; @@ -361,6 +483,58 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi return; } + if (pathname === "/settings") { + const settings = readSupportSettings(); + jsonResponse(res, 200, buildRedactedSettingsPayload(settings)); + return; + } + + if (pathname === "/accounts") { + const settings = readSupportSettings(); + jsonResponse(res, 200, buildAccountSummary(settings)); + return; + } + + if (pathname === "/stats") { + if (!manager) { + jsonResponse(res, 503, { error: "Manager not initialized" }); + return; + } + const snapshot = manager.getSnapshot(); + const settings = readSupportSettings(); + jsonResponse(res, 200, { + ...buildStatsPayload(snapshot), + allTime: { + totalDownloadedAllTime: settings.totalDownloadedAllTime, + totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime + } + }); + return; + } + + if (pathname === "/history") { + const entries = readSupportHistory(); + const limit = normalizeLinesParam(url.searchParams.get("limit"), 50); + const statusFilter = String(url.searchParams.get("status") || "").trim().toLowerCase(); + const grep = String(url.searchParams.get("grep") || "").trim().toLowerCase(); + let filtered = entries; + if (statusFilter) { + filtered = filtered.filter((entry) => String(entry.status || "").toLowerCase() === statusFilter); + } + if (grep) { + filtered = filtered.filter((entry) => JSON.stringify(summarizeHistoryEntry(entry)).toLowerCase().includes(grep)); + } + const sliced = filtered + .sort((a, b) => Number(b.completedAt || 0) - Number(a.completedAt || 0)) + .slice(0, limit); + jsonResponse(res, 200, { + count: sliced.length, + total: filtered.length, + entries: sliced.map((entry) => summarizeHistoryEntry(entry)) + }); + return; + } + if (pathname === "/status") { if (!manager) { jsonResponse(res, 503, { error: "Manager not initialized" }); @@ -478,6 +652,16 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi } }, status: buildStatusPayload(snapshot), + settings: buildRedactedSettingsPayload(readSupportSettings()), + stats: buildStatsPayload(snapshot), + accounts: buildAccountSummary(readSupportSettings()), + history: { + total: readSupportHistory().length, + recent: readSupportHistory() + .sort((a, b) => Number(b.completedAt || 0) - Number(a.completedAt || 0)) + .slice(0, 10) + .map((entry) => summarizeHistoryEntry(entry)) + }, host: getWindowsHostDiagnostics(), selectedPackage: selectedPackage ? summarizePackage(snapshot, selectedPackage, true) : undefined, logs: { @@ -485,6 +669,10 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi path: mainLogPath, lines: filterLines(readLogTailFromFile(mainLogPath, lineCount), grep) }, + audit: { + path: getAuditLogPath(), + lines: filterLines(readLogTailFromFile(getAuditLogPath(), lineCount), grep) + }, session: { path: sessionLogPath, lines: filterLines(readLogTailFromFile(sessionLogPath, lineCount), grep) @@ -500,35 +688,22 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi jsonResponse(res, 404, { error: "Not found", - endpoints: [ - "GET /health", - "GET /meta", - "GET /host/diagnostics", - "GET /log?lines=100&grep=keyword", - "GET /logs/main?lines=100&grep=keyword", - "GET /logs/session?lines=100&grep=keyword", - "GET /logs/package?package=Release&lines=100&grep=keyword", - "GET /logs/item?item=episode.part2.rar&lines=100&grep=keyword", - "GET /status", - "GET /packages?package=Release&includeItems=1", - "GET /items?status=downloading&package=Bloodline", - "GET /session?package=Criminal", - "GET /diagnostics?package=Criminal&lines=150" - ] + endpoints: getEndpointSummaries() }); } export function startDebugServer(mgr: DownloadManager, baseDir: string): void { runtimeBaseDir = baseDir; authToken = loadToken(baseDir); + bindPort = getPort(baseDir); + bindHost = getHost(baseDir); + writeAiManifest(baseDir); if (!authToken) { logger.info("Debug-Server: Kein Token in debug_token.txt, Server wird nicht gestartet"); return; } manager = mgr; - bindPort = getPort(baseDir); - bindHost = getHost(baseDir); server = http.createServer(handleRequest); server.listen(bindPort, bindHost, () => { diff --git a/src/main/support-data.ts b/src/main/support-data.ts new file mode 100644 index 0000000..cf5f32d --- /dev/null +++ b/src/main/support-data.ts @@ -0,0 +1,178 @@ +import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; +import type { AppSettings, HistoryEntry, UiSnapshot } from "../shared/types"; + +function hasText(value: unknown): boolean { + return String(value || "").trim().length > 0; +} + +export function buildAccountSummary(settings: AppSettings): Record { + const debridLinkKeyIds = getDebridLinkApiKeyIds(settings.debridLinkApiKeys); + const disabledDebridLinkIds = new Set(settings.debridLinkDisabledKeyIds || []); + + return { + realDebrid: { + configured: hasText(settings.token) || settings.realDebridUseWebLogin, + tokenConfigured: hasText(settings.token), + webLoginEnabled: settings.realDebridUseWebLogin, + rememberToken: settings.rememberToken + }, + megaDebrid: { + configured: (hasText(settings.megaLogin) && hasText(settings.megaPassword)) + || settings.megaDebridApiEnabled + || settings.megaDebridWebEnabled, + loginConfigured: hasText(settings.megaLogin) && hasText(settings.megaPassword), + apiEnabled: settings.megaDebridApiEnabled, + webEnabled: settings.megaDebridWebEnabled, + preferApi: settings.megaDebridPreferApi + }, + bestDebrid: { + configured: hasText(settings.bestToken) || settings.bestDebridUseWebLogin, + tokenConfigured: hasText(settings.bestToken), + webLoginEnabled: settings.bestDebridUseWebLogin + }, + allDebrid: { + configured: hasText(settings.allDebridToken) || settings.allDebridUseWebLogin, + tokenConfigured: hasText(settings.allDebridToken), + webLoginEnabled: settings.allDebridUseWebLogin + }, + ddownload: { + configured: hasText(settings.ddownloadLogin) && hasText(settings.ddownloadPassword) + }, + oneFichier: { + configured: hasText(settings.oneFichierApiKey) + }, + debridLink: { + configured: debridLinkKeyIds.length > 0, + keyCount: debridLinkKeyIds.length, + enabledKeyCount: debridLinkKeyIds.filter((id) => !disabledDebridLinkIds.has(id)).length, + disabledKeyCount: debridLinkKeyIds.filter((id) => disabledDebridLinkIds.has(id)).length + }, + linkSnappy: { + configured: hasText(settings.linkSnappyLogin) && hasText(settings.linkSnappyPassword) + }, + disabledProviders: [...(settings.disabledProviders || [])] + }; +} + +export function diffAccountSummary(previous: AppSettings, next: AppSettings): Record { + const before = buildAccountSummary(previous); + const after = buildAccountSummary(next); + const changes: Record = {}; + for (const key of Object.keys(after)) { + const beforeJson = JSON.stringify(before[key]); + const afterJson = JSON.stringify(after[key]); + if (beforeJson !== afterJson) { + changes[key] = after[key]; + } + } + return changes; +} + +export function buildRedactedSettingsPayload(settings: AppSettings): Record { + return { + paths: { + outputDir: settings.outputDir, + extractDir: settings.extractDir, + mkvLibraryDir: settings.mkvLibraryDir + }, + providers: { + providerOrder: settings.providerOrder, + providerPrimary: settings.providerPrimary, + providerSecondary: settings.providerSecondary, + providerTertiary: settings.providerTertiary, + autoProviderFallback: settings.autoProviderFallback, + disabledProviders: settings.disabledProviders, + hosterRouting: settings.hosterRouting + }, + extraction: { + autoExtract: settings.autoExtract, + autoExtractWhenStopped: settings.autoExtractWhenStopped, + hybridExtract: settings.hybridExtract, + createExtractSubfolder: settings.createExtractSubfolder, + cleanupMode: settings.cleanupMode, + extractConflictMode: settings.extractConflictMode, + removeLinkFilesAfterExtract: settings.removeLinkFilesAfterExtract, + removeSamplesAfterExtract: settings.removeSamplesAfterExtract, + enableIntegrityCheck: settings.enableIntegrityCheck, + archivePasswordCount: String(settings.archivePasswordList || "") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .length, + extractCpuPriority: settings.extractCpuPriority, + maxParallelExtract: settings.maxParallelExtract + }, + downloads: { + maxParallel: settings.maxParallel, + retryLimit: settings.retryLimit, + autoResumeOnStart: settings.autoResumeOnStart, + autoReconnect: settings.autoReconnect, + reconnectWaitSeconds: settings.reconnectWaitSeconds, + autoSkipExtracted: settings.autoSkipExtracted, + completedCleanupPolicy: settings.completedCleanupPolicy + }, + ui: { + packageName: settings.packageName, + theme: settings.theme, + collapseNewPackages: settings.collapseNewPackages, + hideExtractedItems: settings.hideExtractedItems, + confirmDeleteSelection: settings.confirmDeleteSelection, + clipboardWatch: settings.clipboardWatch, + minimizeToTray: settings.minimizeToTray, + columnOrder: settings.columnOrder + }, + bandwidth: { + speedLimitEnabled: settings.speedLimitEnabled, + speedLimitKbps: settings.speedLimitKbps, + speedLimitMode: settings.speedLimitMode, + bandwidthSchedules: settings.bandwidthSchedules + }, + updates: { + updateRepo: settings.updateRepo, + autoUpdateCheck: settings.autoUpdateCheck + }, + statistics: { + totalDownloadedAllTime: settings.totalDownloadedAllTime, + totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime, + providerDailyLimitBytes: settings.providerDailyLimitBytes, + providerDailyUsageBytes: settings.providerDailyUsageBytes, + providerTotalUsageBytes: settings.providerTotalUsageBytes, + debridLinkApiKeyDailyLimitBytes: settings.debridLinkApiKeyDailyLimitBytes, + debridLinkApiKeyDailyUsageBytes: settings.debridLinkApiKeyDailyUsageBytes, + debridLinkApiKeyTotalUsageBytes: settings.debridLinkApiKeyTotalUsageBytes, + providerDailyUsageDay: settings.providerDailyUsageDay + }, + accounts: buildAccountSummary(settings) + }; +} + +export function buildStatsPayload(snapshot: UiSnapshot): Record { + return { + session: snapshot.stats, + totals: { + totalPackages: Object.keys(snapshot.session.packages).length, + totalItems: Object.keys(snapshot.session.items).length, + speedText: snapshot.speedText, + etaText: snapshot.etaText, + canStart: snapshot.canStart, + canStop: snapshot.canStop, + canPause: snapshot.canPause + } + }; +} + +export function summarizeHistoryEntry(entry: HistoryEntry): Record { + return { + id: entry.id, + name: entry.name, + status: entry.status, + provider: entry.provider, + fileCount: entry.fileCount, + totalBytes: entry.totalBytes, + downloadedBytes: entry.downloadedBytes, + durationSeconds: entry.durationSeconds, + completedAt: entry.completedAt, + outputDir: entry.outputDir, + urlCount: Array.isArray(entry.urls) ? entry.urls.length : 0 + }; +} diff --git a/tests/audit-log.test.ts b/tests/audit-log.test.ts new file mode 100644 index 0000000..a874d9d --- /dev/null +++ b/tests/audit-log.test.ts @@ -0,0 +1,32 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "../src/main/audit-log"; + +const tempDirs: string[] = []; + +afterEach(() => { + shutdownAuditLog(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("audit-log", () => { + it("writes audit events to the audit log", () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-alog-")); + tempDirs.push(baseDir); + + initAuditLog(baseDir); + logAuditEvent("INFO", "Settings changed", { changedKeys: ["token", "autoExtract"] }); + + const logPath = getAuditLogPath(); + expect(logPath).not.toBeNull(); + expect(fs.existsSync(logPath!)).toBe(true); + const content = fs.readFileSync(logPath!, "utf8"); + expect(content).toContain("Audit-Log Start"); + expect(content).toContain("Settings changed"); + expect(content).toContain("changedKeys"); + }); +}); diff --git a/tests/debug-server.test.ts b/tests/debug-server.test.ts index c5e477f..4aafb6a 100644 --- a/tests/debug-server.test.ts +++ b/tests/debug-server.test.ts @@ -40,11 +40,14 @@ vi.mock("../src/main/windows-host-diagnostics", () => ({ })); import { defaultSettings } from "../src/main/constants"; +import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "../src/main/audit-log"; import { startDebugServer, stopDebugServer } from "../src/main/debug-server"; import { ensureItemLog, initItemLogs, shutdownItemLogs } from "../src/main/item-log"; import { configureLogger, getLogFilePath } from "../src/main/logger"; import { ensurePackageLog, initPackageLogs, shutdownPackageLogs } from "../src/main/package-log"; import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/main/session-log"; +import { createStoragePaths, saveHistory, saveSettings } from "../src/main/storage"; +import { getDebridLinkApiKeyIds } from "../src/shared/debrid-link-keys"; import type { DownloadManager } from "../src/main/download-manager"; import type { UiSnapshot } from "../src/shared/types"; @@ -188,13 +191,47 @@ async function createFixture() { const token = "debug-secret"; const port = await getFreePort(); const snapshot = buildSnapshot(baseDir); + const storagePaths = createStoragePaths(baseDir); fs.writeFileSync(path.join(baseDir, "debug_token.txt"), token, "utf8"); fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8"); fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "0.0.0.0", "utf8"); + const debridLinkApiKeys = "key-a\nkey-b"; + const debridLinkKeyIds = getDebridLinkApiKeyIds(debridLinkApiKeys); + + saveSettings(storagePaths, { + ...snapshot.settings, + token: "rd-secret-token", + realDebridUseWebLogin: true, + debridLinkApiKeys, + debridLinkDisabledKeyIds: debridLinkKeyIds[1] ? [debridLinkKeyIds[1]] : [], + totalDownloadedAllTime: 128 * 1024 * 1024, + totalCompletedFilesAllTime: 12 + }); + saveHistory(storagePaths, [ + { + id: "hist-1", + name: "server-package", + totalBytes: 123, + downloadedBytes: 123, + fileCount: 2, + provider: "realdebrid", + completedAt: Date.now() - 5_000, + durationSeconds: 42, + status: "completed", + outputDir: path.join(baseDir, "downloads", "server-package"), + urls: ["https://hoster.example/file-1"] + } + ]); configureLogger(baseDir); fs.writeFileSync(getLogFilePath(), "2026-03-09T00:00:00.000Z [INFO] MAIN-LINE\n", "utf8"); + initAuditLog(baseDir); + const auditLogPath = getAuditLogPath(); + if (!auditLogPath) { + throw new Error("audit log path missing"); + } + logAuditEvent("INFO", "AUDIT-LINE", { scope: "settings" }); initSessionLog(baseDir); const sessionLogPath = getSessionLogPath(); @@ -239,7 +276,8 @@ async function createFixture() { return { baseUrl, - token + token, + baseDir }; } @@ -248,6 +286,7 @@ afterEach(() => { shutdownSessionLog(); shutdownPackageLogs(); shutdownItemLogs(); + shutdownAuditLog(); while (tempDirs.length > 0) { const dir = tempDirs.pop(); if (!dir) { @@ -275,8 +314,31 @@ describe("debug-server", () => { expect(payload.host?.recentKernelPower?.[0]?.id).toBe(41); expect(payload.selectedPackage?.name).toBe("server-package"); expect((payload.logs?.main?.lines || []).join("\n")).toContain("MAIN-LINE"); + expect((payload.logs?.audit?.lines || []).join("\n")).toContain("AUDIT-LINE"); expect((payload.logs?.session?.lines || []).join("\n")).toContain("SESSION-LINE"); expect((payload.logs?.package?.lines || []).join("\n")).toContain("PACKAGE-LINE"); + expect(payload.accounts?.realDebrid?.configured).toBe(true); + expect(payload.history?.total).toBe(1); + }); + + it("writes a machine-readable AI support manifest into the runtime folder", async () => { + const fixture = await createFixture(); + const manifestPath = path.join(fixture.baseDir, "debug_ai_manifest.json"); + expect(fs.existsSync(manifestPath)).toBe(true); + + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as Record; + expect(manifest.appVersion).toBeTruthy(); + expect(manifest.debugServer?.port).toBeGreaterThan(0); + expect(manifest.debugServer?.remoteBaseUrlTemplate).toContain(""); + expect(manifest.quickstart?.[1]).toContain("server IP"); + expect(manifest.runtimeFiles?.tokenFile).toContain("debug_token.txt"); + expect(manifest.endpoints?.some((entry: Record) => entry.path === "/diagnostics")).toBe(true); + expect(JSON.stringify(manifest)).not.toContain(fixture.token); + + const metaResponse = await fetch(`${fixture.baseUrl}/meta?token=${fixture.token}`); + expect(metaResponse.ok).toBe(true); + const metaPayload = await metaResponse.json() as Record; + expect(metaPayload.supportFiles?.aiManifest).toBe(manifestPath); }); it("serves package details and package log by package query", async () => { @@ -316,6 +378,43 @@ describe("debug-server", () => { expect(payload.assessmentHints?.[0]).toContain("watchdog"); }); + it("serves audit log, settings, accounts, stats, and history", async () => { + const fixture = await createFixture(); + + const auditResponse = await fetch(`${fixture.baseUrl}/logs/audit?token=${fixture.token}&lines=20`); + expect(auditResponse.ok).toBe(true); + const auditPayload = await auditResponse.json() as Record; + expect((auditPayload.lines || []).join("\n")).toContain("AUDIT-LINE"); + + const settingsResponse = await fetch(`${fixture.baseUrl}/settings?token=${fixture.token}`); + expect(settingsResponse.ok).toBe(true); + const settingsPayload = await settingsResponse.json() as Record; + expect(settingsPayload.accounts?.realDebrid?.configured).toBe(true); + expect(settingsPayload.extraction?.archivePasswordCount).toBe(0); + expect(JSON.stringify(settingsPayload)).not.toContain("rd-secret-token"); + expect(JSON.stringify(settingsPayload)).not.toContain("key-a"); + expect(JSON.stringify(settingsPayload)).not.toContain("key-b"); + + const accountsResponse = await fetch(`${fixture.baseUrl}/accounts?token=${fixture.token}`); + expect(accountsResponse.ok).toBe(true); + const accountsPayload = await accountsResponse.json() as Record; + expect(accountsPayload.debridLink?.keyCount).toBe(2); + expect(accountsPayload.debridLink?.disabledKeyCount).toBe(1); + + const statsResponse = await fetch(`${fixture.baseUrl}/stats?token=${fixture.token}`); + expect(statsResponse.ok).toBe(true); + const statsPayload = await statsResponse.json() as Record; + expect(statsPayload.session?.totalDownloaded).toBeGreaterThan(0); + expect(statsPayload.allTime?.totalDownloadedAllTime).toBeGreaterThan(0); + + const historyResponse = await fetch(`${fixture.baseUrl}/history?token=${fixture.token}&limit=10`); + expect(historyResponse.ok).toBe(true); + const historyPayload = await historyResponse.json() as Record; + expect(historyPayload.total).toBe(1); + expect(historyPayload.entries?.[0]?.name).toBe("server-package"); + expect(historyPayload.entries?.[0]?.urlCount).toBe(1); + }); + it("rejects unauthenticated requests", async () => { const fixture = await createFixture(); const response = await fetch(`${fixture.baseUrl}/status`);