Files
iistwin/server/services/file-upload.service.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

136 lines
5.8 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 fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
import { db } from '../db';
import { fileUploads } from '@shared/schema';
import { isS3Enabled, uploadToS3 } from '../utils/s3';
import {
uploadsDir,
getFileExt,
validateFilename,
getSizeLimit,
isMimeConsistentWithExt,
EXT_TO_MIME,
EXT_MAGIC,
DOC_MAX_SIZE,
} from '../utils/upload';
// Ошибка загрузки файла — маппится в понятное сообщение пользователю (на русском).
export class FileUploadError extends Error {
constructor(message: string) {
super(message);
this.name = 'FileUploadError';
}
}
export interface UploadFileFromBase64Params {
organizationId: number;
userId: number; // автор загрузки (file_uploads.uploadedBy)
fileName: string;
contentBase64: string; // допускается data-URL префикс "data:<mime>;base64,"
mimeType?: string;
taskId?: number | null; // опциональная привязка к задаче
fieldId?: number | null; // опциональная привязка к полю формы
botId?: number | null; // атрибуция загрузки ботом (file_uploads.bot_id)
}
export interface UploadedFileInfo {
key: string;
url: string;
name: string;
size: number;
mimeType: string;
fileUploadId: number | null;
}
// Максимальная длина base64-строки: 50 МБ бинарных данных * 4/3 + запас на префикс.
const MAX_BASE64_LENGTH = Math.ceil(DOC_MAX_SIZE * 4 / 3) + 1024;
// Загружает файл, переданный в base64, в хранилище (S3/MinIO или локальный диск)
// в том же режиме и с теми же проверками, что POST /api/upload:
// whitelist расширений, magic bytes, раздельные лимиты (10 МБ изображения / 50 МБ прочее).
// Создаёт запись трекинга в file_uploads.
export async function uploadFileFromBase64(params: UploadFileFromBase64Params): Promise<UploadedFileInfo> {
const { organizationId, userId, taskId = null, fieldId = null, botId = null } = params;
// 1. Имя файла: whitelist расширений + запрет недопустимых символов (как в multer fileFilter)
const name = path.basename(params.fileName || '');
const nameCheck = validateFilename(name);
if (!nameCheck.valid) {
throw new FileUploadError(nameCheck.reason || 'Недопустимое имя файла');
}
// 2. Base64: снимаем data-URL префикс, проверяем размер пейлоада до декодирования
let base64 = params.contentBase64 || '';
const commaIdx = base64.indexOf(',');
if (base64.startsWith('data:') && commaIdx !== -1) {
base64 = base64.slice(commaIdx + 1);
}
if (base64.length > MAX_BASE64_LENGTH) {
throw new FileUploadError(`Файл слишком большой (максимум ${DOC_MAX_SIZE / 1024 / 1024} МБ)`);
}
const buffer = Buffer.from(base64, 'base64');
if (buffer.length === 0) {
throw new FileUploadError('Пустое содержимое файла');
}
// 3. Раздельный лимит по расширению (доверенный источник — расширение, не MIME)
const ext = getFileExt(name);
const typeLimit = getSizeLimit(ext);
if (buffer.length > typeLimit) {
throw new FileUploadError(`Файл превышает лимит ${typeLimit / (1024 * 1024)} МБ для данного типа`);
}
// 4. Magic bytes по расширению (для типов с известной сигнатурой)
const signature = EXT_MAGIC[ext];
if (signature && !buffer.subarray(0, signature.length).equals(signature)) {
throw new FileUploadError('Содержимое файла не соответствует расширению — загрузка отклонена');
}
// 5. MIME: принимаем переданный, если согласован с расширением, иначе нормализуем по расширению
const normalizedMime = EXT_TO_MIME[ext]?.[0] ?? 'application/octet-stream';
const mimeType = params.mimeType && isMimeConsistentWithExt(ext, params.mimeType)
? params.mimeType
: normalizedMime;
// 6. Загрузка в хранилище в том же режиме, что POST /api/upload
let url: string;
let key: string;
if (isS3Enabled) {
try {
const result = await uploadToS3(buffer, name, mimeType);
url = result.url;
key = result.key;
} catch (s3Err) {
console.error('S3 upload error (base64):', s3Err);
throw new FileUploadError('Ошибка загрузки файла в хранилище');
}
} else {
// Локальный режим: то же имя файла, что генерирует multer (timestamp-randomhex.ext)
const uniqueName = `${Date.now()}-${crypto.randomBytes(6).toString('hex')}.${ext}`;
await fs.writeFile(path.join(uploadsDir, uniqueName), buffer);
url = `/uploads/${uniqueName}`;
key = uniqueName;
}
// 7. Запись трекинга (best-effort, как в POST /api/upload)
let fileUploadId: number | null = null;
try {
const [row] = await db.insert(fileUploads).values({
organizationId,
uploadedBy: userId,
fileKey: key,
originalName: name,
sizeBytes: buffer.length,
taskId,
fieldId,
botId,
}).returning({ id: fileUploads.id });
fileUploadId = row?.id ?? null;
} catch (trackErr) {
console.warn('[Upload] Failed to track base64 file upload:', trackErr);
}
return { key, url, name, size: buffer.length, mimeType, fileUploadId };
}