import rateLimit, { ipKeyGenerator } from 'express-rate-limit'; import type { Request } from 'express'; import { storage } from '../storage'; import { logAudit } from '../utils/audit'; import { db } from '../db'; import { formatUserName } from '../utils/formatUserName'; import { tasks, forms, roles } from '@shared/schema'; import type { FormStatus, Task } from '@shared/schema'; import { eq, and, inArray } from 'drizzle-orm'; import { SseConnectionIndex } from '../utils/sse-connection-index'; import { logger } from '../utils/logger'; const sseLog = logger('sse'); // ── Rate-limit helpers ─────────────────────────────────────────────────────── // In offices with a single public IP and many users, IP-only rate limiting // puts every user into the same bucket. We extract the caller identity from // the JWT (Authorization header or access_token cookie) so each user/bot has // their own bucket while still keeping the IP as a fallback namespace. function extractJwtIdentifier(token: string): string | null { try { const parts = token.split('.'); if (parts.length !== 3) return null; const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); if (typeof payload.userId === 'number') return `u:${payload.userId}`; if (typeof payload.botId === 'number') return `b:${payload.botId}`; if (typeof payload.superAdminId === 'number') return `sa:${payload.superAdminId}`; return null; } catch { return null; } } function getBearerToken(req: Request): string | null { const auth = req.headers['authorization']; if (auth && typeof auth === 'string' && auth.startsWith('Bearer ')) { return auth.slice(7); } return null; } function getCookieToken(req: Request): string | null { const cookieHeader = req.headers['cookie']; if (!cookieHeader || typeof cookieHeader !== 'string') return null; const match = cookieHeader.match(/\baccess_token=([^;]+)/); return match ? decodeURIComponent(match[1]) : null; } function buildRateLimitKey(req: Request): string { const ip = ipKeyGenerator(req.ip ?? 'unknown'); const token = getBearerToken(req) || getCookieToken(req); if (token) { const id = extractJwtIdentifier(token); if (id) return `${ip}:${id}`; // API keys and other opaque tokens: include the first 32 chars so // different keys do not share a bucket. return `${ip}:${token.slice(0, 32)}`; } return ip; } // Event Bus для SSE соединений export interface SSEConnection { id: string; userId: number; organizationId: number; res: any; // Express Response object } export interface Event { type: string; data: any; organizationId?: number; userId?: number; taskId?: number; } export interface BufferedEvent extends Event { id: string; timestamp: number; } export class EventBus { // Индекс соединений по организации/пользователю — publishEvent не перебирает все соединения private index = new SseConnectionIndex(); private heartbeatInterval = 25000; // 25 seconds — должен быть заметно меньше client watchdog (45s) private heartbeatTimer: NodeJS.Timeout | null = null; private eventIdCounter = 0; // Буферы последних событий для восстановления после reconnect private userBuffers = new Map(); private orgBuffers = new Map(); private readonly BUFFER_SIZE = 500; addConnection(connection: SSEConnection) { this.index.add(connection); this.ensureHeartbeatTimer(); } removeConnection(connectionId: string) { this.index.remove(connectionId); // Когда соединений нет, останавливаем heartbeat-таймер, чтобы не будить event loop впустую if (this.index.size === 0 && this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; } } // Один глобальный heartbeat-таймер на все соединения (вместо таймера на каждое соединение) private ensureHeartbeatTimer() { if (this.heartbeatTimer) return; this.heartbeatTimer = setInterval(() => { this.index.forEach((connection) => { try { // Именованное событие ping — в отличие от SSE-комментария (": heartbeat"), // оно доставляется клиенту как обычное событие и позволяет ему отличить // живое соединение от мёртвого (watchdog в sseManager). connection.res.write(`event: ping\ndata: {}\n\n`); const resAny = connection.res as any; if (typeof resAny.flush === 'function') { resAny.flush(); } } catch (error) { this.removeConnection(connection.id); } }); }, this.heartbeatInterval); // unref, чтобы таймер не удерживал процесс (и не мешал тестам завершаться) this.heartbeatTimer.unref?.(); } private generateEventId(): string { return `${Date.now()}-${++this.eventIdCounter}`; } private pushToBuffer(map: Map, key: number, event: BufferedEvent) { let buf = map.get(key); if (!buf) { buf = []; map.set(key, buf); } buf.push(event); if (buf.length > this.BUFFER_SIZE) { buf.shift(); } } private writeEventToConnection(connection: SSEConnection, event: BufferedEvent): boolean { try { const eventData = JSON.stringify(event.data); connection.res.write(`id: ${event.id}\n`); connection.res.write(`event: ${event.type}\n`); connection.res.write(`data: ${eventData}\n\n`); // Force immediate delivery for SSE streams (Express/Node may buffer writes) const resAny = connection.res as any; if (typeof resAny.flush === 'function') { resAny.flush(); } return true; } catch (error) { sseLog.error(`Failed to send event to connection ${connection.id}:`, error); this.removeConnection(connection.id); return false; } } publishEvent(event: Event) { // Also trigger an offline sync via silent web push when a task changes. // This wakes up closed PWAs so they can pull the latest data via delta sync. if (event.type === 'task_updated' && event.organizationId) { import('../utils/pushSyncNeeded') .then(({ pushSyncNeeded }) => pushSyncNeeded(event.organizationId!).catch(() => {})) .catch(() => {}); } const bufferedEvent: BufferedEvent = { ...event, id: this.generateEventId(), timestamp: Date.now(), }; // Буферизуем событие для восстановления после reconnect if (event.userId) { this.pushToBuffer(this.userBuffers, event.userId, bufferedEvent); } else if (event.organizationId) { this.pushToBuffer(this.orgBuffers, event.organizationId, bufferedEvent); } // Рассылка только соединениям нужной организации/пользователя (tenant isolation в индексе) for (const connection of this.index.getForEventTarget(event.organizationId, event.userId)) { this.writeEventToConnection(connection, bufferedEvent); } } /** * Восстанавливает события, пропущенные после reconnect. * Отправляет пользователю его персональные события + organization-wide события. */ replayBufferedEvents(connection: SSEConnection, lastEventId?: string) { const userBuf = this.userBuffers.get(connection.userId) ?? []; const orgBuf = this.orgBuffers.get(connection.organizationId) ?? []; // Объединяем без дубликатов через Map и сортируем по id (monotonic строка вида timestamp-counter) const seen = new Map(); for (const e of userBuf) seen.set(e.id, e); for (const e of orgBuf) seen.set(e.id, e); const combined = Array.from(seen.values()).sort((a, b) => a.id.localeCompare(b.id)); let startIdx = 0; if (lastEventId) { const idx = combined.findIndex(e => e.id === lastEventId); if (idx !== -1) { startIdx = idx + 1; } // Если lastEventId не найден в буфере, значит он слишком старый — // отправляем всё, что есть (fallback, клиент сам разберёт дубликаты). } const eventsToReplay = combined.slice(startIdx); if (eventsToReplay.length === 0) return; for (const event of eventsToReplay) { // Tenant isolation if (event.organizationId && event.organizationId !== connection.organizationId) continue; // User-specific events only for target user if (event.userId && event.userId !== connection.userId) continue; this.writeEventToConnection(connection, event); } } getActiveConnections() { return this.index.size; } isUserConnected(userId: number, organizationId: number): boolean { return this.index.isUserConnected(userId, organizationId); } } export const eventBus = new EventBus(); /** * Публикует SSE-событие «notification» для конкретного пользователя. * Используется воркерами (напоминания и т.д.) для real-time обновления бейджа. */ export function publishNotificationSSE(userId: number, organizationId: number, data: Record) { eventBus.publishEvent({ type: 'notification', data, organizationId, userId, }); } // Rate limiting configurations // Лимит для входа и регистрации — 20 попыток за 15 минут на IP export const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20, message: { error: 'Слишком много попыток входа. Попробуйте через 15 минут.' }, standardHeaders: true, legacyHeaders: false, handler: (req, res) => { logAudit({ action: 'rate_limit.auth', details: { path: req.path, method: req.method }, ip: req.ip ?? null, userAgent: req.headers['user-agent'] ?? null, }); res.status(429).json({ error: 'Слишком много попыток входа. Попробуйте через 15 минут.' }); }, }); // Отдельный лимит для обновления токенов — приложение вызывает его автоматически, // поэтому лимит значительно выше чтобы не мешать нормальной работе. // Ключ per-user из JWT (как у apiLimiter): офис за NAT не делит общий бакет. export const refreshLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 120, message: { error: 'Слишком много запросов обновления токена. Попробуйте позже.' }, standardHeaders: true, legacyHeaders: false, keyGenerator: buildRateLimitKey, }); // Общий API лимит — 1500 запросов за 15 минут // Auth, SSE и health check пропускаются — у них свои лимитеры // Для аутентифицированных запросов ключ включает идентификатор пользователя // из JWT (Authorization или cookie), чтобы пользователи за одним NAT не делили // между собой один лимит. export const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 1500, message: { error: 'Превышен лимит запросов к API. Попробуйте через несколько минут.' }, standardHeaders: true, legacyHeaders: false, keyGenerator: buildRateLimitKey, skip: (req) => { if (req.method === 'HEAD') return true; // req.path внутри app.use('/api', ...) — без префикса /api const p = req.path; return ( p === '/health' || p.startsWith('/auth/') || p.startsWith('/bot/auth/') || p === '/events' ); }, handler: (req, res) => { logAudit({ action: 'rate_limit.api', details: { path: req.path, method: req.method }, ip: req.ip ?? null, userAgent: req.headers['user-agent'] ?? null, }); res.status(429).json({ error: 'Превышен лимит запросов к API. Попробуйте через несколько минут.' }); }, }); // SSE соединения — не более 600 подключений за час на пользователя/IP. // Как и для API, разделяем бакеты по JWT-идентификатору, чтобы десяток // человек за одним офисным NAT не исчерпал общий лимит. export const sseLimiter = rateLimit({ windowMs: 60 * 60 * 1000, max: 600, message: { error: 'Превышен лимит SSE-подключений. Попробуйте через час.' }, standardHeaders: true, legacyHeaders: false, keyGenerator: buildRateLimitKey, handler: (req, res) => { logAudit({ action: 'rate_limit.sse', details: { path: req.path }, ip: req.ip ?? null, userAgent: req.headers['user-agent'] ?? null, }); res.status(429).json({ error: 'Превышен лимит SSE-подключений. Попробуйте через час.' }); }, }); export interface SystemFieldContext { statuses?: FormStatus[]; usersMap?: Map; } function safeParseOptions(opts: unknown): unknown { if (Array.isArray(opts)) return opts; if (typeof opts === 'string') { try { return JSON.parse(opts); } catch { return undefined; } } if (typeof opts === 'object' && opts !== null) return opts; return undefined; } export interface FieldTitleContext { usersMap?: Map; rolesMap?: Map; taskTitleMap?: Map; /** tableId -> rowId -> displayValue */ tableRowMap?: Map>; /** fieldId -> options array (for select/radio-group resolution) */ fieldOptionsMap?: Map>; } export function formatFieldValueForTitle(val: any, fieldType?: string, ctx?: FieldTitleContext, fieldOptions?: unknown): string { if (val === null || val === undefined) return ''; if (val === '__auto_prolongation__') return val; if (process.env.NODE_ENV !== 'production') { console.log('[formatFieldValueForTitle] fieldType:', fieldType, 'val:', JSON.stringify(val), 'fieldOptions:', JSON.stringify(fieldOptions)); } // For task fields, try to resolve title from map before treating as primitive if (fieldType === 'task' && ctx?.taskTitleMap) { const id = typeof val === 'string' ? parseInt(val, 10) : Number(val); if (!isNaN(id) && ctx.taskTitleMap.has(id)) { return ctx.taskTitleMap.get(id)!; } } const rawStr = String(val); switch (fieldType) { case 'user': { // Мультивыбор (allow_multiple): массив ["user:5", "role:2"] — имена через запятую let parsedUserVal: any = val; if (typeof val === 'string' && val.startsWith('[')) { try { parsedUserVal = JSON.parse(val); } catch { parsedUserVal = val; } } if (Array.isArray(parsedUserVal)) { return parsedUserVal .map((entry: unknown) => formatFieldValueForTitle(String(entry), 'user', ctx)) .filter(Boolean) .join(', '); } if (rawStr.startsWith('user:')) { const uid = Number(rawStr.replace('user:', '')); const user = ctx?.usersMap?.get(uid) ?? (ctx?.usersMap as any)?.get(String(uid)); if (user) { return formatUserName(user); } return rawStr; } if (rawStr.startsWith('role:')) { const rid = Number(rawStr.replace('role:', '')); const role = ctx?.rolesMap?.get(rid) ?? (ctx?.rolesMap as any)?.get(String(rid)); if (role) { return `Роль: ${role.name || rawStr}`; } return rawStr; } const uid = Number(rawStr); if (!isNaN(uid)) { const user = ctx?.usersMap?.get(uid) ?? (ctx?.usersMap as any)?.get(String(uid)); if (user) { return formatUserName(user); } } return rawStr; } case 'role': { if (rawStr.startsWith('role:')) { const rid = Number(rawStr.replace('role:', '')); const role = ctx?.rolesMap?.get(rid) ?? (ctx?.rolesMap as any)?.get(String(rid)); if (role) { return role.name || rawStr; } return rawStr; } const rid = Number(rawStr); if (!isNaN(rid)) { const role = ctx?.rolesMap?.get(rid) ?? (ctx?.rolesMap as any)?.get(String(rid)); if (role) { return role.name || rawStr; } } return rawStr; } case 'table': { if (process.env.NODE_ENV !== 'production') { console.log('[formatFieldValueForTitle] table case — ctx?.tableRowMap size:', ctx?.tableRowMap?.size, 'fieldOptions:', JSON.stringify(fieldOptions)); } // Drizzle may return jsonb values as JSON strings in some query paths let parsedVal: any = val; if (typeof val === 'string') { try { parsedVal = JSON.parse(val); } catch { parsedVal = val; } } // Handle array (multi-select) if (Array.isArray(parsedVal)) { return parsedVal .map((v) => formatFieldValueForTitle(v, fieldType, ctx, fieldOptions)) .filter(Boolean) .join(', '); } // Try to resolve via tableRowMap first if (ctx?.tableRowMap && fieldOptions) { const parsedOpts = safeParseOptions(fieldOptions); const tableOpts = (typeof parsedOpts === 'object' && parsedOpts !== null && !Array.isArray(parsedOpts)) ? parsedOpts as { tableId?: number } : undefined; const tableId = tableOpts?.tableId; let rowId: number | undefined; if (typeof parsedVal === 'object' && parsedVal !== null && typeof parsedVal.rowId === 'number') { rowId = parsedVal.rowId; } else { const num = Number(rawStr); if (!isNaN(num)) rowId = num; } if (process.env.NODE_ENV !== 'production') { console.log('[formatFieldValueForTitle] table resolved — tableId:', tableId, 'rowId:', rowId, 'hasTable:', tableId !== undefined ? ctx.tableRowMap.has(tableId) : false); } if (tableId !== undefined && rowId !== undefined && ctx.tableRowMap.has(tableId)) { const displayValue = ctx.tableRowMap.get(tableId)!.get(rowId); if (process.env.NODE_ENV !== 'production') { console.log('[formatFieldValueForTitle] table displayValue:', displayValue); } if (displayValue !== undefined) return displayValue; } } if (typeof parsedVal === 'object' && parsedVal !== null && typeof parsedVal.rowId === 'number') { return `#${parsedVal.rowId}`; } return rawStr; } case 'select': case 'radio-group': { const parsedOpts = safeParseOptions(fieldOptions); if (process.env.NODE_ENV !== 'production') { console.log('[formatFieldValueForTitle] select/radio case — parsedOpts isArray:', Array.isArray(parsedOpts), 'val:', JSON.stringify(val)); } if (Array.isArray(parsedOpts) && (typeof val === 'string' || typeof val === 'number')) { const strVal = String(val); const opt = (parsedOpts as Array<{ label?: string; value?: string; id?: string }>) .find(o => String(o.value ?? o.id) === strVal); if (process.env.NODE_ENV !== 'production') { console.log('[formatFieldValueForTitle] select found opt:', opt); } if (opt?.label) return opt.label; } return rawStr; } case 'toggle': case 'checkbox': { if (typeof val === 'boolean') return val ? 'Да' : 'Нет'; if (rawStr === 'true') return 'Да'; if (rawStr === 'false') return 'Нет'; return rawStr; } case 'checklist': { if (Array.isArray(val)) { const checkedCount = val.filter((it: any) => it?.checked).length; return `Выполнено ${checkedCount}`; } return rawStr; } case 'geo': { if (typeof val === 'object' && val !== null) { return val.address || val.name || val.value || rawStr; } return rawStr; } default: break; } // Primitives (fallback for non-object types) if (typeof val !== 'object') return rawStr; // Arrays (multi-select, file, etc.) if (Array.isArray(val)) { return val .map((v) => formatFieldValueForTitle(v, fieldType, ctx)) .filter(Boolean) .join(', '); } // Objects by field type switch (fieldType) { case 'contract-number': return val.value || val.number || ''; case 'contact': { const parts: string[] = []; if (val.name) parts.push(val.name); if (val.position) parts.push(`(${val.position})`); if (val.phones?.length) parts.push(val.phones.join(', ')); if (val.emails?.length) parts.push(val.emails.join(', ')); return parts.join(' '); } case 'company': return val.value || val.name || val.shortName || val.fullName || ''; case 'task': return val.taskId || val.id || ''; case 'select': return val.label || val.value || ''; case 'file': return val.name || val.url || ''; default: return val.value || val.label || val.name || val.title || val.text || JSON.stringify(val); } } export async function resolveTaskFieldTitles( valueByFieldId: Map, fields: Array<{ id: number; type: string }>, organizationId: number ): Promise> { const taskIds: number[] = []; for (const field of fields) { if (field.type === 'task') { const val = valueByFieldId.get(field.id); if (val == null || val === '') continue; let id: number | undefined; if (typeof val === 'number') id = val; else if (typeof val === 'string') { const n = parseInt(val, 10); if (!isNaN(n)) id = n; else { try { const parsed = JSON.parse(val); if (parsed && typeof parsed.taskId === 'number') id = parsed.taskId; else if (parsed && typeof parsed.id === 'number') id = parsed.id; } catch { /* ignore */ } } } else if (typeof val === 'object' && val !== null) { const obj = val as any; if (typeof obj.taskId === 'number') id = obj.taskId; else if (typeof obj.id === 'number') id = obj.id; } if (id !== undefined && !isNaN(id)) taskIds.push(id); } } if (taskIds.length === 0) return new Map(); const rows = await db .select({ id: tasks.id, title: tasks.title }) .from(tasks) .innerJoin(forms, eq(tasks.formId, forms.id)) .where(and(inArray(tasks.id, taskIds), eq(forms.organizationId, organizationId))); const map = new Map(); for (const row of rows) { map.set(row.id, row.title); } return map; } export async function buildSystemFieldValues( task: Task, orgId: number, ctx?: SystemFieldContext ): Promise> { const map = new Map(); map.set('_id', String(task.id)); map.set('_number', String(task.id)); map.set('_due_date', task.dueDate ? new Date(task.dueDate).toLocaleDateString('ru-RU') : ''); // Use preloaded statuses if provided, otherwise fetch const statuses = ctx?.statuses ?? (task.currentStatusId && task.formId ? await storage.getFormStatuses(task.formId, orgId) : []); const status = statuses.find((s) => s.id === task.currentStatusId); map.set('_status', status?.name ?? ''); if (task.assignedTo) { const user = ctx?.usersMap ? ctx.usersMap.get(task.assignedTo) : await storage.getUser(task.assignedTo); map.set('_assignee', formatUserName(user)); } else { map.set('_assignee', ''); } if (task.createdBy) { const user = ctx?.usersMap ? ctx.usersMap.get(task.createdBy) : await storage.getUser(task.createdBy); map.set('_author', formatUserName(user)); } else { map.set('_author', ''); } return map; } /** * Пересчитывает названия всех задач формы на основе её titleTemplate. * Возвращает статистику: сколько обновлено, общее количество, ошибки. */ export async function recalculateTaskTitles( formId: number, organizationId: number ): Promise<{ updated: number; total: number; errors: Array<{ taskId: number; title: string; error: string }>; }> { const formTasks = await storage.getTasksByForm(formId, organizationId); if (formTasks.length === 0) { return { updated: 0, total: 0, errors: [] }; } const form = await storage.getForm(formId, organizationId); if (!form || !form.titleTemplate) { return { updated: 0, total: formTasks.length, errors: [] }; } const [allFormFields, titleStatuses, titleUsers, titleRoles] = await Promise.all([ storage.getFormFields(formId, organizationId), storage.getFormStatuses(formId, organizationId), storage.getUsersByOrganization(organizationId), db.select().from(roles).where(eq(roles.organizationId, organizationId)), ]); const fieldCodeMap = new Map(allFormFields.map((f) => [f.code, f])); const usersMap = new Map(titleUsers.map((u) => [u.id, u])); const rolesMap = new Map(titleRoles.map((r) => [r.id, r])); // Загружаем справочники для полей типа 'table' чтобы резолвить ID в названия при формировании title const tableRowMap = await buildTableRowMap(allFormFields, organizationId); let updated = 0; const errors: Array<{ taskId: number; title: string; error: string }> = []; for (const task of formTasks) { try { const savedValues = await storage.getTaskFieldValues(task.id, organizationId); const valueByFieldId = new Map(savedValues.map((v) => [v.fieldId, v.value])); const sysValues = await buildSystemFieldValues(task, organizationId, { statuses: titleStatuses, usersMap }); const taskTitleMap = await resolveTaskFieldTitles(valueByFieldId, allFormFields, organizationId); const titleCtx: FieldTitleContext = { usersMap, rolesMap, taskTitleMap, tableRowMap }; const computedTitle = (form.titleTemplate as string).replace(/\{\{([^}]+)\}\}/g, (_, code: string) => { if (sysValues.has(code)) return sysValues.get(code)!; const fld = fieldCodeMap.get(code); if (!fld) return ""; const val = valueByFieldId.get(fld.id); if (val === null || val === undefined) return ""; if (val === "__auto_prolongation__" && (fld.type === "date" || fld.type === "datetime")) { return fld.autoProlongationLabel || "Автопролонгация"; } return formatFieldValueForTitle(val, fld.type, titleCtx, fld.options); }); if (computedTitle !== task.title) { await storage.updateTask(task.id, organizationId, { title: computedTitle }); updated++; } } catch (err: any) { errors.push({ taskId: task.id, title: task.title || "", error: err.message || "Ошибка пересчёта" }); } } return { updated, total: formTasks.length, errors }; } /** Загружает справочники для полей типа 'table' и строит tableRowMap */ export async function buildTableRowMap( fields: Array<{ type?: string; options?: unknown }>, organizationId: number ): Promise>> { const tableRowMap = new Map>(); const tableFields = fields.filter((f: any) => { if (f.type !== 'table') return false; const parsed = safeParseOptions(f.options); return parsed && typeof parsed === 'object' && !Array.isArray(parsed) && (parsed as any).tableId; }); if (process.env.NODE_ENV !== 'production') { console.log('[buildTableRowMap] tableFields count:', tableFields.length, 'fieldIds:', tableFields.map((f: any) => f.id)); } const uniqueTableIds = new Set(); for (const f of tableFields) { const parsed = safeParseOptions(f.options); const tableId = (parsed as any).tableId as number; if (tableId) uniqueTableIds.add(tableId); } for (const tableId of uniqueTableIds) { const fullTable = await storage.getDataTableFull(tableId, organizationId); if (!fullTable) continue; const columns = (fullTable.columns as Array<{ name: string }>) || []; const rows = (fullTable.rows as Array<{ id: number; values: string[] }>) || []; const displayColumn = (tableFields.find((f: any) => (f.options as any).tableId === tableId)?.options as any)?.displayColumn || columns[0]?.name; const colIndex = columns.findIndex((c) => c.name === displayColumn); const rowMap = new Map(); for (const row of rows) { const displayValue = colIndex >= 0 ? row.values[colIndex] : row.values[0]; rowMap.set(row.id, displayValue || `#${row.id}`); } tableRowMap.set(tableId, rowMap); } return tableRowMap; } // Re-exports from upload utils for backward compatibility export { uploadsDir, tmpUploadDir, EXT_TO_MIME, EXT_MAGIC, IMAGE_EXTENSIONS, IMAGE_MAX_SIZE, DOC_MAX_SIZE, getFileExt, validateFilename, isMimeConsistentWithExt, checkMagicBytes, getSizeLimit, upload, extractFileUrls, deleteFileByUrl, deleteUploadedFile, deleteRemovedFiles, pendingUploads, PENDING_UPLOAD_TTL_MS, getPendingKey, getActivePendingUploads, addPendingUpload, commitPendingUploads, sweepPendingUploads, type PendingUploadEntry, } from '../utils/upload';