Node.js: вложение к заявке и границы загрузки
Заявка DeskQueue может сопровождаться небольшим текстовым описанием оборудования. Приём файла отличается от JSON-команды: тело имеет multipart-формат, данные расходуют память и диск, а имя присылает клиент. Добавим одно текстовое вложение на запрос через Multer 2. Сначала проверим личность и доступ к заявке, затем ограничим парсер и сохраним файл вне публичного каталога.
В stages/19 появляются attachments.js, миграция 004-attachments.sql и переменная UPLOAD_DIR. Это отдельный каталог постоянных данных. Он не находится внутри public и не должен исчезать при обновлении кода. Работа приложения с файлами не превращает их в общедоступные ресурсы: скачивание проходит через тот же договор прав.
Разбор только на одном адресе
Multer разбирает multipart-тело и помещает выбранный файл в req.file. Мы используем память только для малого учебного предела: максимум 256 КиБ, один файл, без дополнительных текстовых полей. Подключаем парсер к POST /api/tickets/:id/attachments, а не ко всему приложению. Возможности и ограничения описаны в официальной документации Multer.
import multer from 'multer';
export const uploadOne = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 256 * 1024, files: 1, fields: 0, parts: 1 }
}).single('attachment');
Имя attachment является частью API. Если клиент отправил файл в другом поле, это ошибочный запрос. Дополнительные файлы не игнорируем молча: парсер должен отвергнуть нарушение. Размер JSON из прошлой главы не ограничивает этот маршрут, потому что формат другой. У каждого входного пути есть собственный ресурс и предел.
Проверка пользователя и владения выполняется перед парсером. Нет смысла принимать весь файл, а затем обнаруживать, что заявка чужая. Это не исключает расход на сам входящий запрос, но сокращает обработку приложения. Получатель должен учитывать и ограничения внешнего сервера, который принимает соединение до Express; наш локальный пример не задаёт полную инфраструктурную политику.
Содержимое важнее расширения
Не доверяем originalname и mimetype как подтверждению типа. Они приходят от клиента. Для учебного текстового вложения декодируем байты как UTF-8 с fatal: true, отвергаем неверную последовательность и нулевой символ. Это выбранный узкий договор: мы не пытаемся безопасно принимать произвольный документ, изображение и исполняемый файл одним универсальным условием.
const text = new TextDecoder('utf-8', { fatal: true }).decode(req.file.buffer);
if (text.includes('\0')) throw new ApiError(400, 'invalid_attachment');
Корректный UTF-8 не означает безопасный HTML. Текст может содержать теги или команды оболочки. Мы не выполняем его, не включаем как HTML и не передаём в shell. При скачивании отправляем как вложение с нейтральным именем attachment.txt, типом application/octet-stream и X-Content-Type-Options: nosniff. Клиент сам выбирает редактор, а сервер не превращает загруженное содержание в страницу своего происхождения.
Имя на диске назначается сервером через UUID. Пользовательское ../../config.env никак не участвует в построении пути. Это предотвращает смешение логического имени и системного адреса. Сохраняем файл флагом wx, чтобы существующее имя не было перезаписано. Коллизия случайного идентификатора крайне маловероятна, но выбранная операция всё равно должна безопасно отвергнуть её.
Файл и строка базы
Таблица attachments содержит идентификатор, заявку, назначенное имя, размер и время создания. Само содержание хранится в файловом каталоге. Функция сначала пишет новый файл, затем вставляет метаданные через параметры. Если вставка не удалась, она пытается удалить созданный файл и передаёт ошибку дальше. Ответ 201 возвращается после обеих успешных частей.
Это не одна транзакция PostgreSQL: файловая система не участвует в её откате. Если процесс аварийно завершится между записью и вставкой, останется файл без строки. Если файл будет удалён вне приложения, строка останется без содержимого. Мы явно сохраняем эту границу. В реальном сопровождении нужен процесс сверки и политика удаления, либо другой способ хранения с подходящими гарантиями.
На читательском стенде ожидаем, что после создания метаданные содержат фактический размер, а файл доступен через защищённый адрес скачивания. При превышении лимита должна прийти ошибка 413, и строка вложения не создаётся. При неправильном поле — 400. При чужой заявке requester получает 404 ещё до загрузки. При подготовке курса эти операции не выполнялись.
Скачивание через разрешение
Адрес GET /api/tickets/:id/attachments/:attachmentId сначала читает заявку и проверяет автора либо роль agent. Затем выбирает вложение одновременно по его идентификатору и ticket_id. Нельзя найти вложение только по номеру и поверить, что оно относится к заявке из пути: клиент мог намеренно соединить два разных идентификатора.
Путь к файлу строится только из доверенного серверного имени, которое было сохранено при создании. В полном модуле дополнительно проверяется формат UUID перед отправкой. Ошибка чтения файла после отправки заголовков проходит через правила потокового ответа; новый JSON уже не может заменить начатую передачу. Поэтому res.download использует callback, который передаёт поздний сбой в обработчик Express.
Доступный для чтения каталог не должен быть доступен для записи любому локальному пользователю. В учебной конфигурации файлы создаются с режимом 0600, каталог — 0700. В настоящем окружении права должны соответствовать сервисному пользователю, резервной копии и выбранному механизму хранения. Публичная раздача всего каталога через отдельный сервер обошла бы нашу проверку владельца.
Размер и параллельные запросы
256 КиБ — предел одного файла, а не всей памяти сервера. Сто одновременных допустимых загрузок способны занять заметно больше памяти, чем один запрос. MemoryStorage выбрана ради маленького ясного примера. Для больших файлов понадобится потоковый или дисковый приём с временным каталогом, очисткой и ограничением одновременных операций. Нельзя повысить лимит до гигабайтов и считать остальную программу неизменной.
Мы также не сохраняем оригинальное имя в публичном ответе. Если продукту нужен такой атрибут, его следует ограничить и безопасно отображать. Сейчас предметный результат проще: вложение принадлежит заявке, сервер назначает место хранения, а клиент получает номер. Это позволяет объяснить безопасность пути без большого количества второстепенных правил отображения.
Текущая ступень принимает только текст. Антивирусная обработка, преобразование изображений, загрузка архивов и исполнение приложенных скриптов не входят в договор. Усложнение типа меняет возможные действия с файлом и требует самостоятельного решения. Поэтому результат этой главы завершён именно для небольшого UTF-8-вложения, а не для любого содержимого интернета.
В итоговой программе теперь есть два постоянных ресурса: PostgreSQL и каталог вложений. Их совместная резервная копия и восстановление относятся к сопровождению. Последняя глава курса сосредоточится на конфигурации и корректном завершении процесса, чтобы сервер не терял незаконченные операции при обычной остановке.
Полные файлы учебного проекта. Примеры при подготовке текста не запускались; ответы и вывод в уроке описывают ожидаемый результат читателя.