Перейти к содержанию

Загрузка и выдача файлов

Курс может получить вложение: например, текстовый план уроков. Но имя, выбранное пользователем, нельзя превращать в путь на сервере. Загрузка связывает несколько границ: право на курс, CSRF, ограничения тела, содержимое, физическое хранение и выдачу. В этом уроке добавим только небольшие текстовые вложения, сохранив разделение между публичным ID и внутренним файлом.

Полная ветка lesson-24/after в архиве продолжения продолжает HTTPS-песочницу 23. Она принимает UTF-8 текст размером до 256 КиБ для курса, принадлежащего редактору. Вложения и их метаданные не интегрированы с PostgreSQL; это отдельная лабораторная модель. Загрузка, SQL, сервер и проверки при подготовке не выполнялись. Общая схема больших файлов, квот и антивирусного сервиса остаётся за границей этого примера.

Имя пользователя не является адресом хранения

Endpoint принимает multipart-форму с IFormFile. В .NET 10 такая форма участвует в стандартной antiforgery-интеграции, поэтому в ветке добавлен UseAntiforgery после authentication и authorization. Заголовок остаётся X-CSRF-TOKEN; JSON-команды продолжают использовать фильтр прошлого урока. Отказ token должен произойти до сохранения вложения.

Входное FileName не используется для построения физического пути. Сервер генерирует ID через Guid и фиксированное расширение .txt. Каталог хранения расположен в Data/uploads, вне wwwroot и вне любого подключённого static-file пути. Известный человеку публичный адрес /api/files/{id} не соответствует прямой раздаче содержимого этого каталога.

var id = Guid.NewGuid().ToString("N");
var physicalPath = Path.Combine(storageRoot, id + ".txt");
await using var output = new FileStream(physicalPath,
    FileMode.CreateNew, FileAccess.Write, FileShare.None,
    4096, FileOptions.Asynchronous);

CreateNew не перезаписывает существующий файл при неожиданной коллизии. Путь строится только из созданного сервером ID. Даже имя клиента ../../appsettings.json не меняет этот выбор. В нашей выдаче имя скачивания тоже фиксированное: course-plan.txt. Если продукт захочет показывать исходное имя, его нужно хранить как отдельные данные и безопасно отображать, а не присоединять к пути.

Размер ограничивается на разных уровнях

У IFormFile.Length есть предметная граница: пустой файл и больше 256 КиБ отклоняются. Однако к моменту handler framework мог уже прочитать или буферизовать multipart. Поэтому в конфигурации ограничены multipart body и общий HTTP-body. Общий лимит чуть больше предметного размера, потому что форма включает заголовки и разделители.

Эти числа не дают бесконечного запаса памяти. Одновременные небольшие загрузки могут суммарно занять много ресурсов; также существует число полей и частей формы. Архив ограничивает число текстовых полей формы и размер multipart-заголовков, но не включает production-квоты аккаунта, диска и общего числа файлов. Песочница должна оставаться локальной и короткоживущей.

Содержимое копируется в ограниченный буфер, а затем проверяется строгим UTF-8 decoder. Неверная последовательность байтов получает 400. Проверка не верит присланному Content-Type и не объявляет файл безопасным лишь из-за расширения. В нашем контракте текст не должен содержать нулевой символ; это позволяет отклонить некоторые случайно загруженные бинарные данные, но не является универсальным распознаванием формата.

Даже корректный UTF-8 может содержать HTML или команды. Поэтому он сохраняется как данные, не выполняется и не открывается сервером как шаблон. Если позднее потребуется Markdown-рендеринг, появится отдельная граница экранирования и обработки ссылок. Нельзя из допустимой кодировки заключать допустимость любого дальнейшего использования.

Метаданные связывают файл с владельцем

До чтения содержимого handler находит курс и выполняет ресурсную политику урока 22. Вошедший редактор может прикрепить план к JavaScript, но не к чужому Markdown. После успешной записи в памяти сохраняются ID вложения, ID курса, ID владельца и физический путь. Публичный JSON возвращает только ID и адрес выдачи, не каталог диска.

Если запись файла отменена или завершилась ошибкой до регистрации метаданных, partial-файл удаляется в обработке отказа. При этом внезапное завершение процесса может оставить orphan-файл: код finally не гарантированно выполнится при любом внешнем останове. Нужна последующая уборка, основанная на согласованном состоянии. Учебная ветка не выдаёт её за реализованный фоновый сборщик.

Метаданные находятся только в памяти процесса. После перезапуска уже существующий файл не становится автоматически публичным: ID отсутствует в словаре, GET возвращает 404. Это предотвращает случайную раздачу всего каталога, но не решает долговременное хранение. Реальное приложение должно сохранять метаданные устойчиво и согласовать запись файла с базой, включая компенсацию при частичном сбое.

Выдача повторно проверяет право

GET /api/files/{id} требует authentication. Сервер находит доверенные метаданные и сравнивает владельца с удостоверенным ID. Неизвестный ID возвращает 404, чужой — 403. Путь не восстанавливается из пользовательского текста запроса, поэтому знание структуры URL не позволяет обойти словарь и выбрать произвольный файл диска.

context.Response.Headers["X-Content-Type-Options"] = "nosniff";
return Results.File(metadata.Path, "application/octet-stream",
    fileDownloadName: "course-plan.txt", enableRangeProcessing: false);

Файл выдаётся как скачиваемое вложение, а не как HTML-страница того же origin. nosniff дополняет выбранный тип. Это особенно важно при содержимом, похожем на разметку: допустимый текст не должен получать полномочия сайта. Не устанавливайте публичное кеширование для приватной выдачи; в ветке задан Cache-Control: no-store.

Для будущего самостоятельного выполнения используйте короткий план JavaScript с русским текстом. Ожидается 201 с новым ID, после чего владелец получает прежние байты. Попробуйте неверный UTF-8, слишком большой файл и чужой курс: ни один из этих случаев не должен создавать доступное вложение. Перезапуск показывает ограничение метаданных, а не обещание долговечной системы хранения.

Результат урока — явный путь от разрешённого курса к контролируемому вложению и обратно. Лимиты reverse proxy, object storage, сканирование, квоты и устойчивое состояние понадобятся отдельно перед реальным выпуском. Требования к недоверенным именам и буферизации описаны в документации загрузок ASP.NET Core.

Содержимое файла и его доступность наблюдаются отдельно. Ответ 201 ещё не доказывает, что байты выдаются без изменения, а успешное скачивание владельцем не доказывает отказ чужому пользователю. В будущей проверке сравните длину и байты исходного текста с полученным вложением, затем обратитесь к тому же ID из анонимного клиента. Также проверьте, что отказ оставляет каталог хранения без нового доступного объекта. Для большого сервиса понадобится квота накопленного объёма: даже тысяча файлов меньше индивидуального лимита способна заполнить диск. Сканирование, очистка orphan и устойчивые метаданные должны иметь собственные состояния и ошибки; их нельзя считать включёнными из-за существования папки uploads.