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:
93
server/utils/logger.ts
Normal file
93
server/utils/logger.ts
Normal 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));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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)}»` };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user