Files
iistwin/server/routes/files.routes.ts
Ильяс Султанов affbdeee1e Upload-тикеты: загрузка файлов через REST без знания API-ключа
- MCP create_upload_ticket → короткоживущий uploadUrl (10 мин, in-memory)
- POST /api/upload/ticket/:token — тот же пайплайн (processUploadRequest вынесен из /api/upload), авторизация тикетом, проверки скоупов
- get_api_guide: флоу без ключа — ticket → curl → привязка по fileUrl
2026-07-23 15:53:17 +03:00

293 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 { Router, type Response } 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 { consumeUploadTicket } from "../utils/upload-tickets";
import { openTenantCtx, _tenantCtx } from "../db";
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();
// Контекст загрузки, не зависящий от способа авторизации
// (JWT пользователя / API-ключ / upload-тикет)
interface UploadRequestContext {
organizationId: number;
/** Автор загрузки для file_uploads.uploadedBy (владелец ключа/тикета).
* Для JWT-пути не передаётся — берётся req.user.id. */
uploadedBy?: number;
/** Атрибуция бота для file_uploads.botId */
botId?: number | null;
}
// Общий пайплайн загрузки файла ПОСЛЕ авторизации:
// multer → magic bytes → лимиты по типу/полю → S3 или локальный диск → file_uploads.
// Используется и POST /api/upload (JWT/API-ключ), и POST /api/upload/ticket/:token.
async function processUploadRequest(req: AuthenticatedRequest, res: Response, ctx: UploadRequestContext) {
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) || !ctx.organizationId) {
await deleteNewFile();
return res.status(400).json({ error: 'Неверный контекст загрузки файла' });
}
let field: Awaited<ReturnType<typeof storage.getFormFieldById>>;
try {
field = await storage.getFormFieldById(fieldId, ctx.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, ctx.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 ?? ctx.uploadedBy;
if (uploaderId && ctx.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: ctx.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 ?? ctx.botId ?? null,
}).onConflictDoNothing();
} catch (trackErr) {
console.warn('[Upload] Failed to track file upload:', trackErr);
}
}
}
return res.json({ url, name, size: req.file.size });
}
// Загрузка по upload-тикету (БЕЗ API-ключа/JWT): авторизация = валидный тикет.
// Тикет выдаёт MCP-инструмент create_upload_ticket — см. server/mcp.ts.
// ВАЖНО: роут зарегистрирован ДО '/api/upload', пути не пересекаются
// ('/api/upload' — exact match, '/api/upload/ticket/:token' — отдельная ветка).
router.post('/api/upload/ticket/:token', async (req: AuthenticatedRequest, res) => {
const ticket = consumeUploadTicket(String(req.params.token || ''));
if (!ticket) {
return res.status(401).json({ error: 'Тикет загрузки недействителен или истёк' });
}
if (ticket.scopes.mode === 'read') {
return res.status(403).json({ error: 'API-ключ в режиме read не может загружать файлы' });
}
// Основная логика: tenant-контекст (RLS) открывается так же,
// как в authenticateTokenOrApiKey (openTenantCtx + release по finish/close)
const proceed = async () => {
// Проверка доступа к форме задачи по скоупам ключа (как в POST /api/upload)
const taskIdForScope = req.query.taskId ? parseInt(String(req.query.taskId)) : NaN;
if (!isNaN(taskIdForScope)) {
const scopedTask = await storage.getTask(taskIdForScope, ticket.organizationId);
if (!scopedTask || !isFormAllowedByScopes(ticket.scopes, scopedTask.formId)) {
return res.status(403).json({ error: 'Доступ к форме этой задачи запрещён правами API-ключа' });
}
}
req.organizationId = ticket.organizationId;
return processUploadRequest(req, res, {
organizationId: ticket.organizationId,
uploadedBy: ticket.createdBy,
botId: ticket.botId,
});
};
if (_tenantCtx.getStore()) return proceed();
try {
const handle = await openTenantCtx(ticket.organizationId);
handle.run(() => {
const guard = setTimeout(() => {
console.warn(`[POOL] Force-releasing tenant connection after 30s timeout (${req.method} ${req.path})`);
handle.release();
}, 30_000);
res.once('finish', () => { clearTimeout(guard); handle.release(); });
res.once('close', () => { clearTimeout(guard); handle.release(); });
proceed().catch((err: unknown) => {
console.error('[Upload ticket] error:', err);
if (!res.headersSent) res.status(500).json({ error: 'Ошибка загрузки файла' });
});
});
} catch (err) {
console.error('[Upload ticket] tenant context error:', err);
return res.status(500).json({ error: 'Ошибка загрузки файла' });
}
});
// File upload endpoint (JWT пользователя или API-ключ организации)
router.post('/api/upload', authenticateTokenOrApiKey, async (req: AuthenticatedRequest, res) => {
return processUploadRequest(req, res, {
organizationId: req.organizationId!,
uploadedBy: req.apiKey?.createdBy,
botId: req.apiKey?.botId ?? null,
});
});
export default router;