chore(ci+logs): eslint в CI, npm audit, logger с уровнями, retention error_logs

Шаги 1.6 и 1.7 плана production-готовности:
- eslint flat-config (баг-ловушки, легаси warn), lint блокирующий в pr-check
- npm audit --audit-level=high в CI (отчёт)
- фикс реального бага: условный useRef в TaskTitleInline
- server/utils/logger.ts (LOG_LEVEL/LOG_FORMAT) в 5 горячих местах
- error_logs retention 30 дней (worker), redactSensitive в captureErrorLog
- 0 errors lint, vitest 96/96
This commit is contained in:
2026-09-08 00:11:38 +03:00
parent 6eaca79cc6
commit abd5d4b65f
14 changed files with 1579 additions and 19 deletions

93
server/utils/logger.ts Normal file
View File

@@ -0,0 +1,93 @@
/**
* Единый серверный logger.
*
* НОВЫЕ модули сервера должны использовать этот logger вместо console.*,
* чтобы уровни и формат логов управлялись централизованно:
*
* import { logger } from "./utils/logger";
* const log = logger("billing");
* log.info("Payment created", { id: 123 });
*
* Уровень логирования — env LOG_LEVEL (debug|info|warn|error), по умолчанию info.
* Формат — env LOG_FORMAT=json включает JSON-строки (для сборщиков логов),
* иначе вывод в историческом формате сервера: "HH:MM:SS AM [source] message",
* совместимом с log() из server/vite.ts.
*
* Массовая замена console.* по существующему коду сознательно не делается —
* logger подключается точечно в новых/горячих местах.
*/
type LogLevel = "debug" | "info" | "warn" | "error";
const LEVEL_ORDER: Record<LogLevel, number> = {
debug: 10,
info: 20,
warn: 30,
error: 40,
};
function resolveMinLevel(): number {
const raw = (process.env.LOG_LEVEL || "info").toLowerCase();
return LEVEL_ORDER[raw as LogLevel] ?? LEVEL_ORDER.info;
}
const MIN_LEVEL = resolveMinLevel();
const JSON_FORMAT = process.env.LOG_FORMAT === "json";
// Формат времени как в server/vite.ts log() — чтобы строки не отличались
function formattedTime(): string {
return new Date().toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true,
});
}
function serialize(level: LogLevel, source: string, message: string, meta?: unknown): string {
if (JSON_FORMAT) {
return JSON.stringify({
time: new Date().toISOString(),
level,
source,
message,
...(meta !== undefined ? { meta } : {}),
});
}
// Ошибки и объекты разворачиваем в читаемую строку
let suffix = "";
if (meta instanceof Error) {
suffix = ` ${meta.stack || meta.message}`;
} else if (meta !== undefined) {
try {
suffix = ` ${JSON.stringify(meta)}`;
} catch {
suffix = ` [unserializable meta]`;
}
}
return `${formattedTime()} [${source}] ${message}${suffix}`;
}
export interface Logger {
debug(message: string, meta?: unknown): void;
info(message: string, meta?: unknown): void;
warn(message: string, meta?: unknown): void;
error(message: string, meta?: unknown): void;
}
export function logger(source: string): Logger {
return {
debug(message, meta) {
if (MIN_LEVEL <= LEVEL_ORDER.debug) console.debug(serialize("debug", source, message, meta));
},
info(message, meta) {
if (MIN_LEVEL <= LEVEL_ORDER.info) console.log(serialize("info", source, message, meta));
},
warn(message, meta) {
if (MIN_LEVEL <= LEVEL_ORDER.warn) console.warn(serialize("warn", source, message, meta));
},
error(message, meta) {
if (MIN_LEVEL <= LEVEL_ORDER.error) console.error(serialize("error", source, message, meta));
},
};
}

View File

@@ -83,6 +83,7 @@ export function validateFilename(originalname: string): { valid: boolean; reason
// Block path traversal, shell metacharacters, and control characters
// Allow Unicode letters (including Cyrillic), digits, spaces, dots, underscores, hyphens, parentheses
// (скобки безопасны: сохраняемое имя файла генерируется, оригинальное используется только для отображения)
// eslint-disable-next-line no-control-regex -- управляющие символы в имени файла запрещаем осознанно
if (/[\/\\&|;$<>\`\"'\x00-\x1f]/.test(namePart) || namePart.includes('..')) {
return { valid: false, reason: `Имя файла содержит недопустимые символы: «${namePart.slice(0, 100)}»` };
}