Files
iistwin/server/routes/files.routes.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

214 lines
9.9 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 { Router } from "express";
import fs from 'fs/promises';
import multer from 'multer';
import { storage } from "../storage";
import { authenticateTokenOrApiKey, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { isS3Enabled, uploadToS3FromFile } from "../utils/s3";
import { isFormAllowedByScopes } from "../utils/api-key";
import { upload, EXT_TO_MIME, getFileExt, checkMagicBytes, getSizeLimit, DOC_MAX_SIZE,
getPendingKey, getActivePendingUploads, addPendingUpload,
} from "./shared";
import { db } from "../db";
import { fileUploads } from "@shared/schema";
const router = Router();
// File upload endpoint (JWT пользователя или API-ключ организации)
router.post('/api/upload', authenticateTokenOrApiKey, async (req: AuthenticatedRequest, res) => {
try {
await new Promise<void>((resolve, reject) => {
upload.single('file')(req, res, (err: unknown) => {
if (err) reject(err);
else resolve();
});
});
} catch (err: unknown) {
if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: `Файл слишком большой (максимум ${DOC_MAX_SIZE / 1024 / 1024} МБ)` });
}
const message = err instanceof Error ? err.message : 'Ошибка загрузки файла';
return res.status(400).json({ error: message });
}
if (!req.file) {
return res.status(400).json({ error: 'Файл не передан' });
}
// Проверки для авторизации по API-ключу (JWT-путь не меняется)
if (req.apiKey) {
if (req.apiKey.scopes.mode === 'read') {
if (req.file?.path) { try { await fs.unlink(req.file.path); } catch { /* ignore */ } }
return res.status(403).json({ error: 'API-ключ в режиме read не может загружать файлы' });
}
const taskIdForScope = req.query.taskId ? parseInt(String(req.query.taskId)) : NaN;
if (!isNaN(taskIdForScope)) {
const scopedTask = await storage.getTask(taskIdForScope, req.organizationId!);
if (!scopedTask || !isFormAllowedByScopes(req.apiKey.scopes, scopedTask.formId)) {
if (req.file?.path) { try { await fs.unlink(req.file.path); } catch { /* ignore */ } }
return res.status(403).json({ error: 'Доступ к форме этой задачи запрещён правами API-ключа' });
}
}
}
// deleteNewFile — удаляем временный файл с диска если валидация не прошла
// Теперь всегда disk-режим (S3 тоже пишет во tmpUploadDir до загрузки)
const deleteNewFile = async () => {
if (req.file?.path) {
try { await fs.unlink(req.file.path); } catch { /* ignore */ }
}
};
// ── Дополнительные проверки безопасности после приёма файла ──────────────
// Все проверки основаны на расширении файла (доверенный источник),
// а не на file.mimetype (клиентская переменная, легко подделывается)
const fileExt = getFileExt(req.file.originalname);
// 1. Проверка magic bytes по РАСШИРЕНИЮ (не по MIME)
// Файл, переименованный из .exe в .jpg, не пройдёт эту проверку
const magicOk = await checkMagicBytes(req.file.path, fileExt);
if (!magicOk) {
await deleteNewFile();
return res.status(400).json({ error: 'Содержимое файла не соответствует расширению — загрузка отклонена' });
}
// 2. Раздельный лимит по РАСШИРЕНИЮ: изображения 10 МБ, документы 50 МБ
const typeLimit = getSizeLimit(fileExt);
if (req.file.size > typeLimit) {
await deleteNewFile();
const limitMB = typeLimit / (1024 * 1024);
return res.status(400).json({ error: `Файл превышает лимит ${limitMB} МБ для данного типа` });
}
// ─────────────────────────────────────────────────────────────────────────
const rawFieldId = req.query.fieldId;
const rawTaskId = req.query.taskId;
if (rawFieldId) {
const fieldId = parseInt(String(rawFieldId));
if (isNaN(fieldId) || !req.organizationId) {
await deleteNewFile();
return res.status(400).json({ error: 'Неверный контекст загрузки файла' });
}
let field: Awaited<ReturnType<typeof storage.getFormFieldById>>;
try {
field = await storage.getFormFieldById(fieldId, req.organizationId);
} catch (lookupErr) {
console.error('Upload field limit check error:', lookupErr);
await deleteNewFile();
return res.status(500).json({ error: 'Ошибка проверки ограничений поля' });
}
if (!field || field.type !== 'file') {
await deleteNewFile();
return res.status(404).json({ error: 'Поле не найдено' });
}
const hasCountLimit = field.maxFileCount != null;
const hasSizeLimit = field.maxFileSizeMB != null;
if (hasCountLimit || hasSizeLimit) {
const taskId = rawTaskId ? parseInt(String(rawTaskId)) : NaN;
let existingBytes = 0;
let existingCount = 0;
if (!isNaN(taskId)) {
const existingValues = await storage.getTaskFieldValues(taskId, req.organizationId);
const fieldValue = existingValues.find(v => v.fieldId === fieldId);
if (fieldValue && Array.isArray(fieldValue.value)) {
const existingFiles = fieldValue.value as Array<{ size?: number }>;
existingCount = existingFiles.length;
existingBytes = existingFiles.reduce((sum, f) => sum + (Number(f.size) || 0), 0);
}
} else {
if (req.user?.id) {
const key = getPendingKey(req.user.id, fieldId);
const pending = getActivePendingUploads(key);
existingCount = pending.length;
existingBytes = pending.reduce((sum, e) => sum + e.size, 0);
}
}
if (hasCountLimit && existingCount >= field.maxFileCount!) {
await deleteNewFile();
return res.status(400).json({
error: `Превышено максимальное количество файлов (${field.maxFileCount})`,
});
}
if (hasSizeLimit) {
const limitBytes = field.maxFileSizeMB! * 1024 * 1024;
if (existingBytes + req.file.size > limitBytes) {
await deleteNewFile();
return res.status(400).json({
error: `Суммарный размер файлов превысит лимит (${field.maxFileSizeMB} МБ)`,
});
}
}
}
}
const name = Buffer.from(req.file.originalname, 'latin1').toString('utf8');
let url: string;
if (isS3Enabled) {
// S3/MinIO режим: файл уже на диске в tmpUploadDir, загружаем потоком и удаляем
// Используем нормализованный MIME по расширению, не клиентский mimetype
const normalizedMime = EXT_TO_MIME[fileExt]?.[0] ?? 'application/octet-stream';
try {
const result = await uploadToS3FromFile(req.file.path, name, normalizedMime);
url = result.url;
} catch (s3Err) {
console.error('S3 upload error:', s3Err);
return res.status(500).json({ error: 'Ошибка загрузки файла в хранилище' });
} finally {
// Всегда удаляем временный файл после загрузки в S3
try { await fs.unlink(req.file.path); } catch { /* ignore */ }
}
} else {
// Локальный режим — файл уже сохранён на диске в uploadsDir
url = `/uploads/${req.file.filename}`;
}
// Трекинг для новых задач (без taskId) для контроля лимитов
if (rawFieldId && !rawTaskId && req.user?.id) {
const fieldId = parseInt(String(rawFieldId));
if (!isNaN(fieldId)) {
const key = getPendingKey(req.user.id, fieldId);
addPendingUpload(key, url, req.file.size);
}
}
// Записываем файл в таблицу трекинга для последующей проверки доступа
// Автор: пользователь (JWT) или владелец API-ключа; botId — атрибуция бота
const uploaderId = req.user?.id ?? req.apiKey?.createdBy;
if (uploaderId && req.organizationId) {
const fileKey = isS3Enabled
? url.replace('/api/files/', '')
: (req.file.filename ?? '');
if (fileKey) {
const taskIdNum = rawTaskId ? parseInt(String(rawTaskId)) : undefined;
const fieldIdNum = rawFieldId ? parseInt(String(rawFieldId)) : undefined;
try {
await db.insert(fileUploads).values({
organizationId: req.organizationId,
uploadedBy: uploaderId,
fileKey,
originalName: name,
sizeBytes: req.file.size,
taskId: taskIdNum && !isNaN(taskIdNum) ? taskIdNum : null,
fieldId: fieldIdNum && !isNaN(fieldIdNum) ? fieldIdNum : null,
botId: req.apiKey?.botId ?? null,
}).onConflictDoNothing();
} catch (trackErr) {
console.warn('[Upload] Failed to track file upload:', trackErr);
}
}
}
return res.json({ url, name, size: req.file.size });
});
export default router;