import crypto from 'crypto' import fs from 'fs/promises' import path from 'path' import { getServerDataPath } from './paths.js' const RETENTION_MS = 72 * 60 * 60 * 1000 const LOG_FILE = getServerDataPath('password-reset.log.jsonl') export function normalizeResetEmail(email) { return String(email || '').trim().toLowerCase() } export function maskResetEmail(email) { const normalized = normalizeResetEmail(email) const [localPart = '', domain = ''] = normalized.split('@') if (!domain) return normalized ? `${localPart.slice(0, 2)}***` : '' const localVisible = localPart.slice(0, Math.min(2, localPart.length)) const domainParts = domain.split('.') const domainName = domainParts.shift() || '' const suffix = domainParts.length ? `.${domainParts.join('.')}` : '' return `${localVisible}***@${domainName.slice(0, 2)}***${suffix}` } export function fingerprintResetEmail(email) { return crypto.createHash('sha256').update(normalizeResetEmail(email)).digest('hex') } function safeText(value, max = 160) { return String(value == null ? '' : value).slice(0, max) } function errorLabel(error) { return safeText(error?.code || error?.name || 'Error', 80) } function sanitizedErrorMessage(error) { return safeText(error?.message || error || '') .replace(/[^\s<>"']+@[^\s<>"']+/gi, email => maskResetEmail(email)) .replace(/((?:pass(?:word)?|token|secret|authorization|auth)\s*[=:]\s*)[^\s,;]+/gi, '$1[redacted]') .replace(/(smtp:\/\/[^:\s/]+:)[^@\s/]+@/gi, '$1[redacted]@') } export async function writePasswordResetLog({ requestId, email, ip, step, status, userId, reason, error }) { const normalizedEmail = normalizeResetEmail(email) const entry = { ts: new Date().toISOString(), requestId: safeText(requestId, 80), emailMasked: maskResetEmail(normalizedEmail), emailFingerprint: fingerprintResetEmail(normalizedEmail), ip: safeText(ip, 80), step: safeText(step, 80), status: safeText(status, 40) } if (userId) entry.userId = safeText(userId, 80) if (reason) entry.reason = safeText(reason, 100) if (error) { entry.errorCode = errorLabel(error) entry.errorMessage = sanitizedErrorMessage(error) } await fs.mkdir(path.dirname(LOG_FILE), { recursive: true }) await fs.appendFile(LOG_FILE, `${JSON.stringify(entry)}\n`, 'utf8') } export async function cleanupPasswordResetLogs(now = Date.now()) { let contents try { contents = await fs.readFile(LOG_FILE, 'utf8') } catch (error) { if (error.code === 'ENOENT') return { retained: 0, removed: 0 } throw error } const threshold = now - RETENTION_MS const entries = contents .split('\n') .filter(Boolean) .flatMap(line => { try { return [JSON.parse(line)] } catch { return [] } }) const retained = entries.filter(entry => new Date(entry.ts).getTime() >= threshold) const removed = entries.length - retained.length if (removed > 0) { const serialized = retained.map(entry => JSON.stringify(entry)).join('\n') await fs.writeFile(LOG_FILE, serialized ? `${serialized}\n` : '', 'utf8') } return { retained: retained.length, removed } } export async function readPasswordResetLogs() { await cleanupPasswordResetLogs() try { const contents = await fs.readFile(LOG_FILE, 'utf8') return contents .split('\n') .filter(Boolean) .flatMap(line => { try { return [JSON.parse(line)] } catch { return [] } }) .sort((a, b) => String(b.ts).localeCompare(String(a.ts))) } catch (error) { if (error.code === 'ENOENT') return [] throw error } } export const PASSWORD_RESET_LOG_RETENTION_HOURS = RETENTION_MS / (60 * 60 * 1000)