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:;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 { 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 }; }