Files
iistwin/server/routes/files.routes.ts
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

194 lines
8.6 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 } from "express";
import fs from 'fs/promises';
import multer from 'multer';
import { storage } from "../storage";
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { isS3Enabled, uploadToS3FromFile } from "../utils/s3";
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();
// File upload endpoint
router.post('/api/upload', authenticateToken, async (req: AuthenticatedRequest, res) => {
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: 'Файл не передан' });
}
// 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) {
await deleteNewFile();
return res.status(400).json({ error: 'Неверный контекст загрузки файла' });
}
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!) {
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);
}
}
// Записываем файл в таблицу трекинга для последующей проверки доступа
if (req.user?.id && 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: req.user.id,
fileKey,
originalName: name,
sizeBytes: req.file.size,
taskId: taskIdNum && !isNaN(taskIdNum) ? taskIdNum : null,
fieldId: fieldIdNum && !isNaN(fieldIdNum) ? fieldIdNum : null,
}).onConflictDoNothing();
} catch (trackErr) {
console.warn('[Upload] Failed to track file upload:', trackErr);
}
}
}
return res.json({ url, name, size: req.file.size });
});
export default router;