943 lines
33 KiB
TypeScript
943 lines
33 KiB
TypeScript
import http from "node:http";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import crypto from "node:crypto";
|
|
import { APP_VERSION } from "./constants";
|
|
import { getAuditLogPath } from "./audit-log";
|
|
import { getDebugSetupCheck } from "./debug-setup";
|
|
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 { getRenameLogPath } from "./rename-log";
|
|
import { createStoragePaths, loadHistory, loadSettings } from "./storage";
|
|
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
|
|
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
|
|
import { getTraceConfig, getTraceConfigPath, getTraceLogPath, logTraceEvent, setTraceEnabled, updateTraceConfig } from "./trace-log";
|
|
import { getWindowsHostDiagnostics } from "./windows-host-diagnostics";
|
|
import type { DownloadManager } from "./download-manager";
|
|
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: "/debug/setup", description: "Checks whether the local debug setup is configured for support." },
|
|
{ method: "GET", path: "/self-check", description: "Extended support self-check with disk space, log sizes, and support bundle estimate." },
|
|
{ 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/rename", queryExample: "lines=100&grep=keyword", description: "Reads the dedicated rename and MKV move log." },
|
|
{ method: "GET", path: "/logs/trace", queryExample: "lines=100&grep=keyword", description: "Reads the optional support trace log." },
|
|
{ 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: "/trace/config", queryExample: "enable=1¬e=support&durationMinutes=120", description: "Reads or updates the support trace configuration." },
|
|
{ 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: "/support/bundle", description: "Downloads a ZIP support bundle with logs, diagnostics, and redacted 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;
|
|
let authToken = "";
|
|
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 extractDebugClientIp(req: http.IncomingMessage): string {
|
|
const forwarded = req.headers["x-forwarded-for"];
|
|
const forwardedValue = Array.isArray(forwarded) ? forwarded[0] : forwarded;
|
|
const forwardedIp = String(forwardedValue || "").split(",")[0]?.trim();
|
|
if (forwardedIp) {
|
|
return forwardedIp;
|
|
}
|
|
const realIp = String(req.headers["x-real-ip"] || "").trim();
|
|
if (realIp) {
|
|
return realIp;
|
|
}
|
|
const remote = String(req.socket.remoteAddress || req.socket.address()?.address || "").trim();
|
|
return remote.replace(/^::ffff:/i, "");
|
|
}
|
|
|
|
function getAiManifestPath(baseDir: string = runtimeBaseDir): string {
|
|
return path.join(baseDir, AI_MANIFEST_FILE);
|
|
}
|
|
|
|
function getDebugTokenPath(baseDir: string = runtimeBaseDir): string {
|
|
return path.join(baseDir, "debug_token.txt");
|
|
}
|
|
|
|
function loadToken(baseDir: string): string {
|
|
const tokenPath = path.join(baseDir, "debug_token.txt");
|
|
try {
|
|
return fs.readFileSync(tokenPath, "utf8").trim();
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function getPort(baseDir: string): number {
|
|
const portPath = path.join(baseDir, "debug_port.txt");
|
|
try {
|
|
const n = Number(fs.readFileSync(portPath, "utf8").trim());
|
|
if (Number.isFinite(n) && n >= 1024 && n <= 65535) {
|
|
return n;
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return DEFAULT_PORT;
|
|
}
|
|
|
|
function getHost(baseDir: string): string {
|
|
const hostPath = path.join(baseDir, "debug_host.txt");
|
|
try {
|
|
const raw = fs.readFileSync(hostPath, "utf8").trim();
|
|
if (!raw) {
|
|
return DEFAULT_HOST;
|
|
}
|
|
if (/^(localhost|0\.0\.0\.0|127\.0\.0\.1|::1)$/i.test(raw)) {
|
|
return raw;
|
|
}
|
|
if (/^[a-z0-9.-]+$/i.test(raw)) {
|
|
return raw;
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return DEFAULT_HOST;
|
|
}
|
|
|
|
function checkAuth(req: http.IncomingMessage): boolean {
|
|
if (!authToken) {
|
|
return false;
|
|
}
|
|
const header = req.headers.authorization || "";
|
|
if (header === `Bearer ${authToken}`) {
|
|
return true;
|
|
}
|
|
const url = new URL(req.url || "/", "http://localhost");
|
|
return url.searchParams.get("token") === authToken;
|
|
}
|
|
|
|
function jsonResponse(res: http.ServerResponse, status: number, data: unknown): void {
|
|
const body = JSON.stringify(data, null, 2);
|
|
res.writeHead(status, {
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Cache-Control": "no-cache"
|
|
});
|
|
res.end(body);
|
|
}
|
|
|
|
function binaryResponse(
|
|
res: http.ServerResponse,
|
|
status: number,
|
|
body: Buffer,
|
|
contentType: string,
|
|
fileName?: string
|
|
): void {
|
|
res.writeHead(status, {
|
|
"Content-Type": contentType,
|
|
"Content-Length": String(body.length),
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Cache-Control": "no-cache",
|
|
...(fileName ? { "Content-Disposition": `attachment; filename="${fileName}"` } : {})
|
|
});
|
|
res.end(body);
|
|
}
|
|
|
|
function normalizeLinesParam(rawValue: string | null, fallback: number): number {
|
|
const parsed = Number(rawValue || String(fallback));
|
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
return fallback;
|
|
}
|
|
return Math.max(1, Math.min(Math.floor(parsed), MAX_LOG_LINES));
|
|
}
|
|
|
|
function readLogTailFromFile(filePath: string | null, lines: number): string[] {
|
|
if (!filePath) {
|
|
return ["(Log-Datei nicht gefunden)"];
|
|
}
|
|
try {
|
|
const content = fs.readFileSync(filePath, "utf8");
|
|
const allLines = content.split("\n").filter((l) => l.trim().length > 0);
|
|
return allLines.slice(-Math.min(lines, MAX_LOG_LINES));
|
|
} catch {
|
|
return ["(Log-Datei nicht lesbar)"];
|
|
}
|
|
}
|
|
|
|
function filterLines(lines: string[], grep: string): string[] {
|
|
const pattern = String(grep || "").trim().toLowerCase();
|
|
if (!pattern) {
|
|
return lines;
|
|
}
|
|
return lines.filter((line) => line.toLowerCase().includes(pattern));
|
|
}
|
|
|
|
function toBooleanQuery(value: string | null): boolean | null {
|
|
if (value === null) {
|
|
return null;
|
|
}
|
|
if (/^(1|true|yes|on)$/i.test(value)) {
|
|
return true;
|
|
}
|
|
if (/^(0|false|no|off)$/i.test(value)) {
|
|
return false;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function sanitizeRequestUrlForTrace(rawUrl: string): string {
|
|
try {
|
|
const url = new URL(rawUrl || "/", "http://localhost");
|
|
if (url.searchParams.has("token")) {
|
|
url.searchParams.set("token", "***");
|
|
}
|
|
return `${url.pathname}${url.search}`;
|
|
} catch {
|
|
return String(rawUrl || "/");
|
|
}
|
|
}
|
|
|
|
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<string, unknown> {
|
|
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 /self-check or /debug/setup to quickly verify whether token, host, manifest, trace, disk space, and log sizes are in a good support state.",
|
|
"Use /diagnostics for an overview, then drill into /logs/item, /logs/package, /logs/rename, /status, /packages, /items, /settings, /accounts, /stats, /history, or /logs/trace.",
|
|
"If a full handoff is needed, download /support/bundle as a ZIP."
|
|
],
|
|
auth: {
|
|
required: true,
|
|
methods: [
|
|
"Authorization: Bearer <token>",
|
|
"?token=<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(),
|
|
renameLogFile: getRenameLogPath(),
|
|
traceLogFile: getTraceLogPath(),
|
|
traceConfigFile: getTraceConfigPath(),
|
|
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://<SERVER_IP_OR_DNS>:${bindPort}`,
|
|
remoteHostHint
|
|
},
|
|
setupCheckEndpoint: "/debug/setup",
|
|
selfCheckEndpoint: "/self-check",
|
|
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)}`);
|
|
}
|
|
}
|
|
|
|
export function rotateDebugToken(baseDir: string = runtimeBaseDir): { path: string; token: string } {
|
|
const token = crypto.randomBytes(24).toString("hex");
|
|
const tokenPath = getDebugTokenPath(baseDir);
|
|
fs.writeFileSync(tokenPath, `${token}\n`, "utf8");
|
|
if (baseDir === runtimeBaseDir) {
|
|
authToken = token;
|
|
writeAiManifest(baseDir);
|
|
}
|
|
logger.info(`Debug-Server Token rotiert: ${tokenPath}`);
|
|
logTraceEvent("INFO", "support", "Debug-Token rotiert", { tokenPath });
|
|
return { path: tokenPath, token };
|
|
}
|
|
|
|
function summarizeItem(item: DownloadItem): Record<string, unknown> {
|
|
return {
|
|
id: item.id,
|
|
packageId: item.packageId,
|
|
fileName: item.fileName,
|
|
status: item.status,
|
|
fullStatus: item.fullStatus,
|
|
provider: item.provider,
|
|
providerLabel: item.providerLabel || "",
|
|
progress: item.progressPercent,
|
|
speedMBs: +(item.speedBps / 1024 / 1024).toFixed(2),
|
|
downloadedMB: +(item.downloadedBytes / 1024 / 1024).toFixed(1),
|
|
totalMB: item.totalBytes ? +(item.totalBytes / 1024 / 1024).toFixed(1) : null,
|
|
retries: item.retries,
|
|
lastError: item.lastError,
|
|
targetPath: item.targetPath,
|
|
updatedAt: item.updatedAt
|
|
};
|
|
}
|
|
|
|
function summarizePackage(snapshot: UiSnapshot, pkg: PackageEntry, includeItems: boolean): Record<string, unknown> {
|
|
const ids = new Set(pkg.itemIds);
|
|
const packageItems = Object.values(snapshot.session.items).filter((item) => ids.has(item.id));
|
|
const byStatus: Record<string, number> = {};
|
|
for (const item of packageItems) {
|
|
byStatus[item.status] = (byStatus[item.status] || 0) + 1;
|
|
}
|
|
return {
|
|
id: pkg.id,
|
|
name: pkg.name,
|
|
status: pkg.status,
|
|
enabled: pkg.enabled,
|
|
cancelled: pkg.cancelled,
|
|
outputDir: pkg.outputDir,
|
|
extractDir: pkg.extractDir,
|
|
postProcessLabel: pkg.postProcessLabel || "",
|
|
itemCount: pkg.itemIds.length,
|
|
itemCounts: byStatus,
|
|
updatedAt: pkg.updatedAt,
|
|
items: includeItems ? packageItems.map((item) => summarizeItem(item)) : undefined
|
|
};
|
|
}
|
|
|
|
function findPackage(snapshot: UiSnapshot, query: string): PackageEntry | null {
|
|
const needle = String(query || "").trim().toLowerCase();
|
|
if (!needle) {
|
|
return null;
|
|
}
|
|
return Object.values(snapshot.session.packages).find((pkg) =>
|
|
pkg.id.toLowerCase() === needle || pkg.name.toLowerCase().includes(needle)
|
|
) || null;
|
|
}
|
|
|
|
function findItem(snapshot: UiSnapshot, query: string): DownloadItem | null {
|
|
const needle = String(query || "").trim().toLowerCase();
|
|
if (!needle) {
|
|
return null;
|
|
}
|
|
return Object.values(snapshot.session.items).find((item) =>
|
|
item.id.toLowerCase() === needle || item.fileName.toLowerCase().includes(needle)
|
|
) || null;
|
|
}
|
|
|
|
function getPackageLogPathForQuery(snapshot: UiSnapshot, query: string): { pkg: PackageEntry | null; logPath: string | null } {
|
|
const pkg = findPackage(snapshot, query);
|
|
if (pkg) {
|
|
const livePath = manager?.getPackageLogPath(pkg.id) || null;
|
|
return { pkg, logPath: livePath || getPersistedPackageLogPath(pkg.id) };
|
|
}
|
|
const directPath = getPersistedPackageLogPath(String(query || "").trim());
|
|
return { pkg: null, logPath: directPath };
|
|
}
|
|
|
|
function getItemLogPathForQuery(snapshot: UiSnapshot, query: string): { item: DownloadItem | null; logPath: string | null } {
|
|
const item = findItem(snapshot, query);
|
|
if (item) {
|
|
const livePath = manager?.getItemLogPath(item.id) || null;
|
|
return { item, logPath: livePath || getPersistedItemLogPath(item.id) };
|
|
}
|
|
const directPath = getPersistedItemLogPath(String(query || "").trim());
|
|
return { item: null, logPath: directPath };
|
|
}
|
|
|
|
function buildStatusPayload(snapshot: UiSnapshot): Record<string, unknown> {
|
|
const items = Object.values(snapshot.session.items);
|
|
const packages = Object.values(snapshot.session.packages);
|
|
|
|
const byStatus: Record<string, number> = {};
|
|
for (const item of items) {
|
|
byStatus[item.status] = (byStatus[item.status] || 0) + 1;
|
|
}
|
|
|
|
const activeItems = items
|
|
.filter((item) => item.status === "downloading" || item.status === "validating")
|
|
.map((item) => summarizeItem(item));
|
|
|
|
const failedItems = items
|
|
.filter((item) => item.status === "failed")
|
|
.map((item) => summarizeItem(item));
|
|
|
|
return {
|
|
running: snapshot.session.running,
|
|
paused: snapshot.session.paused,
|
|
speed: snapshot.speedText,
|
|
eta: snapshot.etaText,
|
|
itemCounts: byStatus,
|
|
totalItems: items.length,
|
|
totalPackages: packages.length,
|
|
packages: packages.map((pkg) => summarizePackage(snapshot, pkg, false)),
|
|
activeItems,
|
|
failedItems: failedItems.length > 0 ? failedItems : undefined
|
|
};
|
|
}
|
|
|
|
function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
const url = new URL(req.url || "/", "http://localhost");
|
|
const pathname = url.pathname;
|
|
const traceConfig = getTraceConfig();
|
|
if (traceConfig.enabled && traceConfig.logDebugRequests) {
|
|
logTraceEvent("INFO", "debug-http", "Request", {
|
|
method: req.method || "GET",
|
|
url: sanitizeRequestUrlForTrace(req.url || "/"),
|
|
clientIp: extractDebugClientIp(req)
|
|
});
|
|
}
|
|
|
|
if (req.method === "OPTIONS") {
|
|
res.writeHead(204, {
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Access-Control-Allow-Headers": "Authorization",
|
|
"Access-Control-Allow-Methods": "GET,OPTIONS"
|
|
});
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
if (!checkAuth(req)) {
|
|
if (traceConfig.enabled && traceConfig.logDebugRequests) {
|
|
logTraceEvent("WARN", "debug-http", "Unauthorized request", {
|
|
method: req.method || "GET",
|
|
url: sanitizeRequestUrlForTrace(req.url || "/"),
|
|
clientIp: extractDebugClientIp(req)
|
|
});
|
|
}
|
|
jsonResponse(res, 401, { error: "Unauthorized" });
|
|
return;
|
|
}
|
|
|
|
if (pathname === "/health") {
|
|
jsonResponse(res, 200, {
|
|
status: "ok",
|
|
appVersion: APP_VERSION,
|
|
uptime: Math.floor(process.uptime()),
|
|
memoryMB: Math.round(process.memoryUsage().rss / 1024 / 1024)
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathname === "/meta") {
|
|
jsonResponse(res, 200, {
|
|
appVersion: APP_VERSION,
|
|
runtimeBaseDir,
|
|
debugServer: {
|
|
host: bindHost,
|
|
port: bindPort
|
|
},
|
|
supportFiles: {
|
|
aiManifest: getAiManifestPath(),
|
|
traceConfig: getTraceConfigPath(),
|
|
traceLog: getTraceLogPath()
|
|
},
|
|
supportChecks: {
|
|
setup: "/debug/setup",
|
|
selfCheck: "/self-check"
|
|
},
|
|
logPaths: {
|
|
main: getLogFilePath(),
|
|
audit: getAuditLogPath(),
|
|
rename: getRenameLogPath(),
|
|
session: getSessionLogPath(),
|
|
trace: getTraceLogPath()
|
|
},
|
|
endpoints: getEndpointSummaries()
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathname === "/debug/setup" || pathname === "/self-check") {
|
|
jsonResponse(res, 200, getDebugSetupCheck(runtimeBaseDir));
|
|
return;
|
|
}
|
|
|
|
if (pathname === "/host/diagnostics") {
|
|
jsonResponse(res, 200, getWindowsHostDiagnostics());
|
|
return;
|
|
}
|
|
|
|
if (pathname === "/log" || pathname === "/logs/main") {
|
|
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
|
|
const grep = url.searchParams.get("grep") || "";
|
|
const lines = filterLines(readLogTailFromFile(getLogFilePath(), count), grep);
|
|
jsonResponse(res, 200, { lines, count: lines.length });
|
|
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/rename") {
|
|
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
|
|
const grep = url.searchParams.get("grep") || "";
|
|
const logPath = getRenameLogPath();
|
|
const lines = filterLines(readLogTailFromFile(logPath, count), grep);
|
|
jsonResponse(res, 200, {
|
|
path: logPath,
|
|
lines,
|
|
count: lines.length
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathname === "/logs/trace") {
|
|
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
|
|
const grep = url.searchParams.get("grep") || "";
|
|
const logPath = getTraceLogPath();
|
|
const lines = filterLines(readLogTailFromFile(logPath, count), grep);
|
|
jsonResponse(res, 200, {
|
|
path: logPath,
|
|
configPath: getTraceConfigPath(),
|
|
config: getTraceConfig(),
|
|
lines,
|
|
count: lines.length
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathname === "/logs/session") {
|
|
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
|
|
const grep = url.searchParams.get("grep") || "";
|
|
const logPath = getSessionLogPath();
|
|
const lines = filterLines(readLogTailFromFile(logPath, count), grep);
|
|
jsonResponse(res, 200, {
|
|
path: logPath,
|
|
lines,
|
|
count: lines.length
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathname === "/trace/config") {
|
|
const patch: Record<string, unknown> = {};
|
|
const enabled = toBooleanQuery(url.searchParams.get("enable"));
|
|
const includeMainLog = toBooleanQuery(url.searchParams.get("includeMainLog"));
|
|
const includeAudit = toBooleanQuery(url.searchParams.get("includeAudit"));
|
|
const logDebugRequests = toBooleanQuery(url.searchParams.get("logDebugRequests"));
|
|
if (enabled !== null) {
|
|
patch.enabled = enabled;
|
|
}
|
|
if (includeMainLog !== null) {
|
|
patch.includeMainLog = includeMainLog;
|
|
}
|
|
if (includeAudit !== null) {
|
|
patch.includeAudit = includeAudit;
|
|
}
|
|
if (logDebugRequests !== null) {
|
|
patch.logDebugRequests = logDebugRequests;
|
|
}
|
|
const note = String(url.searchParams.get("note") || "").trim();
|
|
const durationMinutesRaw = Number(url.searchParams.get("durationMinutes") || "120");
|
|
const durationMinutes = Number.isFinite(durationMinutesRaw) && durationMinutesRaw > 0
|
|
? Math.min(Math.floor(durationMinutesRaw), 24 * 60)
|
|
: 120;
|
|
let config = getTraceConfig();
|
|
if (enabled !== null) {
|
|
config = setTraceEnabled(enabled, note, durationMinutes * 60 * 1000);
|
|
}
|
|
const configPatch = { ...patch };
|
|
delete configPatch.enabled;
|
|
if (Object.keys(configPatch).length > 0) {
|
|
config = updateTraceConfig(configPatch);
|
|
}
|
|
if (Object.keys(patch).length > 0) {
|
|
logTraceEvent("INFO", "support", "Trace-Konfiguration über Debug-Server geändert", { ...patch, note, durationMinutes });
|
|
}
|
|
jsonResponse(res, 200, {
|
|
path: getTraceConfigPath(),
|
|
logPath: getTraceLogPath(),
|
|
config
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathname === "/logs/package") {
|
|
if (!manager) {
|
|
jsonResponse(res, 503, { error: "Manager not initialized" });
|
|
return;
|
|
}
|
|
const snapshot = manager.getSnapshot();
|
|
const packageQuery = url.searchParams.get("package") || url.searchParams.get("packageId") || "";
|
|
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
|
|
const grep = url.searchParams.get("grep") || "";
|
|
const resolved = getPackageLogPathForQuery(snapshot, packageQuery);
|
|
if (!resolved.logPath) {
|
|
jsonResponse(res, 404, { error: "Package log not found", package: packageQuery });
|
|
return;
|
|
}
|
|
const lines = filterLines(readLogTailFromFile(resolved.logPath, count), grep);
|
|
jsonResponse(res, 200, {
|
|
package: resolved.pkg ? summarizePackage(snapshot, resolved.pkg, false) : undefined,
|
|
path: resolved.logPath,
|
|
lines,
|
|
count: lines.length
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathname === "/logs/item") {
|
|
if (!manager) {
|
|
jsonResponse(res, 503, { error: "Manager not initialized" });
|
|
return;
|
|
}
|
|
const snapshot = manager.getSnapshot();
|
|
const itemQuery = url.searchParams.get("item") || url.searchParams.get("itemId") || "";
|
|
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
|
|
const grep = url.searchParams.get("grep") || "";
|
|
const resolved = getItemLogPathForQuery(snapshot, itemQuery);
|
|
if (!resolved.logPath) {
|
|
jsonResponse(res, 404, { error: "Item log not found", item: itemQuery });
|
|
return;
|
|
}
|
|
const lines = filterLines(readLogTailFromFile(resolved.logPath, count), grep);
|
|
jsonResponse(res, 200, {
|
|
item: resolved.item ? summarizeItem(resolved.item) : undefined,
|
|
path: resolved.logPath,
|
|
lines,
|
|
count: lines.length
|
|
});
|
|
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,
|
|
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs
|
|
}
|
|
});
|
|
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" });
|
|
return;
|
|
}
|
|
const snapshot = manager.getSnapshot();
|
|
jsonResponse(res, 200, buildStatusPayload(snapshot));
|
|
return;
|
|
}
|
|
|
|
if (pathname === "/packages") {
|
|
if (!manager) {
|
|
jsonResponse(res, 503, { error: "Manager not initialized" });
|
|
return;
|
|
}
|
|
const snapshot = manager.getSnapshot();
|
|
const packageQuery = url.searchParams.get("package") || "";
|
|
const includeItems = /^(1|true|yes)$/i.test(String(url.searchParams.get("includeItems") || ""));
|
|
let packages = Object.values(snapshot.session.packages);
|
|
if (packageQuery) {
|
|
const needle = packageQuery.toLowerCase();
|
|
packages = packages.filter((pkg) => pkg.id.toLowerCase() === needle || pkg.name.toLowerCase().includes(needle));
|
|
}
|
|
jsonResponse(res, 200, {
|
|
count: packages.length,
|
|
packages: packages.map((pkg) => summarizePackage(snapshot, pkg, includeItems))
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathname === "/items") {
|
|
if (!manager) {
|
|
jsonResponse(res, 503, { error: "Manager not initialized" });
|
|
return;
|
|
}
|
|
const snapshot = manager.getSnapshot();
|
|
const filter = url.searchParams.get("status");
|
|
const pkg = url.searchParams.get("package");
|
|
let items = Object.values(snapshot.session.items);
|
|
if (filter) {
|
|
items = items.filter((i) => i.status === filter);
|
|
}
|
|
if (pkg) {
|
|
const matchedPkg = findPackage(snapshot, pkg);
|
|
if (matchedPkg) {
|
|
const ids = new Set(matchedPkg.itemIds);
|
|
items = items.filter((i) => ids.has(i.id));
|
|
}
|
|
}
|
|
jsonResponse(res, 200, {
|
|
count: items.length,
|
|
items: items.map((i) => summarizeItem(i))
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathname === "/session") {
|
|
if (!manager) {
|
|
jsonResponse(res, 503, { error: "Manager not initialized" });
|
|
return;
|
|
}
|
|
const snapshot = manager.getSnapshot();
|
|
const pkg = url.searchParams.get("package");
|
|
if (pkg) {
|
|
const matchedPkg = findPackage(snapshot, pkg);
|
|
if (matchedPkg) {
|
|
const ids = new Set(matchedPkg.itemIds);
|
|
const pkgItems = Object.values(snapshot.session.items)
|
|
.filter((i) => ids.has(i.id));
|
|
jsonResponse(res, 200, {
|
|
package: summarizePackage(snapshot, matchedPkg, false),
|
|
items: pkgItems.map((item) => summarizeItem(item))
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
jsonResponse(res, 200, {
|
|
running: snapshot.session.running,
|
|
paused: snapshot.session.paused,
|
|
packageCount: Object.keys(snapshot.session.packages).length,
|
|
itemCount: Object.keys(snapshot.session.items).length,
|
|
packages: Object.values(snapshot.session.packages).map((p) => ({
|
|
id: p.id,
|
|
name: p.name,
|
|
status: p.status,
|
|
items: p.itemIds.length
|
|
}))
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathname === "/support/bundle") {
|
|
if (!manager) {
|
|
jsonResponse(res, 503, { error: "Manager not initialized" });
|
|
return;
|
|
}
|
|
const fileName = getSupportBundleDefaultFileName();
|
|
const body = buildSupportBundle(manager, runtimeBaseDir);
|
|
logTraceEvent("INFO", "support", "Support-Bundle über Debug-Server heruntergeladen", {
|
|
fileName,
|
|
sizeBytes: body.length
|
|
});
|
|
binaryResponse(res, 200, body, "application/zip", fileName);
|
|
return;
|
|
}
|
|
|
|
if (pathname === "/diagnostics") {
|
|
if (!manager) {
|
|
jsonResponse(res, 503, { error: "Manager not initialized" });
|
|
return;
|
|
}
|
|
const snapshot = manager.getSnapshot();
|
|
const lineCount = normalizeLinesParam(url.searchParams.get("lines"), 150);
|
|
const grep = url.searchParams.get("grep") || "";
|
|
const packageQuery = url.searchParams.get("package") || "";
|
|
const mainLogPath = getLogFilePath();
|
|
const sessionLogPath = getSessionLogPath();
|
|
const selectedPackage = packageQuery ? findPackage(snapshot, packageQuery) : null;
|
|
const packageLogPath = selectedPackage
|
|
? manager.getPackageLogPath(selectedPackage.id) || getPersistedPackageLogPath(selectedPackage.id)
|
|
: null;
|
|
jsonResponse(res, 200, {
|
|
meta: {
|
|
appVersion: APP_VERSION,
|
|
serverTime: new Date().toISOString(),
|
|
runtimeBaseDir,
|
|
debugServer: {
|
|
host: bindHost,
|
|
port: bindPort
|
|
},
|
|
setup: getDebugSetupCheck(runtimeBaseDir)
|
|
},
|
|
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: {
|
|
main: {
|
|
path: mainLogPath,
|
|
lines: filterLines(readLogTailFromFile(mainLogPath, lineCount), grep)
|
|
},
|
|
audit: {
|
|
path: getAuditLogPath(),
|
|
lines: filterLines(readLogTailFromFile(getAuditLogPath(), lineCount), grep)
|
|
},
|
|
rename: {
|
|
path: getRenameLogPath(),
|
|
lines: filterLines(readLogTailFromFile(getRenameLogPath(), lineCount), grep)
|
|
},
|
|
trace: {
|
|
path: getTraceLogPath(),
|
|
config: getTraceConfig(),
|
|
lines: filterLines(readLogTailFromFile(getTraceLogPath(), lineCount), grep)
|
|
},
|
|
session: {
|
|
path: sessionLogPath,
|
|
lines: filterLines(readLogTailFromFile(sessionLogPath, lineCount), grep)
|
|
},
|
|
package: selectedPackage ? {
|
|
path: packageLogPath,
|
|
lines: filterLines(readLogTailFromFile(packageLogPath, lineCount), grep)
|
|
} : undefined
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
jsonResponse(res, 404, {
|
|
error: "Not found",
|
|
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;
|
|
|
|
server = http.createServer(handleRequest);
|
|
server.listen(bindPort, bindHost, () => {
|
|
logger.info(`Debug-Server gestartet auf ${bindHost}:${bindPort}`);
|
|
});
|
|
server.on("error", (err) => {
|
|
logger.warn(`Debug-Server Fehler: ${String(err)}`);
|
|
server = null;
|
|
});
|
|
}
|
|
|
|
export function stopDebugServer(): void {
|
|
if (server) {
|
|
server.close();
|
|
server = null;
|
|
logger.info("Debug-Server gestoppt");
|
|
}
|
|
}
|