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
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
import { Router } from "express";
|
||||
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";
|
||||
@@ -12,202 +14,279 @@ 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();
|
||||
});
|
||||
|
||||
// Контекст загрузки, не зависящий от способа авторизации
|
||||
// (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 });
|
||||
});
|
||||
} 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: 'Файл не передан' });
|
||||
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 не может загружать файлы' });
|
||||
}
|
||||
|
||||
// Проверки для авторизации по API-ключу (JWT-путь не меняется)
|
||||
if (req.apiKey) {
|
||||
if (req.apiKey.scopes.mode === '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-ключ в режиме read не может загружать файлы' });
|
||||
return res.status(403).json({ error: 'Доступ к форме этой задачи запрещён правами API-ключа' });
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (hasCountLimit && existingCount >= field.maxFileCount!) {
|
||||
await deleteNewFile();
|
||||
return res.status(400).json({ error: 'Неверный контекст загрузки файла' });
|
||||
return res.status(400).json({
|
||||
error: `Превышено максимальное количество файлов (${field.maxFileCount})`,
|
||||
});
|
||||
}
|
||||
|
||||
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!) {
|
||||
if (hasSizeLimit) {
|
||||
const limitBytes = field.maxFileSizeMB! * 1024 * 1024;
|
||||
if (existingBytes + req.file.size > limitBytes) {
|
||||
await deleteNewFile();
|
||||
return res.status(400).json({
|
||||
error: `Превышено максимальное количество файлов (${field.maxFileCount})`,
|
||||
error: `Суммарный размер файлов превысит лимит (${field.maxFileSizeMB} МБ)`,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
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';
|
||||
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 {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Записываем файл в таблицу трекинга для последующей проверки доступа
|
||||
// Автор: пользователь (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 });
|
||||
}
|
||||
|
||||
// Загрузка по 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,
|
||||
});
|
||||
};
|
||||
|
||||
return res.json({ url, name, size: req.file.size });
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user