Files
iistwin/server/utils/upload.ts
Ильяс Султанов abd5d4b65f 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
2026-09-08 00:11:38 +03:00

310 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
// ──────────────────────────────────────────────────────────────────────────────