
Архивирование нескольких файлов в ZIP в ASP.NET и отдача архива пользователю
Пользователь отметил десять отчётов и хочет скачать их одним файлом. Собираем ZIP-архив на лету, пишем его прямо в Response и не оставляем на диске сервера временного мусора.
Задача: скачать выбранное одним архивом
Сценарий встречается постоянно: список документов в GridView, у каждой строки флажок, внизу кнопка «Скачать выбранное». Отдавать файлы по одному неудобно, браузер покажет десять диалогов сохранения. Логичнее упаковать всё в ZIP и вернуть один ответ.
В .NET Framework до версии 4.5 нет встроенного API для ZIP. Класс GZipStream сжимает один поток, но не умеет хранить внутри несколько файлов с именами. Поэтому в проектах на 2.0, 3.5 и 4.0 используют сторонние библиотеки: DotNetZip (Ionic.Zip) или SharpZipLib. Обе бесплатные и подключаются одной сборкой в папку bin.
Ниже пример на DotNetZip: у неё самый лаконичный API, и она умеет писать архив сразу в выходной поток ответа.
Подготовка: список файлов и проверка путей
Главная опасность такой функции не в архивировании, а в путях. Если имена файлов приходят из запроса, злоумышленник может передать строку вида ..\..\web.config и получить в архиве конфигурацию сайта. Поэтому браузер должен присылать только идентификаторы документов, а реальные пути вычисляет сервер.
Если же идентификаторов нет и приходят имена, отрезайте всё, кроме имени файла, через Path.GetFileName, собирайте полный путь от разрешённой папки и проверяйте, что результат действительно лежит внутри неё.
Imports System.IO
Public Module SafePath
' Возвращает полный путь внутри rootFolder или Nothing
Public Function Resolve(ByVal rootFolder As String, ByVal name As String) As String
Dim fileName As String = Path.GetFileName(name) ' отрезаем каталоги
If String.IsNullOrEmpty(fileName) Then Return Nothing
Dim root As String = Path.GetFullPath(rootFolder)
Dim full As String = Path.GetFullPath(Path.Combine(root, fileName))
If Not full.StartsWith(root, StringComparison.OrdinalIgnoreCase) Then Return Nothing
If Not File.Exists(full) Then Return Nothing
Return full
End Function
End ModuleСборка архива и запись в Response
DotNetZip позволяет добавить файл методом AddFile, указав папку внутри архива. Пустая строка означает корень архива, без исходной структуры каталогов сервера. Метод Save принимает любой Stream, поэтому архив пишется прямо в Response.OutputStream, и временный файл не нужен.
Перед записью очищаем буфер ответа, задаём тип application/zip и заголовок Content-Disposition, чтобы браузер предложил сохранить файл с понятным именем. После сохранения вызываем Response.End или CompleteRequest, иначе разметка страницы допишется в конец архива и испортит его.
Imports Ionic.Zip
Protected Sub btnZip_Click(ByVal sender As Object, ByVal e As EventArgs) Handles btnZip.Click
Dim root As String = Server.MapPath("~/App_Data/docs")
Dim files As New List(Of String)
For Each row As GridViewRow In gvDocs.Rows
Dim chk As CheckBox = CType(row.FindControl("chkSelect"), CheckBox)
If chk IsNot Nothing AndAlso chk.Checked Then
Dim name As String = gvDocs.DataKeys(row.RowIndex).Value.ToString()
Dim full As String = SafePath.Resolve(root, name)
If full IsNot Nothing Then files.Add(full)
End If
Next
If files.Count = 0 Then lblInfo.Text = "Ничего не выбрано" : Return
Response.Clear()
Response.BufferOutput = False ' отдаём архив по мере сборки
Response.ContentType = "application/zip"
Response.AddHeader("Content-Disposition", "attachment; filename=documents.zip")
Using zip As New ZipFile()
zip.AlternateEncoding = System.Text.Encoding.UTF8 ' русские имена файлов
zip.AlternateEncodingUsage = ZipOption.AsNecessary
For Each f As String In files
zip.AddFile(f, "") ' в корень архива
Next
zip.Save(Response.OutputStream)
End Using
HttpContext.Current.ApplicationInstance.CompleteRequest()
End SubБуферизация и большие архивы
По умолчанию ASP.NET буферизует весь ответ в памяти и отправляет его по завершении запроса. Для архива на сотни мегабайт это означает сотни мегабайт в памяти рабочего процесса на каждого пользователя. Свойство BufferOutput = False переключает ответ в потоковый режим: данные уходят клиенту по мере сжатия.
У потокового режима есть следствие: браузер не знает итогового размера, и полоска загрузки не покажет процент. Если это важно, архив придётся сначала собрать во временный файл, а затем отдать через TransmitFile с заголовком Content-Length. Временный файл тогда нужно обязательно удалить, например в блоке Finally после отправки.
Ещё один параметр: уровень сжатия. Документы PDF, JPEG и уже упакованные файлы почти не сжимаются, и тратить на них процессор бессмысленно. Для них ставьте CompressionLevel.None, архив станет просто контейнером и соберётся заметно быстрее.
Отдельный обработчик вместо кнопки на странице
Отдавать файл из обработчика кнопки на обычной странице работает, но не всегда удобно: страница с UpdatePanel не сможет вернуть бинарный ответ на асинхронный постбэк, получится ошибка разбора ответа. Чище вынести выдачу архива в Generic Handler (файл .ashx) и вызывать его обычной ссылкой или формой с методом POST.
Обработчик не тянет за собой жизненный цикл страницы, ViewState и мастер-страницу, поэтому работает быстрее и не рискует дописать в ответ лишнюю разметку. Кнопку внутри UpdatePanel в таком случае регистрируют как PostBackTrigger или просто заменяют ссылкой на обработчик.
- передавайте в обработчик идентификаторы документов, а не пути;
- проверяйте права пользователя на каждый документ, а не только факт входа на сайт;
- ограничьте число файлов и суммарный размер, чтобы один запрос не занимал сервер на минуты.
Проверка результата
Перед выкладкой стоит проверить несколько вещей. Архив должен открываться встроенным проводником Windows и архиваторами 7-Zip и WinRAR. Русские имена файлов должны читаться. Попытка передать имя с ..\ должна молча отбрасываться. Повторное нажатие кнопки не должно оставлять на сервере временных файлов, если вы выбрали вариант с TransmitFile.
Как это делают сегодня
Вопросы и ответы
Почему скачанный архив повреждён и не открывается?
Чаще всего после архива в ответ дописалась HTML-разметка страницы. Очистите Response перед записью и завершите запрос сразу после zip.Save.
Можно ли поставить пароль на архив?
DotNetZip поддерживает свойство Password и шифрование AES. Помните, что пароль придётся как-то передать пользователю, а стандартный ZipCrypto считается слабым.
Работает ли этот код внутри UpdatePanel?
Нет, асинхронный постбэк не может вернуть файл. Зарегистрируйте кнопку как PostBackTrigger или вынесите выдачу архива в отдельный обработчик .ashx.