Шаги 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
310 lines
14 KiB
TypeScript
310 lines
14 KiB
TypeScript
import path from 'path';
|
||
import fs from 'fs/promises';
|
||
import fssync from 'fs';
|
||
import multer from 'multer';
|
||
import crypto from 'crypto';
|
||
import { isS3Enabled, deleteFromS3 } from './s3';
|
||
|
||
// ── File upload security configuration ────────────────────────────────────────
|
||
|
||
// Uploads directory for local disk mode
|
||
export const uploadsDir = path.resolve(process.cwd(), 'uploads');
|
||
if (!fssync.existsSync(uploadsDir)) {
|
||
fssync.mkdirSync(uploadsDir, { recursive: true });
|
||
}
|
||
// Temp directory for S3 mode — files deleted after successful S3 upload
|
||
export const tmpUploadDir = path.resolve(process.cwd(), 'uploads/tmp');
|
||
if (!fssync.existsSync(tmpUploadDir)) {
|
||
fssync.mkdirSync(tmpUploadDir, { recursive: true });
|
||
}
|
||
|
||
// ── Extension-based file classification (trusted source of truth) ─────────────
|
||
// All policy decisions (magic bytes, size limits, MIME consistency) use the
|
||
// file EXTENSION, not file.mimetype, because MIME is client-controlled.
|
||
|
||
// Extension → acceptable MIME types (browser may send any of these for that ext)
|
||
// application/octet-stream is always accepted as a fallback from some browsers/OSes
|
||
export const EXT_TO_MIME: Record<string, readonly string[]> = {
|
||
jpg: ['image/jpeg'],
|
||
jpeg: ['image/jpeg'],
|
||
png: ['image/png'],
|
||
gif: ['image/gif'],
|
||
webp: ['image/webp'],
|
||
svg: ['image/svg+xml'],
|
||
pdf: ['application/pdf'],
|
||
doc: ['application/msword'],
|
||
docx: ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
||
xls: ['application/vnd.ms-excel'],
|
||
xlsx: ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
|
||
ppt: ['application/vnd.ms-powerpoint'],
|
||
pptx: ['application/vnd.openxmlformats-officedocument.presentationml.presentation'],
|
||
txt: ['text/plain'],
|
||
csv: ['text/csv', 'text/plain', 'application/csv'],
|
||
zip: ['application/zip', 'application/x-zip-compressed', 'application/x-zip'],
|
||
rar: ['application/x-rar-compressed', 'application/vnd.rar', 'application/x-rar'],
|
||
};
|
||
|
||
// Magic byte signatures keyed by EXTENSION (not MIME)
|
||
// A file renamed from .exe → .jpg will fail this check
|
||
export const EXT_MAGIC: Record<string, Buffer> = {
|
||
jpg: Buffer.from([0xFF, 0xD8, 0xFF]),
|
||
jpeg: Buffer.from([0xFF, 0xD8, 0xFF]),
|
||
png: Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
|
||
pdf: Buffer.from([0x25, 0x50, 0x44, 0x46]), // %PDF
|
||
};
|
||
|
||
// Image extensions → лимит изображений; all others → документный лимит
|
||
// Лимиты настраиваются через env (в МБ), дефолты: 25 МБ изображения, 100 МБ документы
|
||
export const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg']);
|
||
export const IMAGE_MAX_SIZE = Number(process.env.UPLOAD_IMAGE_MAX_MB || 25) * 1024 * 1024;
|
||
export const DOC_MAX_SIZE = Number(process.env.UPLOAD_DOC_MAX_MB || 100) * 1024 * 1024;
|
||
// Жёсткий потолок multer (fileSize) — отдельный env, дефолт 100 МБ
|
||
export const UPLOAD_MAX_SIZE = Number(process.env.UPLOAD_MAX_MB || 100) * 1024 * 1024;
|
||
|
||
// Derives the lowercase extension from the original filename (trusted)
|
||
export function getFileExt(originalname: string): string {
|
||
return path.extname(path.basename(originalname)).slice(1).toLowerCase();
|
||
}
|
||
|
||
// Validates original filename:
|
||
// - extension must be in EXT_TO_MIME (whitelist)
|
||
// - name part blocks path traversal, shell metacharacters and control characters
|
||
// - Unicode letters (including Cyrillic), digits, spaces, dots, underscores, hyphens and parentheses are allowed
|
||
export function validateFilename(originalname: string): { valid: boolean; reason?: string } {
|
||
const basename = path.basename(originalname);
|
||
const ext = path.extname(basename).slice(1).toLowerCase();
|
||
if (!EXT_TO_MIME[ext]) {
|
||
return { valid: false, reason: `Недопустимое расширение файла: .${ext}` };
|
||
}
|
||
const namePart = path.basename(basename, path.extname(basename));
|
||
if (!namePart) {
|
||
return { valid: false, 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)}»` };
|
||
}
|
||
return { valid: true };
|
||
}
|
||
|
||
// Checks declared MIME against the expected list for the given extension.
|
||
// Returns false if MIME is clearly wrong for the extension (e.g. .jpg + application/msword).
|
||
// application/octet-stream is always accepted as a browser fallback.
|
||
export function isMimeConsistentWithExt(ext: string, mime: string): boolean {
|
||
if (mime === 'application/octet-stream') return true; // browser fallback — always ok
|
||
const allowed = EXT_TO_MIME[ext];
|
||
if (!allowed) return false;
|
||
return allowed.includes(mime);
|
||
}
|
||
|
||
// Reads magic bytes from file on disk and compares against known signature for the extension.
|
||
// Returns true if no signature is defined for this extension (no check needed).
|
||
export async function checkMagicBytes(filePath: string, ext: string): Promise<boolean> {
|
||
const signature = EXT_MAGIC[ext];
|
||
if (!signature) return true;
|
||
const fd = await fs.open(filePath, 'r');
|
||
try {
|
||
const buf = Buffer.alloc(signature.length);
|
||
await fd.read(buf, 0, signature.length, 0);
|
||
return buf.equals(signature);
|
||
} finally {
|
||
await fd.close();
|
||
}
|
||
}
|
||
|
||
// Returns the size limit in bytes based on extension (trusted), not MIME (client-controlled)
|
||
export function getSizeLimit(ext: string): number {
|
||
return IMAGE_EXTENSIONS.has(ext) ? IMAGE_MAX_SIZE : DOC_MAX_SIZE;
|
||
}
|
||
|
||
// Always use disk storage (tmpUploadDir for S3 mode so we can stream to S3, then delete)
|
||
const multerStorage = multer.diskStorage({
|
||
destination: (_req, _file, cb) => cb(null, isS3Enabled ? tmpUploadDir : uploadsDir),
|
||
filename: (_req, file, cb) => {
|
||
const ext = getFileExt(file.originalname);
|
||
const uniqueName = `${Date.now()}-${crypto.randomBytes(6).toString('hex')}.${ext}`;
|
||
cb(null, uniqueName);
|
||
},
|
||
});
|
||
|
||
export const upload = multer({
|
||
storage: multerStorage,
|
||
limits: { fileSize: UPLOAD_MAX_SIZE }, // жёсткий потолок (UPLOAD_MAX_MB); раздельная проверка по типам — в хендлере
|
||
fileFilter: (_req, file, cb) => {
|
||
// 1. Validate filename: strict regex + extension whitelist (extension is trusted)
|
||
const { valid, reason } = validateFilename(file.originalname);
|
||
if (!valid) {
|
||
return cb(new Error(reason || 'Недопустимое имя файла'));
|
||
}
|
||
// 2. Reject if declared MIME is clearly incompatible with the extension
|
||
// (e.g. .jpg uploaded with Content-Type: application/msword is suspicious)
|
||
const ext = getFileExt(file.originalname);
|
||
if (!isMimeConsistentWithExt(ext, file.mimetype)) {
|
||
return cb(new Error(`Тип файла (${file.mimetype}) не соответствует расширению .${ext}`));
|
||
}
|
||
cb(null, true);
|
||
},
|
||
});
|
||
// ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
// Удаляет физический файл из uploads/ по сохранённому значению поля (объект или JSON-строка)
|
||
// Извлекает все URL из значения файлового поля (одиночный объект, массив или JSON-строка)
|
||
export function extractFileUrls(fileValue: unknown): string[] {
|
||
if (!fileValue) return [];
|
||
// Массив объектов [{url, name, size}]
|
||
if (Array.isArray(fileValue)) {
|
||
return fileValue
|
||
.filter((f): f is Record<string, unknown> => f && typeof f === 'object')
|
||
.map(f => (typeof f.url === 'string' ? f.url : null))
|
||
.filter((u): u is string => !!u);
|
||
}
|
||
// Одиночный объект {url, name, size}
|
||
if (typeof fileValue === 'object') {
|
||
const obj = fileValue as Record<string, unknown>;
|
||
return typeof obj.url === 'string' ? [obj.url] : [];
|
||
}
|
||
// JSON-строка
|
||
if (typeof fileValue === 'string' && fileValue) {
|
||
try {
|
||
return extractFileUrls(JSON.parse(fileValue));
|
||
} catch {
|
||
return fileValue ? [fileValue] : [];
|
||
}
|
||
}
|
||
return [];
|
||
}
|
||
|
||
// Нормализация self-URL файла к относительному виду.
|
||
// Абсолютный http(s) URL, pathname которого начинается с /api/files/ или /uploads/
|
||
// (т.е. указывает на этот же сервер), сводится к pathname — в БД храним относительные URL.
|
||
// Внешние URL и невалидные строки возвращаются без изменений.
|
||
export function normalizeFileUrl(url: string): string {
|
||
if (!url.startsWith('http://') && !url.startsWith('https://')) return url;
|
||
try {
|
||
const pathname = new URL(url).pathname;
|
||
if (pathname.startsWith('/api/files/') || pathname.startsWith('/uploads/')) {
|
||
return pathname;
|
||
}
|
||
} catch {
|
||
// невалидный URL — оставляем как есть
|
||
}
|
||
return url;
|
||
}
|
||
|
||
// Нормализует url внутри значения file-поля: одиночный объект {url,name,size}
|
||
// или массив таких объектов. Прочие значения возвращаются без изменений.
|
||
export function normalizeFileFieldUrls(value: unknown): unknown {
|
||
const normalizeItem = (item: unknown): unknown => {
|
||
if (item && typeof item === 'object' && typeof (item as Record<string, unknown>).url === 'string') {
|
||
const obj = item as Record<string, unknown>;
|
||
return { ...obj, url: normalizeFileUrl(obj.url as string) };
|
||
}
|
||
return item;
|
||
};
|
||
if (Array.isArray(value)) return value.map(normalizeItem);
|
||
return normalizeItem(value);
|
||
}
|
||
|
||
export async function deleteFileByUrl(url: string): Promise<void> {
|
||
if (isS3Enabled) {
|
||
// S3-режим: извлекаем ключ из URL вида /api/files/:key
|
||
const key = url.startsWith('/api/files/') ? url.slice('/api/files/'.length) : path.basename(url);
|
||
if (!key || key.includes('..') || key.includes('/')) return;
|
||
await deleteFromS3(key);
|
||
} else {
|
||
// Локальный режим: удаляем с диска
|
||
const filename = path.basename(url);
|
||
if (!filename || filename.includes('..') || !filename.includes('-')) return;
|
||
try {
|
||
await fs.unlink(path.join(uploadsDir, filename));
|
||
} catch (e: unknown) {
|
||
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||
console.error('Ошибка удаления файла с диска:', e);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Удаляет все файлы из значения поля (используется при полном удалении поля)
|
||
export async function deleteUploadedFile(fileValue: unknown): Promise<void> {
|
||
const urls = extractFileUrls(fileValue);
|
||
for (const url of urls) {
|
||
await deleteFileByUrl(url);
|
||
}
|
||
}
|
||
|
||
// Удаляет только файлы, которые были в oldValue но исчезли в newValue
|
||
export async function deleteRemovedFiles(oldValue: unknown, newValue: unknown): Promise<void> {
|
||
const oldUrls = new Set(extractFileUrls(oldValue));
|
||
const newUrls = new Set(extractFileUrls(newValue));
|
||
for (const url of oldUrls) {
|
||
if (!newUrls.has(url)) {
|
||
await deleteFileByUrl(url);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Pending-upload registry for new-task flows ────────────────────────────────
|
||
// Tracks uploads that have been accepted but not yet committed to a saved task.
|
||
// Key: `${userId}_${fieldId}`; entries expire after 60 minutes.
|
||
// This allows the server to enforce per-field limits without trusting the client.
|
||
export interface PendingUploadEntry {
|
||
url: string;
|
||
size: number;
|
||
expiresAt: number;
|
||
}
|
||
export const pendingUploads = new Map<string, PendingUploadEntry[]>();
|
||
export const PENDING_UPLOAD_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||
|
||
export function getPendingKey(userId: number, fieldId: number): string {
|
||
return `${userId}_${fieldId}`;
|
||
}
|
||
|
||
export function getActivePendingUploads(key: string): PendingUploadEntry[] {
|
||
const now = Date.now();
|
||
const entries = pendingUploads.get(key) || [];
|
||
const expired = entries.filter(e => e.expiresAt <= now);
|
||
const active = entries.filter(e => e.expiresAt > now);
|
||
if (active.length !== entries.length) {
|
||
pendingUploads.set(key, active);
|
||
// Удаляем осиротевшие файлы (disk или S3) асинхронно
|
||
expired.forEach(e => deleteFileByUrl(e.url).catch(() => {/* ignore */}));
|
||
}
|
||
return active;
|
||
}
|
||
|
||
export function addPendingUpload(key: string, url: string, size: number): void {
|
||
const active = getActivePendingUploads(key);
|
||
active.push({ url, size, expiresAt: Date.now() + PENDING_UPLOAD_TTL_MS });
|
||
pendingUploads.set(key, active);
|
||
}
|
||
|
||
export function commitPendingUploads(userId: number, fieldId: number, committedUrls: string[]): void {
|
||
const key = getPendingKey(userId, fieldId);
|
||
const active = getActivePendingUploads(key);
|
||
const urlSet = new Set(committedUrls);
|
||
pendingUploads.set(key, active.filter(e => !urlSet.has(e.url)));
|
||
}
|
||
|
||
export function sweepPendingUploads(): void {
|
||
const now = Date.now();
|
||
for (const [key, entries] of pendingUploads.entries()) {
|
||
const expired = entries.filter(e => e.expiresAt <= now);
|
||
const active = entries.filter(e => e.expiresAt > now);
|
||
if (expired.length > 0) {
|
||
if (active.length === 0) {
|
||
pendingUploads.delete(key);
|
||
} else {
|
||
pendingUploads.set(key, active);
|
||
}
|
||
expired.forEach(e => deleteFileByUrl(e.url).catch(() => {/* ignore */}));
|
||
}
|
||
}
|
||
}
|
||
// Run orphan sweep every hour
|
||
setInterval(sweepPendingUploads, 60 * 60 * 1000);
|
||
// ──────────────────────────────────────────────────────────────────────────────
|