Files
iistwin/server/utils/upload.ts
Ильяс Султанов 8cfd49fd9f Атрибуция от бота в MCP/REST + REST API по ключу (этап 2)
- MCP: getActor (владелец ключа + бот), аудит changedByName=бот/label ключа, botId, metadata.source='mcp'; аудит для update_task/update_task_status
- sendTaskMessage: botId (messageType 'bot'), починен путь isBotToken без req.user
- authenticateTokenOrApiKey: JWT или X-Api-Key с белым списком endpoint'ов (/api/upload, messages, field-values, create task), проверки скоупов, аудит source='api'
- Лимиты файлов через env: UPLOAD_IMAGE_MAX_MB=25, UPLOAD_DOC_MAX_MB=100, UPLOAD_MAX_MB=100
- MCP upload-инструменты: параметр fileUrl для привязки уже загруженного файла; новый инструмент get_api_guide
2026-07-22 15:18:04 +03:00

277 lines
12 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 and hyphens 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
if (/[\/\\&|;$()<>\`\"'\x00-\x1f]/.test(namePart) || namePart.includes('..')) {
return { valid: false, reason: 'Имя файла содержит недопустимые символы' };
}
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 [];
}
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);
// ──────────────────────────────────────────────────────────────────────────────