real-debrid-downloader/src/main/audit-log.ts
Sucukdeluxe 99e4b2b885 Fix: Log-Zeitstempel in lokaler Zeit (mit Offset) statt UTC
User-Report: Logs zeigten z.B. "17:29:43" obwohl es lokal 19:29:43 war (CEST/UTC+2), weil
alle Logger `new Date().toISOString()` (UTC "...Z") nutzten. Neuer Helper logTimestamp()
formatiert lokale Zeit mit explizitem Offset (ISO 8601, z.B. "2026-05-31T19:29:43.605+02:00")
— menschlich lokal UND weiterhin eindeutig/Date.parse-bar. Angewandt auf alle Log-Zeilen-
Writer: item-log, logger (rd_downloader.log), audit-log, rename-log, session-log,
package-log, account-rotation-log, trace-log. Interne/API-/Dateinamen-Zeitstempel
(debug-server, support-bundle, trace autoDisableAt-Config) bleiben absichtlich UTC.

Test: tests/log-timestamp.test.ts (Format + Round-Trip zum selben Instant + lokale Stunde,
TZ-unabhaengig). 650 Tests gruen, tsc 9, Build sauber.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:10:18 +02:00

125 lines
3.2 KiB
TypeScript

import fs from "node:fs";
import { logTimestamp } from "./log-timestamp";
import path from "node:path";
type AuditLevel = "INFO" | "WARN" | "ERROR";
const AUDIT_LOG_MAX_FILE_BYTES = Number(process.env.RD_AUDIT_LOG_MAX_BYTES || 10 * 1024 * 1024);
const AUDIT_LOG_RETENTION_DAYS = Number(process.env.RD_AUDIT_LOG_RETENTION_DAYS || 30);
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, unknown>): 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(" | ")}` : "";
}
function rotateIfNeeded(filePath: string): void {
try {
const stat = fs.statSync(filePath);
if (stat.size < AUDIT_LOG_MAX_FILE_BYTES) {
return;
}
const backup = `${filePath}.old`;
try {
fs.rmSync(backup, { force: true });
} catch {
// ignore
}
fs.renameSync(filePath, backup);
} catch {
// ignore
}
}
function cleanupOldBackup(filePath: string): void {
const backup = `${filePath}.old`;
try {
const stat = fs.statSync(backup);
const cutoff = Date.now() - AUDIT_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
if (stat.mtimeMs < cutoff) {
fs.rmSync(backup, { force: true });
}
} catch {
// ignore
}
}
export function initAuditLog(baseDir: string): void {
auditLogPath = path.join(baseDir, "audit.log");
try {
fs.mkdirSync(path.dirname(auditLogPath), { recursive: true });
cleanupOldBackup(auditLogPath);
if (!fs.existsSync(auditLogPath)) {
fs.writeFileSync(auditLogPath, "", "utf8");
}
rotateIfNeeded(auditLogPath);
if (!fs.existsSync(auditLogPath)) {
fs.writeFileSync(auditLogPath, "", "utf8");
}
fs.appendFileSync(auditLogPath, `=== Audit-Log Start: ${logTimestamp()} ===\n`, "utf8");
} catch {
auditLogPath = null;
}
}
export function logAuditEvent(level: AuditLevel, message: string, fields?: Record<string, unknown>): void {
if (!auditLogPath) {
return;
}
try {
rotateIfNeeded(auditLogPath);
if (!fs.existsSync(auditLogPath)) {
fs.writeFileSync(auditLogPath, "", "utf8");
}
fs.appendFileSync(
auditLogPath,
`${logTimestamp()} [${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: ${logTimestamp()} ===\n`, "utf8");
} catch {
// ignore
}
auditLogPath = null;
}