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

273
server/utils/upload.ts Normal file
View File

@@ -0,0 +1,273 @@
import path from 'path';
import fs from 'fs/promises';
import fssync from 'fs';
import multer from 'multer';
import crypto from 'crypto';
import { isS3Enabled, deleteFromS3 } from './s3';
// ── File upload security configuration ────────────────────────────────────────
// Uploads directory for local disk mode
export const uploadsDir = path.resolve(process.cwd(), 'uploads');
if (!fssync.existsSync(uploadsDir)) {
fssync.mkdirSync(uploadsDir, { recursive: true });
}
// Temp directory for S3 mode — files deleted after successful S3 upload
export const tmpUploadDir = path.resolve(process.cwd(), 'uploads/tmp');
if (!fssync.existsSync(tmpUploadDir)) {
fssync.mkdirSync(tmpUploadDir, { recursive: true });
}
// ── Extension-based file classification (trusted source of truth) ─────────────
// All policy decisions (magic bytes, size limits, MIME consistency) use the
// file EXTENSION, not file.mimetype, because MIME is client-controlled.
// Extension → acceptable MIME types (browser may send any of these for that ext)
// application/octet-stream is always accepted as a fallback from some browsers/OSes
export const EXT_TO_MIME: Record<string, readonly string[]> = {
jpg: ['image/jpeg'],
jpeg: ['image/jpeg'],
png: ['image/png'],
gif: ['image/gif'],
webp: ['image/webp'],
svg: ['image/svg+xml'],
pdf: ['application/pdf'],
doc: ['application/msword'],
docx: ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
xls: ['application/vnd.ms-excel'],
xlsx: ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
ppt: ['application/vnd.ms-powerpoint'],
pptx: ['application/vnd.openxmlformats-officedocument.presentationml.presentation'],
txt: ['text/plain'],
csv: ['text/csv', 'text/plain', 'application/csv'],
zip: ['application/zip', 'application/x-zip-compressed', 'application/x-zip'],
rar: ['application/x-rar-compressed', 'application/vnd.rar', 'application/x-rar'],
};
// Magic byte signatures keyed by EXTENSION (not MIME)
// A file renamed from .exe → .jpg will fail this check
export const EXT_MAGIC: Record<string, Buffer> = {
jpg: Buffer.from([0xFF, 0xD8, 0xFF]),
jpeg: Buffer.from([0xFF, 0xD8, 0xFF]),
png: Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
pdf: Buffer.from([0x25, 0x50, 0x44, 0x46]), // %PDF
};
// Image extensions → 10 MB limit; all others → 50 MB
export const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg']);
export const IMAGE_MAX_SIZE = 10 * 1024 * 1024; // 10 МБ
export const DOC_MAX_SIZE = 50 * 1024 * 1024; // 50 МБ
// Derives the lowercase extension from the original filename (trusted)
export function getFileExt(originalname: string): string {
return path.extname(path.basename(originalname)).slice(1).toLowerCase();
}
// Validates original filename:
// - extension must be in EXT_TO_MIME (whitelist)
// - name part blocks path traversal, shell metacharacters and control characters
// - Unicode letters (including Cyrillic), digits, spaces, dots, underscores and hyphens are allowed
export function validateFilename(originalname: string): { valid: boolean; reason?: string } {
const basename = path.basename(originalname);
const ext = path.extname(basename).slice(1).toLowerCase();
if (!EXT_TO_MIME[ext]) {
return { valid: false, reason: `Недопустимое расширение файла: .${ext}` };
}
const namePart = path.basename(basename, path.extname(basename));
if (!namePart) {
return { valid: false, reason: 'Пустое имя файла' };
}
// Block path traversal, shell metacharacters, and control characters
// Allow Unicode letters (including Cyrillic), digits, spaces, dots, underscores, hyphens
if (/[\/\\&|;$()<>\`\"'\x00-\x1f]/.test(namePart) || namePart.includes('..')) {
return { valid: false, reason: 'Имя файла содержит недопустимые символы' };
}
return { valid: true };
}
// Checks declared MIME against the expected list for the given extension.
// Returns false if MIME is clearly wrong for the extension (e.g. .jpg + application/msword).
// application/octet-stream is always accepted as a browser fallback.
export function isMimeConsistentWithExt(ext: string, mime: string): boolean {
if (mime === 'application/octet-stream') return true; // browser fallback — always ok
const allowed = EXT_TO_MIME[ext];
if (!allowed) return false;
return allowed.includes(mime);
}
// Reads magic bytes from file on disk and compares against known signature for the extension.
// Returns true if no signature is defined for this extension (no check needed).
export async function checkMagicBytes(filePath: string, ext: string): Promise<boolean> {
const signature = EXT_MAGIC[ext];
if (!signature) return true;
const fd = await fs.open(filePath, 'r');
try {
const buf = Buffer.alloc(signature.length);
await fd.read(buf, 0, signature.length, 0);
return buf.equals(signature);
} finally {
await fd.close();
}
}
// Returns the size limit in bytes based on extension (trusted), not MIME (client-controlled)
export function getSizeLimit(ext: string): number {
return IMAGE_EXTENSIONS.has(ext) ? IMAGE_MAX_SIZE : DOC_MAX_SIZE;
}
// Always use disk storage (tmpUploadDir for S3 mode so we can stream to S3, then delete)
const multerStorage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, isS3Enabled ? tmpUploadDir : uploadsDir),
filename: (_req, file, cb) => {
const ext = getFileExt(file.originalname);
const uniqueName = `${Date.now()}-${crypto.randomBytes(6).toString('hex')}.${ext}`;
cb(null, uniqueName);
},
});
export const upload = multer({
storage: multerStorage,
limits: { fileSize: DOC_MAX_SIZE }, // hard cap 50 МБ; per-type check done in handler
fileFilter: (_req, file, cb) => {
// 1. Validate filename: strict regex + extension whitelist (extension is trusted)
const { valid, reason } = validateFilename(file.originalname);
if (!valid) {
return cb(new Error(reason || 'Недопустимое имя файла'));
}
// 2. Reject if declared MIME is clearly incompatible with the extension
// (e.g. .jpg uploaded with Content-Type: application/msword is suspicious)
const ext = getFileExt(file.originalname);
if (!isMimeConsistentWithExt(ext, file.mimetype)) {
return cb(new Error(`Тип файла (${file.mimetype}) не соответствует расширению .${ext}`));
}
cb(null, true);
},
});
// ──────────────────────────────────────────────────────────────────────────────
// Удаляет физический файл из uploads/ по сохранённому значению поля (объект или JSON-строка)
// Извлекает все URL из значения файлового поля (одиночный объект, массив или JSON-строка)
export function extractFileUrls(fileValue: unknown): string[] {
if (!fileValue) return [];
// Массив объектов [{url, name, size}]
if (Array.isArray(fileValue)) {
return fileValue
.filter((f): f is Record<string, unknown> => f && typeof f === 'object')
.map(f => (typeof f.url === 'string' ? f.url : null))
.filter((u): u is string => !!u);
}
// Одиночный объект {url, name, size}
if (typeof fileValue === 'object') {
const obj = fileValue as Record<string, unknown>;
return typeof obj.url === 'string' ? [obj.url] : [];
}
// JSON-строка
if (typeof fileValue === 'string' && fileValue) {
try {
return extractFileUrls(JSON.parse(fileValue));
} catch {
return fileValue ? [fileValue] : [];
}
}
return [];
}
export async function deleteFileByUrl(url: string): Promise<void> {
if (isS3Enabled) {
// S3-режим: извлекаем ключ из URL вида /api/files/:key
const key = url.startsWith('/api/files/') ? url.slice('/api/files/'.length) : path.basename(url);
if (!key || key.includes('..') || key.includes('/')) return;
await deleteFromS3(key);
} else {
// Локальный режим: удаляем с диска
const filename = path.basename(url);
if (!filename || filename.includes('..') || !filename.includes('-')) return;
try {
await fs.unlink(path.join(uploadsDir, filename));
} catch (e: unknown) {
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('Ошибка удаления файла с диска:', e);
}
}
}
}
// Удаляет все файлы из значения поля (используется при полном удалении поля)
export async function deleteUploadedFile(fileValue: unknown): Promise<void> {
const urls = extractFileUrls(fileValue);
for (const url of urls) {
await deleteFileByUrl(url);
}
}
// Удаляет только файлы, которые были в oldValue но исчезли в newValue
export async function deleteRemovedFiles(oldValue: unknown, newValue: unknown): Promise<void> {
const oldUrls = new Set(extractFileUrls(oldValue));
const newUrls = new Set(extractFileUrls(newValue));
for (const url of oldUrls) {
if (!newUrls.has(url)) {
await deleteFileByUrl(url);
}
}
}
// ── Pending-upload registry for new-task flows ────────────────────────────────
// Tracks uploads that have been accepted but not yet committed to a saved task.
// Key: `${userId}_${fieldId}`; entries expire after 60 minutes.
// This allows the server to enforce per-field limits without trusting the client.
export interface PendingUploadEntry {
url: string;
size: number;
expiresAt: number;
}
export const pendingUploads = new Map<string, PendingUploadEntry[]>();
export const PENDING_UPLOAD_TTL_MS = 60 * 60 * 1000; // 1 hour
export function getPendingKey(userId: number, fieldId: number): string {
return `${userId}_${fieldId}`;
}
export function getActivePendingUploads(key: string): PendingUploadEntry[] {
const now = Date.now();
const entries = pendingUploads.get(key) || [];
const expired = entries.filter(e => e.expiresAt <= now);
const active = entries.filter(e => e.expiresAt > now);
if (active.length !== entries.length) {
pendingUploads.set(key, active);
// Удаляем осиротевшие файлы (disk или S3) асинхронно
expired.forEach(e => deleteFileByUrl(e.url).catch(() => {/* ignore */}));
}
return active;
}
export function addPendingUpload(key: string, url: string, size: number): void {
const active = getActivePendingUploads(key);
active.push({ url, size, expiresAt: Date.now() + PENDING_UPLOAD_TTL_MS });
pendingUploads.set(key, active);
}
export function commitPendingUploads(userId: number, fieldId: number, committedUrls: string[]): void {
const key = getPendingKey(userId, fieldId);
const active = getActivePendingUploads(key);
const urlSet = new Set(committedUrls);
pendingUploads.set(key, active.filter(e => !urlSet.has(e.url)));
}
export function sweepPendingUploads(): void {
const now = Date.now();
for (const [key, entries] of pendingUploads.entries()) {
const expired = entries.filter(e => e.expiresAt <= now);
const active = entries.filter(e => e.expiresAt > now);
if (expired.length > 0) {
if (active.length === 0) {
pendingUploads.delete(key);
} else {
pendingUploads.set(key, active);
}
expired.forEach(e => deleteFileByUrl(e.url).catch(() => {/* ignore */}));
}
}
}
// Run orphan sweep every hour
setInterval(sweepPendingUploads, 60 * 60 * 1000);
// ──────────────────────────────────────────────────────────────────────────────