Контракт загрузки файлов
Редактору может понадобиться прикрепить файл к курсу. Это отдельное действие: сервер получает байты, проверяет их и создаёт ресурс хранения. Название файла, объявленный тип и успешное чтение тела ещё не означают, что содержимое безопасно или что операцию можно повторять без дубликата.
В этом уроке ограничим задачу обычным текстовым UTF-8 файлом. Результат — вы сможете описать принятие, отказ без частичной публикации и повтор загрузки одного намерения. Не строим универсальный загрузчик документов или антивирус. Сервер, поток и файловая система не запускались; точный короткий пример служит моделью байтов.
Узкий вход вместо любого файла
Вводим POST /api/courses/c001/files. Он принимает сырые байты text/plain; charset=utf-8, без NUL и размером до двух МиБ. Другие форматы пока не поддерживаются. Это сознательная граница: для изображения понадобились бы проверка декодирования, размеров и другой способ выдачи, а для архива — ограничения распаковки.
Условный файл f001 содержит ровно ABCDEFGHIJKL: двенадцать ASCII-байтов без перевода строки. Тело следующего листинга показывает эти байты; транспортные служебные поля по-прежнему сокращены.
POST /api/courses/c001/files HTTP/1.1
Host: catalog.example
Authorization: Bearer tok-alice
Content-Type: text/plain; charset=utf-8
Accept: application/json, application/problem+json
Idempotency-Key: upload-text-a001
ABCDEFGHIJKL
Alice аутентифицирована Bearer и имеет право на frontend-курс c001. Cookie-версия той же операции потребовала бы Origin и X-CSRF-Token. Новое действие не меняет title, topic и lessons самого курса: файл становится отдельным ресурсом, связанным с ним.
Имя локального файла пользователя не используется как путь сохранения. Сервер назначает id и место хранения. Если продукт позже начнёт принимать отображаемое имя, оно останется недоверенной метаданной с собственными правилами, а не компонентом исполняемого маршрута или командой оболочки.
Размер проверяется во время приёма
Content-Length способен помочь раннему отказу, но сервер не доверяет ему как единственной границе. Тело может поступать другим допустимым способом, а фактическое число принятых байтов нужно ограничивать при чтении. До полного накопления огромного файла уже должен существовать потоковый предел.
Два МиБ относятся к байтам файла. Заголовки HTTP, возможная оболочка multipart и ограничения прокси являются другими величинами. Мы выбрали прямое тело без multipart, чтобы не смешивать содержимое с именами полей и границами. Если клиент позднее перейдёт к multipart, весь договор размеров нужно уточнить, а не копировать прежнее число без объяснения.
При превышении предела выбираем 413 Content Too Large. Неподдерживаемый Content-Type даёт 415, а неподходящее содержимое при правильном типе — 422. Например, некорректная UTF-8 последовательность не становится текстом только из-за правильной строки заголовка.
{"type":"https://catalog.example/problems/file-too-large","title":"Файл превышает допустимый размер","status":413,"detail":"Допускается текстовый файл до двух МиБ."}
Сам ответ является ожидаемой формой отказа, а не подтверждением реально принятого потока. До публикации файл не появляется по публичному или личному адресу. Клиент сохраняет исходный выбор и может объяснить, что требуется уменьшить файл либо выбрать поддерживаемый формат.
Временное хранение и принятие
Приём обычно нуждается во временной области. Данные сначала проходят ограничения, проверку содержимого и квоты, затем получают подтверждённую запись. Если соединение оборвалось, остался неполный staging или не хватило места, такой объект не должен выглядеть готовым вложением.
Временное место находится вне исполняемого web-root. Оно не превращается в доступный скрипт по совпадению имени. Периодическая очистка оставшихся staging-объектов и учёт квоты — требования к будущей реализации. Урок не обещает, что один вызов записи файла уже обеспечивает их.
Проверка текстового формата не является универсальным поиском вредного содержимого. Она лишь поддерживает узкий договор bytes UTF-8 без NUL. При дальнейшем выдающем интерфейсе текст нельзя бездумно вставлять как HTML: файл может содержать угловые скобки и другие символы, которые остаются данными. Границы загрузки файлов OWASP.
Создание отдельного ресурса
После принятия сервер назначает f001, связывает его с owner u17 и курсом c001. Предполагаемый ответ:
HTTP/1.1 201 Created
Location: /api/files/f001
Content-Type: application/json
Cache-Control: no-store
{"id":"f001","bytes":12,"mediaType":"text/plain"}
Число bytes описывает содержимое файла, а не длину этого JSON-ответа. Его двенадцать получается из выбранного ASCII-примера без newline. Для русского текста число символов и байтов различалось бы; библиотека должна измерять фактические данные.
Адрес файла закрыт и проверяет владельца при чтении. Публичность курса не делает любое вложение общедоступным автоматически. Для чужого пользователя наш договор выбирает 404 по политике сокрытия. Непредсказуемый id помог бы уменьшить угадывание, но не заменил бы проверку owner.
Выдача использует attachment с фиксированным именем catalog.txt, Content-Type текстового формата и nosniff. Это помогает сохранить назначение ресурса как файла данных, а не произвольного документа приложения. При этом заголовок не отзывает уже скачанную копию и не запрещает человеку открыть её другим инструментом.
Повтор после потери ответа
Загрузка использует прикладной Idempotency-Key по прежнему механизму. Его область теперь subject, POST, путь загрузки данного курса и ключ. Отпечаток связывает байты и значимые условия операции. Повтор того же намерения в окне хранения возвращает прежний f001, а не создаёт второй файл.
Сохранение файла, его метаданных и результата операции должно иметь согласованную атомарную границу. Файловая система и база не обязательно дают одну транзакцию, поэтому реализации понадобится staging и восстановление после сбоя. Если метаданные говорят «готово», а файл отсутствует, обещание нарушено; если файл принят без записи ключа, повтор может дублировать результат.
Иные байты под прежним ключом дают 409, не скрытое обновление f001. После установленного 24-часового срока ключ уже не обещает прежнюю дедупликацию. Поздний клиент сначала разрешает неопределённый исход, а не повторяет огромную передачу, считая гарантию вечной.
Закрытая выдача не отменяет стоимость хранения
Входной предел ограничивает один файл, но не общий объём пользователя. Тысяча допустимых файлов по два МиБ всё равно требует квоты, политики удаления и наблюдаемой причины отказа. В нашем узком договоре числа общей квоты ещё не выбраны, поэтому нельзя вывести её из частотного ведра урока 27 или из размера одной передачи.
При удалении ссылки на курс файл также не должен случайно становиться бесхозным публичным объектом. Реализации понадобится определить его жизненный цикл и доступность owner. Здесь удаление файла не введено как операция API: успешный 201 подтверждает принятие по текущему договору, а не обещает все возможные действия над вложением.
Отдельно важно не считать имя attachment гарантией безопасного просмотра. Получатель может сохранить файл и открыть его другим приложением, а содержание всё ещё является недоверенными данными. Наша серверная выдача ограничивает интерпретацию в собственном контексте и сохраняет owner-проверку; она не контролирует любые дальнейшие действия с уже скачанными байтами.
Что стоит сравнить в будущем опыте
Для отдельной реализации подготовьте короткий пригодный текст, неверную UTF-8 последовательность, предел размера и обрыв до принятия. Но сейчас эти действия не выполнялись: мы только задали ожидаемые ветки. Полезно сопоставлять наличие готового ресурса, число файлов одного намерения и отсутствие частичной публикации при отказе.
Не ограничивайтесь статусом: 201 с недоступным для владельца файлом тоже нарушает договор. Успех включает корректное хранение и будущую выдачу. Следующий урок использует точные двенадцать байтов f001, чтобы объяснить частичный ответ Range без смешивания символов, сжатия и версии выбранного представления.