fix(number-fields): избегаем потери точности длинных чисел

- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
2026-07-07 21:03:40 +03:00
commit 1f5ecb6da4
1089 changed files with 237546 additions and 0 deletions

View File

@@ -0,0 +1,193 @@
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;