MCP: загрузка файлов (base64) в file-поля, комментарии, справочники и таб-таблицы задач
- server/services/file-upload.service.ts: uploadFileFromBase64 (whitelist расширений, magic bytes, лимиты 10/50 МБ, S3/MinIO или локальный диск, запись file_uploads) - Инструменты (write/full): upload_file, upload_task_file, upload_message_file, upload_directory_file, upload_table_row_file - Проверки доступа isFormAllowed/isTableAllowed
This commit is contained in:
371
server/mcp.ts
371
server/mcp.ts
@@ -48,7 +48,8 @@ import { tasksMinimalCache, formsCache } from "./utils/cache";
|
|||||||
import { evaluateAutoTransitions } from "./utils/auto-transitions";
|
import { evaluateAutoTransitions } from "./utils/auto-transitions";
|
||||||
import { notifyTaskAssigned } from "./utils/notifyAssignee";
|
import { notifyTaskAssigned } from "./utils/notifyAssignee";
|
||||||
import { eventBus, publishNotificationSSE } from "./routes/shared";
|
import { eventBus, publishNotificationSSE } from "./routes/shared";
|
||||||
import { indexFormAsync } from "./routes/task-helpers";
|
import { indexFormAsync, indexTaskAsync } from "./routes/task-helpers";
|
||||||
|
import { uploadFileFromBase64, FileUploadError } from "./services/file-upload.service";
|
||||||
import { DocumentTemplateService } from "./documents/template.service";
|
import { DocumentTemplateService } from "./documents/template.service";
|
||||||
import { DocumentGenerationService } from "./documents/generation.service";
|
import { DocumentGenerationService } from "./documents/generation.service";
|
||||||
import { DataResolutionService } from "./documents/data-resolution.service";
|
import { DataResolutionService } from "./documents/data-resolution.service";
|
||||||
@@ -160,6 +161,11 @@ const WRITE_EXTRA_TOOLS: readonly string[] = [
|
|||||||
'set_task_reminder',
|
'set_task_reminder',
|
||||||
'send_notification',
|
'send_notification',
|
||||||
'mark_notifications_read',
|
'mark_notifications_read',
|
||||||
|
'upload_file',
|
||||||
|
'upload_task_file',
|
||||||
|
'upload_message_file',
|
||||||
|
'upload_directory_file',
|
||||||
|
'upload_table_row_file',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Все остальные инструменты (изменение/удаление форм, задач, пользователей,
|
// Все остальные инструменты (изменение/удаление форм, задач, пользователей,
|
||||||
@@ -4716,6 +4722,369 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Загрузка файлов (base64) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
// upload_file
|
||||||
|
register(
|
||||||
|
"upload_file",
|
||||||
|
{
|
||||||
|
title: "Upload File",
|
||||||
|
description:
|
||||||
|
"Upload a file (base64) to the organization storage without attaching it anywhere. " +
|
||||||
|
"Returns {key, url, name, size, mimeType} — the url can then be placed into fields, cells or attachments manually. " +
|
||||||
|
"Limits: images 10 MB, other types 50 MB; extension whitelist and magic-bytes checks apply (same as the web UI upload).",
|
||||||
|
inputSchema: {
|
||||||
|
fileName: z.string().min(1).describe("Original file name with extension (extension must be whitelisted: images, pdf, office docs, txt/csv, zip/rar)"),
|
||||||
|
contentBase64: z.string().min(1).describe("File content as base64 (data-URL prefix 'data:...;base64,' is allowed)"),
|
||||||
|
mimeType: z.string().optional().describe("MIME type (optional; normalized from the extension if missing or inconsistent)"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ fileName, contentBase64, mimeType }) => {
|
||||||
|
const orgUsers = await storage.getUsersByOrganization(organizationId);
|
||||||
|
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
|
||||||
|
if (!adminUser) return mcpError("В организации нет пользователей");
|
||||||
|
try {
|
||||||
|
const file = await uploadFileFromBase64({
|
||||||
|
organizationId,
|
||||||
|
userId: adminUser.id,
|
||||||
|
fileName,
|
||||||
|
contentBase64,
|
||||||
|
mimeType,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text" as const,
|
||||||
|
text: JSON.stringify({ success: true, file: { key: file.key, url: file.url, name: file.name, size: file.size, mimeType: file.mimeType } }, null, 2),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof FileUploadError) return mcpError(err.message);
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return mcpError(`Ошибка загрузки файла: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// upload_task_file
|
||||||
|
register(
|
||||||
|
"upload_task_file",
|
||||||
|
{
|
||||||
|
title: "Upload Task Field File",
|
||||||
|
description:
|
||||||
|
"Upload a file (base64) and APPEND it to a file-type form field of a task. " +
|
||||||
|
"File fields are multiple: the value is an array of {url, name, size}. " +
|
||||||
|
"Field maxFileCount/maxFileSizeMB limits are enforced. Returns the updated field value.",
|
||||||
|
inputSchema: {
|
||||||
|
taskId: z.number().int().describe("The numeric ID of the task"),
|
||||||
|
fieldId: z.number().int().describe("The numeric ID of the file-type form field"),
|
||||||
|
fileName: z.string().min(1).describe("Original file name with extension"),
|
||||||
|
contentBase64: z.string().min(1).describe("File content as base64 (data-URL prefix allowed)"),
|
||||||
|
mimeType: z.string().optional().describe("MIME type (optional)"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ taskId, fieldId, fileName, contentBase64, mimeType }) => {
|
||||||
|
const task = await storage.getTask(taskId, organizationId);
|
||||||
|
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||||||
|
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||||||
|
|
||||||
|
const formFields = await storage.getFormFields(task.formId, organizationId);
|
||||||
|
const field = formFields.find((f) => f.id === fieldId);
|
||||||
|
if (!field) return mcpError(`Поле ${fieldId} не найдено в форме ${task.formId}`);
|
||||||
|
if (field.type !== 'file') {
|
||||||
|
return mcpError(`Поле ${fieldId} имеет тип «${field.type}», а не file`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgUsers = await storage.getUsersByOrganization(organizationId);
|
||||||
|
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
|
||||||
|
if (!adminUser) return mcpError("В организации нет пользователей");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const file = await uploadFileFromBase64({
|
||||||
|
organizationId,
|
||||||
|
userId: adminUser.id,
|
||||||
|
fileName,
|
||||||
|
contentBase64,
|
||||||
|
mimeType,
|
||||||
|
taskId,
|
||||||
|
fieldId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// File-поля множественные: значение = массив {url, name, size} — добавляем файл
|
||||||
|
const existingValues = await storage.getTaskFieldValues(taskId, organizationId);
|
||||||
|
const existingValue = existingValues.find((v) => v.fieldId === fieldId);
|
||||||
|
const currentFiles = Array.isArray(existingValue?.value) ? existingValue.value as Array<Record<string, unknown>> : [];
|
||||||
|
const newFiles = [...currentFiles, { url: file.url, name: file.name, size: file.size }];
|
||||||
|
|
||||||
|
// Лимиты поля (как в PATCH /api/tasks/:id/field-values/:fieldId)
|
||||||
|
if (field.maxFileCount != null && newFiles.length > field.maxFileCount) {
|
||||||
|
return mcpError(`Превышено максимальное количество файлов (${field.maxFileCount})`);
|
||||||
|
}
|
||||||
|
if (field.maxFileSizeMB != null) {
|
||||||
|
const totalBytes = newFiles.reduce((sum, f) => sum + (Number(f.size) || 0), 0);
|
||||||
|
if (totalBytes > field.maxFileSizeMB * 1024 * 1024) {
|
||||||
|
return mcpError(`Суммарный размер файлов превышает лимит (${field.maxFileSizeMB} МБ)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingValue) {
|
||||||
|
await storage.updateTaskFieldValue(taskId, fieldId, organizationId, { value: newFiles });
|
||||||
|
} else {
|
||||||
|
await storage.createTaskFieldValue({ taskId, fieldId, formId: task.formId, value: newFiles });
|
||||||
|
}
|
||||||
|
|
||||||
|
const editorName = `${adminUser.firstName || ''} ${adminUser.middleName || ''} ${adminUser.lastName || ''}`.trim() || adminUser.email || 'MCP';
|
||||||
|
storage.addTaskAuditLog({
|
||||||
|
taskId,
|
||||||
|
organizationId,
|
||||||
|
action: 'field.changed',
|
||||||
|
fieldId: field.id,
|
||||||
|
fieldName: field.name,
|
||||||
|
oldValue: existingValue?.value ?? null,
|
||||||
|
newValue: newFiles,
|
||||||
|
changedBy: adminUser.id,
|
||||||
|
changedByName: editorName,
|
||||||
|
}).catch((e: unknown) => { console.error('Audit log error (MCP upload_task_file):', e); });
|
||||||
|
|
||||||
|
tasksMinimalCache.invalidatePrefix(`tasks:${organizationId}:minimal:`);
|
||||||
|
const autoResult = await evaluateAutoTransitions(taskId, organizationId, { triggeredBy: adminUser.id });
|
||||||
|
indexTaskAsync(taskId, organizationId).catch(() => {});
|
||||||
|
const freshTask = await storage.getTask(taskId, organizationId);
|
||||||
|
eventBus.publishEvent({
|
||||||
|
type: 'task_updated',
|
||||||
|
organizationId,
|
||||||
|
data: { taskId, formId: task.formId, task: freshTask, autoTransition: autoResult.changed },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text" as const,
|
||||||
|
text: JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
file: { key: file.key, url: file.url, name: file.name, size: file.size, mimeType: file.mimeType },
|
||||||
|
fieldValue: newFiles,
|
||||||
|
}, null, 2),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof FileUploadError) return mcpError(err.message);
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return mcpError(`Ошибка загрузки файла: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// upload_message_file
|
||||||
|
register(
|
||||||
|
"upload_message_file",
|
||||||
|
{
|
||||||
|
title: "Upload Message Attachment",
|
||||||
|
description:
|
||||||
|
"Upload a file (base64) and post it as a task comment attachment. " +
|
||||||
|
"If content is omitted, the message text is generated as '📎 <file name>'. " +
|
||||||
|
"Triggers the same side effects as send_task_message (notifications, SSE, webhooks).",
|
||||||
|
inputSchema: {
|
||||||
|
taskId: z.number().int().describe("The numeric ID of the task"),
|
||||||
|
fileName: z.string().min(1).describe("Original file name with extension"),
|
||||||
|
contentBase64: z.string().min(1).describe("File content as base64 (data-URL prefix allowed)"),
|
||||||
|
mimeType: z.string().optional().describe("MIME type (optional)"),
|
||||||
|
content: z.string().optional().describe("Comment text (optional; default '📎 <file name>')"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ taskId, fileName, contentBase64, mimeType, content }) => {
|
||||||
|
const task = await storage.getTask(taskId, organizationId);
|
||||||
|
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||||||
|
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||||||
|
|
||||||
|
const orgUsers = await storage.getUsersByOrganization(organizationId);
|
||||||
|
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
|
||||||
|
if (!adminUser) return mcpError("В организации нет пользователей");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const file = await uploadFileFromBase64({
|
||||||
|
organizationId,
|
||||||
|
userId: adminUser.id,
|
||||||
|
fileName,
|
||||||
|
contentBase64,
|
||||||
|
mimeType,
|
||||||
|
taskId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = await sendTaskMessage({
|
||||||
|
task,
|
||||||
|
user: adminUser,
|
||||||
|
organizationId,
|
||||||
|
message: content?.trim() || `📎 ${file.name}`,
|
||||||
|
attachments: [{ url: file.url, name: file.name, size: file.size, mimeType: file.mimeType }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text" as const,
|
||||||
|
text: JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
file: { key: file.key, url: file.url, name: file.name, size: file.size, mimeType: file.mimeType },
|
||||||
|
message: { id: created.id, taskId: created.taskId, message: created.message, createdAt: created.createdAt },
|
||||||
|
}, null, 2),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof FileUploadError) return mcpError(err.message);
|
||||||
|
if (err instanceof SendTaskMessageError) return mcpError(err.message);
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return mcpError(`Ошибка загрузки файла: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// upload_directory_file
|
||||||
|
register(
|
||||||
|
"upload_directory_file",
|
||||||
|
{
|
||||||
|
title: "Upload Directory Cell File",
|
||||||
|
description:
|
||||||
|
"Upload a file (base64) and write a link into a directory row cell. " +
|
||||||
|
"Directory columns have no file type, so the cell gets a markdown link: [file name](url).",
|
||||||
|
inputSchema: {
|
||||||
|
tableId: z.number().int().describe("The numeric ID of the directory (data table)"),
|
||||||
|
rowId: z.number().int().describe("The numeric ID of the row"),
|
||||||
|
columnIndex: z.number().int().min(0).describe("Column index (0-based)"),
|
||||||
|
fileName: z.string().min(1).describe("Original file name with extension"),
|
||||||
|
contentBase64: z.string().min(1).describe("File content as base64 (data-URL prefix allowed)"),
|
||||||
|
mimeType: z.string().optional().describe("MIME type (optional)"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ tableId, rowId, columnIndex, fileName, contentBase64, mimeType }) => {
|
||||||
|
if (!isTableAllowed(tableId)) return tableDenied(tableId);
|
||||||
|
const table = await storage.getDataTable(tableId, organizationId);
|
||||||
|
if (!table) return mcpError(`Справочник ${tableId} не найден`);
|
||||||
|
const columnCount = table.columns?.length ?? 0;
|
||||||
|
if (columnIndex < 0 || columnIndex >= columnCount) {
|
||||||
|
return mcpError(`Колонка ${columnIndex} вне диапазона (в справочнике ${columnCount} колонок)`);
|
||||||
|
}
|
||||||
|
const row = await storage.getDataTableRow(rowId, tableId, organizationId);
|
||||||
|
if (!row) return mcpError(`Строка ${rowId} не найдена в справочнике ${tableId}`);
|
||||||
|
|
||||||
|
const orgUsers = await storage.getUsersByOrganization(organizationId);
|
||||||
|
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
|
||||||
|
if (!adminUser) return mcpError("В организации нет пользователей");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const file = await uploadFileFromBase64({
|
||||||
|
organizationId,
|
||||||
|
userId: adminUser.id,
|
||||||
|
fileName,
|
||||||
|
contentBase64,
|
||||||
|
mimeType,
|
||||||
|
});
|
||||||
|
|
||||||
|
// У колонок справочника нет file-типа: в ячейку пишем markdown-ссылку [имя](url)
|
||||||
|
const values = Array.isArray(row.values) ? [...row.values] : [];
|
||||||
|
while (values.length < columnCount) values.push('');
|
||||||
|
values[columnIndex] = `[${file.name}](${file.url})`;
|
||||||
|
const updated = await storage.updateDataTableRow(rowId, tableId, organizationId, { values });
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text" as const,
|
||||||
|
text: JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
file: { key: file.key, url: file.url, name: file.name, size: file.size, mimeType: file.mimeType },
|
||||||
|
row: { id: updated.id, values: updated.values },
|
||||||
|
}, null, 2),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof FileUploadError) return mcpError(err.message);
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return mcpError(`Ошибка загрузки файла: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// upload_table_row_file
|
||||||
|
register(
|
||||||
|
"upload_table_row_file",
|
||||||
|
{
|
||||||
|
title: "Upload Table Tab Cell File",
|
||||||
|
description:
|
||||||
|
"Upload a file (base64) and write a link into a cell of a task's 'table' tab (regular_table_rows). " +
|
||||||
|
"The cell gets a markdown link: [file name](url). " +
|
||||||
|
"If rowId is omitted, a new row is created (like append_table_row). Use get_form_tabs to discover tab and column IDs.",
|
||||||
|
inputSchema: {
|
||||||
|
taskId: z.number().int().describe("The numeric ID of the task"),
|
||||||
|
tabId: z.number().int().describe("The numeric ID of the table tab"),
|
||||||
|
columnId: z.string().min(1).describe("Column ID (e.g. 'gf_12', 'ft_34')"),
|
||||||
|
rowId: z.number().int().optional().describe("Row ID to update. Omit to create a new row."),
|
||||||
|
fileName: z.string().min(1).describe("Original file name with extension"),
|
||||||
|
contentBase64: z.string().min(1).describe("File content as base64 (data-URL prefix allowed)"),
|
||||||
|
mimeType: z.string().optional().describe("MIME type (optional)"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ taskId, tabId, columnId, rowId, fileName, contentBase64, mimeType }) => {
|
||||||
|
const task = await storage.getTask(taskId, organizationId);
|
||||||
|
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||||||
|
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||||||
|
|
||||||
|
const tab = await storage.getFormTab(tabId, task.formId, organizationId);
|
||||||
|
if (!tab) return mcpError(`Таб ${tabId} не найден в форме ${task.formId}`);
|
||||||
|
if (tab.type !== 'table') return mcpError(`Таб ${tabId} имеет тип '${tab.type}', а не 'table'`);
|
||||||
|
type ColDef = { id: string; name: string; type?: string };
|
||||||
|
const columns: ColDef[] = Array.isArray(tab.tableColumns) ? (tab.tableColumns as ColDef[]) : [];
|
||||||
|
if (!columns.some((c) => c.id === columnId)) {
|
||||||
|
return mcpError(`Колонка "${columnId}" не найдена в табе ${tabId}. Доступные: ${columns.map((c) => c.id).join(', ') || '(нет)'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgUsers = await storage.getUsersByOrganization(organizationId);
|
||||||
|
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
|
||||||
|
if (!adminUser) return mcpError("В организации нет пользователей");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const file = await uploadFileFromBase64({
|
||||||
|
organizationId,
|
||||||
|
userId: adminUser.id,
|
||||||
|
fileName,
|
||||||
|
contentBase64,
|
||||||
|
mimeType,
|
||||||
|
taskId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Явного file-формата у ячеек таб-таблиц нет (рендерятся как текст):
|
||||||
|
// пишем markdown-ссылку [имя](url), как и в справочниках
|
||||||
|
const cellValue = `[${file.name}](${file.url})`;
|
||||||
|
|
||||||
|
let row;
|
||||||
|
if (rowId !== undefined) {
|
||||||
|
const existing = await storage.getRegularTableRow(rowId, taskId, tabId);
|
||||||
|
if (!existing) return mcpError(`Строка ${rowId} не найдена в табе ${tabId}`);
|
||||||
|
const data = { ...((existing.data ?? {}) as Record<string, unknown>), [columnId]: cellValue };
|
||||||
|
row = await storage.updateRegularTableRow(rowId, taskId, tabId, { data });
|
||||||
|
} else {
|
||||||
|
row = await storage.createRegularTableRow({
|
||||||
|
taskId,
|
||||||
|
tabId,
|
||||||
|
data: { [columnId]: cellValue },
|
||||||
|
createdBy: adminUser.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text" as const,
|
||||||
|
text: JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
file: { key: file.key, url: file.url, name: file.name, size: file.size, mimeType: file.mimeType },
|
||||||
|
row: { id: row.id, data: row.data },
|
||||||
|
}, null, 2),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof FileUploadError) return mcpError(err.message);
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return mcpError(`Ошибка загрузки файла: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return server;
|
return server;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
133
server/services/file-upload.service.ts
Normal file
133
server/services/file-upload.service.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
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; // опциональная привязка к полю формы
|
||||||
|
}
|
||||||
|
|
||||||
|
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 } = 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,
|
||||||
|
}).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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user