Архивирование нескольких файлов в ZIP в ASP.NET и отдача архива пользователю

РазделASP.NETУровеньСредний Чтение8 минОпубликовано ТегиASP.NETZIPVB.NETФайлы

Пользователь отметил десять отчётов и хочет скачать их одним файлом. Собираем ZIP-архив на лету, пишем его прямо в Response и не оставляем на диске сервера временного мусора.

Главное. Несколько файлов удобно отдавать одним ZIP, собранным на лету прямо в Response.OutputStream. Самое важное здесь не сжатие, а безопасность путей и отключение буферизации для больших архивов.

Задача: скачать выбранное одним архивом

Сценарий встречается постоянно: список документов в 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, собирайте полный путь от разрешённой папки и проверяйте, что результат действительно лежит внутри неё.

SafePath.vbVB.NET
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, иначе разметка страницы допишется в конец архива и испортит его.

Documents.aspx.vbVB.NET
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
Если имена файлов в архиве на кириллице, без явной кодировки UTF-8 старые архиваторы покажут вместо них нечитаемые символы.

Буферизация и большие архивы

По умолчанию 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.

Как это делают сегодня

Начиная с .NET Framework 4.5 сторонняя библиотека не нужна: классы ZipArchive и ZipFile из System.IO.Compression умеют создавать архивы в любом потоке. В ASP.NET Core архив пишут в Response.Body или возвращают через File() из контроллера, а для очень больших наборов файлов используют асинхронную запись, чтобы не блокировать потоки пула.

Вопросы и ответы

Почему скачанный архив повреждён и не открывается?

Чаще всего после архива в ответ дописалась HTML-разметка страницы. Очистите Response перед записью и завершите запрос сразу после zip.Save.

Можно ли поставить пароль на архив?

DotNetZip поддерживает свойство Password и шифрование AES. Помните, что пароль придётся как-то передать пользователю, а стандартный ZipCrypto считается слабым.

Работает ли этот код внутри UpdatePanel?

Нет, асинхронный постбэк не может вернуть файл. Зарегистрируйте кнопку как PostBackTrigger или вынесите выдачу архива в отдельный обработчик .ashx.

Читайте также