diff --git a/.ci/run_browser_tests.sh b/.ci/run_browser_tests.sh
index 3ce5bb9e5a..68265eba1d 100755
--- a/.ci/run_browser_tests.sh
+++ b/.ci/run_browser_tests.sh
@@ -63,3 +63,7 @@ TESTS_MODE=tablet vendor/bin/phpunit -c tests/Browser/phpunit.xml --fail-on-warn
# Mobile mode tests are unreliable on Github Actions
# echo "TESTS_MODE: PHONE"
# TESTS_MODE=phone vendor/bin/phpunit -c tests/Browser/phpunit.xml --fail-on-warning --fail-on-risky --display-deprecations --exclude-group=failsonga-phone
+
+echo "TESTS_MODE: DESKTOP with zipstream"
+composer require $COMPOSER_ARGS -n 'maennchen/zipstream-php:^3.0'
+TESTS_MODE=desktop vendor/bin/phpunit -c tests/Browser/phpunit.xml --fail-on-warning --fail-on-risky --display-deprecations plugins/zipdownload/tests/Browser
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 11a8df2a92..81b3bf3dff 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -71,6 +71,7 @@ jobs:
run: |
composer require "kolab/net_ldap3:~1.1.1" --no-update
composer require "laravel/dusk:^8.3" --no-update
+ composer require "maennchen/zipstream-php:^3.0" --no-update
- name: Install dependencies
run: composer install --prefer-dist --no-interaction --no-progress
diff --git a/composer.json b/composer.json
index 1992af5097..bd2b29a774 100644
--- a/composer.json
+++ b/composer.json
@@ -68,7 +68,8 @@
},
"suggest": {
"bjeavons/zxcvbn-php": "^1.0 required for Zxcvbn password strength driver",
- "kolab/net_ldap3": "~1.1.4 required for connecting to LDAP"
+ "kolab/net_ldap3": "~1.1.4 required for connecting to LDAP",
+ "maennchen/zipstream-php": "^3.0 optional for larger zip support in zipdownload plugin"
},
"repositories": [
{
diff --git a/plugins/zipdownload/composer.json b/plugins/zipdownload/composer.json
index 7639225275..f6fe39ca18 100644
--- a/plugins/zipdownload/composer.json
+++ b/plugins/zipdownload/composer.json
@@ -3,7 +3,7 @@
"type": "roundcube-plugin",
"description": "Adds an option to download all attachments to a message in one zip file, when a message has multiple attachments. Also allows the download of a selection of messages in one zip file. Supports mbox and maildir format.",
"license": "GPL-3.0-or-later",
- "version": "3.6",
+ "version": "3.8",
"authors": [
{
"name": "Thomas Bruederli",
diff --git a/plugins/zipdownload/config.inc.php.dist b/plugins/zipdownload/config.inc.php.dist
index a6911067fc..98332d5213 100644
--- a/plugins/zipdownload/config.inc.php.dist
+++ b/plugins/zipdownload/config.inc.php.dist
@@ -16,4 +16,4 @@ $config['zipdownload_attachments'] = 1;
$config['zipdownload_selection'] = '50MB';
// Charset to use for filenames inside the zip
-$config['zipdownload_charset'] = 'ISO-8859-1';
+$config['zipdownload_charset'] = 'UTF-8';
diff --git a/plugins/zipdownload/zipdownload.php b/plugins/zipdownload/zipdownload.php
index d3aa92baed..046c081dc6 100644
--- a/plugins/zipdownload/zipdownload.php
+++ b/plugins/zipdownload/zipdownload.php
@@ -1,5 +1,9 @@
request_security_check(rcube_utils::INPUT_GET);
+ $message = new rcube_message(rcube_utils::get_input_string('_uid', rcube_utils::INPUT_GET));
+ $filename = ($this->_filename_from_subject($message->subject) ?: 'attachments') . '.zip';
+
+ if (class_exists('ZipStream\ZipStream')) {
+ $this->_download_attachments_zipstream($message, $filename);
+ } else {
+ $this->_download_attachments_tempfile($message, $filename);
+ }
+ }
+
+ /**
+ * Perform attachment download using temporary files
+ *
+ * @param rcube_message $message Where to retrieve attachments
+ * @param string $filename Name to give to the download file
+ */
+ public function _download_attachments_tempfile($message, $filename)
+ {
$tmpfname = rcube_utils::temp_filename('zipdownload');
$tempfiles = [$tmpfname];
- $message = new rcube_message(rcube_utils::get_input_string('_uid', rcube_utils::INPUT_GET));
// open zip file
$zip = new \ZipArchive();
@@ -153,8 +176,6 @@ public function download_attachments()
$zip->close();
- $filename = ($this->_filename_from_subject($message->subject) ?: 'attachments') . '.zip';
-
$this->_deliver_zipfile($tmpfname, $filename);
// delete temporary files from disk
@@ -165,6 +186,35 @@ public function download_attachments()
exit;
}
+ /**
+ * Perform attachment download using ZipStream
+ *
+ * @param rcube_message $message Where to retrieve attachments
+ * @param string $filename Name to give to the download file
+ */
+ public function _download_attachments_zipstream($message, $filename)
+ {
+ $rcmail = rcmail::get_instance();
+ $rcmail->output->download_headers($filename);
+
+ $zip = new ZipStream(
+ sendHttpHeaders: false,
+ defaultDeflateLevel: 1,
+ flushOutput: true
+ );
+
+ foreach ($message->attachments as $part) {
+ $disp_name = $this->_create_displayname($part);
+ $fs = new \FiberStream(static fn ($fp) => $zip->addFileFromStream($disp_name, $fp));
+ $message->get_part_body($part->mime_id, false, 0, $fs->get_file());
+ $fs->close();
+ }
+
+ $zip->finish();
+
+ exit;
+ }
+
/**
* Handler for message download action
*/
@@ -232,8 +282,6 @@ private function _download_messages($messageset)
$limit = $rcmail->config->get('zipdownload_selection', $this->default_limit);
$limit = $limit !== true ? parse_bytes($limit) : -1;
$delimiter = $imap->get_hierarchy_delimiter();
- $tmpfname = rcube_utils::temp_filename('zipdownload');
- $tempfiles = [$tmpfname];
$folders = count($messageset) > 1;
$timezone = new \DateTimeZone('UTC');
$messages = [];
@@ -251,14 +299,15 @@ private function _download_messages($messageset)
foreach ($uids as $uid) {
$headers = $imap->get_message_headers($uid);
+ // Received (internal) date
+ $date = rcube_utils::anytodatetime($headers->internaldate);
+
if ($mode == 'mbox') {
// Sender address
$from = rcube_mime::decode_address_list($headers->from, null, true, $headers->charset, true);
$from = array_shift($from);
$from = preg_replace('/\s/', '-', $from);
- // Received (internal) date
- $date = rcube_utils::anytodatetime($headers->internaldate);
if ($date) {
$date = $date->setTimezone($timezone)->format(self::MBOX_DATE_FORMAT);
} else {
@@ -277,14 +326,12 @@ private function _download_messages($messageset)
$path = $folders ? str_replace($delimiter, '/', $mbox) . '/' : '';
$disp_name = $path . $uid . ($subject ? " {$subject}" : '') . '.eml';
- $messages[$uid . ':' . $mbox] = $disp_name;
+ $messages[$uid . ':' . $mbox] = ($date ? $date->getTimestamp() : '') . ':' . $disp_name;
}
$size += $headers->size;
if ($limit > 0 && $size > $limit) {
- unlink($tmpfname);
-
$msg = $this->gettext([
'name' => 'sizelimiterror',
'vars' => ['$size' => rcmail_action::show_bytes($limit)],
@@ -297,60 +344,109 @@ private function _download_messages($messageset)
}
}
+ $basename = $folders ? 'messages' : $imap->get_folder();
+ if (class_exists('ZipStream\ZipStream')) {
+ $this->_download_messages_zipstream($messages, $mode, $basename);
+ } else {
+ $this->_download_messages_tempfile($messages, $mode, $basename);
+ }
+ }
+
+ /**
+ * Helper method to add a single email to an mbox-style file stream
+ *
+ * @param resource $stream File stream to write to
+ * @param string $header Mbox header to write before the email
+ * @param string $mbox The mailbox folder containing the email
+ * @param string $uid The UID of the email to write
+ * @param bool $is_last Is this the last email in the mbox
+ * @param \FiberStream $fiberstream FiberStream, if any, associated with the $stream
+ */
+ private function _write_mbox_stream($stream, $header, $mbox, $uid, $is_last, $fiberstream = null)
+ {
+ $rcmail = rcmail::get_instance();
+ $imap = $rcmail->get_storage();
+
+ fwrite($stream, $header);
+ $imap->set_folder($mbox);
+
+ // Use stream filter to quote "From " in the message body
+ $filter = stream_filter_append($stream, 'mbox_filter');
+ $imap->get_raw_body($uid, $stream);
+ stream_filter_remove($filter);
+
+ // Make sure the delimiter is a double \r\n
+ if ($fiberstream) {
+ $last_two = $fiberstream->get_last_two(); // if $stream doesn't support the functions below
+ } else {
+ $last_two = stream_get_contents($stream, 2, fstat($stream)['size'] - 2);
+ }
+ if ($last_two != "\r\n") {
+ fwrite($stream, "\r\n");
+ }
+ if (!$is_last) {
+ fwrite($stream, "\r\n");
+ }
+ }
+
+ /**
+ * Perform message download using temporary files
+ *
+ * @param array $messages Map of uid:mbox => mbox_header or timestamp:display_name
+ * @param string $mode The _mode POST parameter
+ * @param string $basename Name, without extension, to give to the download file
+ */
+ private function _download_messages_tempfile($messages, $mode, $basename)
+ {
+ $rcmail = rcmail::get_instance();
+ $imap = $rcmail->get_storage();
+ $tmpfname = rcube_utils::temp_filename('zipdownload');
+ $tempfiles = [$tmpfname];
+
if ($mode == 'mbox') {
$tmpfp = fopen($tmpfname . '.mbox', 'w');
if (!$tmpfp) {
exit;
}
+ stream_filter_register('mbox_filter', 'zipdownload_mbox_filter');
}
// open zip file
+ putenv('TZ=UTC'); // see _datetime_to_ziplocal() comments
$zip = new \ZipArchive();
$zip->open($tmpfname, \ZipArchive::OVERWRITE);
$last_key = array_key_last($messages);
foreach ($messages as $key => $value) {
[$uid, $mbox] = explode(':', $key, 2);
- $imap->set_folder($mbox);
if (!empty($tmpfp)) {
- fwrite($tmpfp, $value);
-
- // Use stream filter to quote "From " in the message body
- stream_filter_register('mbox_filter', 'zipdownload_mbox_filter');
- $filter = stream_filter_append($tmpfp, 'mbox_filter');
- $imap->get_raw_body($uid, $tmpfp);
- stream_filter_remove($filter);
-
- // Make sure the delimiter is a double \r\n
- $fstat = fstat($tmpfp);
- if (stream_get_contents($tmpfp, 2, $fstat['size'] - 2) != "\r\n") {
- fwrite($tmpfp, "\r\n");
- }
- if ($key != $last_key) {
- fwrite($tmpfp, "\r\n");
- }
+ $this->_write_mbox_stream($tmpfp, $value, $mbox, $uid, $key == $last_key);
} else { // maildir
+ [$date, $filename] = explode(':', $value, 2);
$tmpfn = rcube_utils::temp_filename('zipmessage');
$fp = fopen($tmpfn, 'w');
+ $imap->set_folder($mbox);
$imap->get_raw_body($uid, $fp);
$tempfiles[] = $tmpfn;
fclose($fp);
- $zip->addFile($tmpfn, $value);
+ $zip->addFile($tmpfn, $filename);
+ if ($date) {
+ $date = $this->_datetime_to_ziplocal(new \DateTime('@' . $date));
+ $zip->setMtimeName($filename, $date->getTimestamp());
+ }
}
}
- $filename = $folders ? 'messages' : $imap->get_folder();
-
if (!empty($tmpfp)) {
$tempfiles[] = $tmpfname . '.mbox';
fclose($tmpfp);
- $zip->addFile($tmpfname . '.mbox', $filename . '.mbox');
+ $zip->addFile($tmpfname . '.mbox', $basename . '.mbox');
}
$zip->close();
- $this->_deliver_zipfile($tmpfname, $filename . '.zip');
+ $this->_deliver_zipfile($tmpfname, $basename . '.zip');
// delete temporary files from disk
foreach ($tempfiles as $tmpfn) {
@@ -360,6 +456,90 @@ private function _download_messages($messageset)
exit;
}
+ /**
+ * Perform message download using ZipStream
+ *
+ * @param array $messages Map of uid:mbox => mbox_header or timestamp:display_name
+ * @param string $mode The _mode POST parameter
+ * @param string $basename Name, without extension, to give to the download file
+ */
+ private function _download_messages_zipstream($messages, $mode, $basename)
+ {
+ $rcmail = rcmail::get_instance();
+ $imap = $rcmail->get_storage();
+
+ $rcmail->output->download_headers($basename . '.zip');
+
+ $zip = new ZipStream(
+ sendHttpHeaders: false,
+ defaultDeflateLevel: 1,
+ flushOutput: true
+ );
+
+ if ($mode == 'mbox') {
+ $fs = new \FiberStream(static fn ($fp) => $zip->addFileFromStream($basename . '.mbox', $fp));
+ stream_filter_register('mbox_filter', 'zipdownload_mbox_filter');
+ }
+
+ $last_key = array_key_last($messages);
+ foreach ($messages as $key => $value) {
+ [$uid, $mbox] = explode(':', $key, 2);
+
+ if ($mode == 'mbox') {
+ $this->_write_mbox_stream($fs->get_file(), $value, $mbox, $uid, $key == $last_key, $fs);
+ } else { // maildir
+ [$date, $filename] = explode(':', $value, 2);
+ $date = $date ? $this->_datetime_to_ziplocal(new \DateTime('@' . $date)) : null;
+ $imap->set_folder($mbox);
+ $fs = new \FiberStream(static fn ($fp) => $zip->addFileFromStream($filename, $fp, lastModificationDateTime: $date));
+ $imap->get_raw_body($uid, $fs->get_file());
+ $fs->close();
+ }
+ }
+
+ if ($mode == 'mbox') {
+ $fs->close();
+ }
+ $zip->finish();
+
+ exit;
+ }
+
+ /**
+ * Zip files do not store timezones; most extraction tools extract times
+ * as though they were local times. This converts the UTC times inside
+ * DateTime objects into a user's preferred local time (despite claiming
+ * to still be UTC) so that when added and later extracted to/from a zip
+ * file, they will be in that user's local time.
+ *
+ * Also, ZipArchive creation is affected the system's default timezone
+ * (NOT date_default_timezone_set); to mitigate this, putenv('TZ=UTC').
+ *
+ * @param \DateTimeInterface $real The accurate DateTime of a file (is not changed)
+ *
+ * @return \DateTimeInterface A "fake" DateTimeImmutable for inclusion into a zip
+ */
+ private function _datetime_to_ziplocal($real)
+ {
+ if (!$this->timezone) {
+ if ($this->timezone === false) {
+ return $real;
+ }
+ $rcmail = rcmail::get_instance();
+ try {
+ $this->timezone = new \DateTimeZone($rcmail->config->get('timezone'));
+ } catch (\DateInvalidTimeZoneException) {
+ $this->timezone = false;
+ return $real;
+ }
+ }
+
+ $real = \DateTime::createFromInterface($real);
+ $real->setTimezone($this->timezone);
+ $local = max($real->format('Y/m/d H:i:s'), '1980/01/01 00:00:00'); // Earliest supported by zip
+ return \DateTimeImmutable::createFromFormat('Y/m/d H:i:s O', $local . ' +0000');
+ }
+
/**
* Helper method to send the zip archive to the browser
*/
@@ -369,7 +549,20 @@ private function _deliver_zipfile($tmpfname, $filename)
$rcmail->output->download_headers($filename, ['length' => filesize($tmpfname)]);
- readfile($tmpfname);
+ $tmpfp = fopen($tmpfname, 'r');
+ if (!$tmpfp) {
+ return;
+ }
+ while (true) {
+ $data = fread($tmpfp, 512 * 1024);
+ if (strlen($data) == 0) {
+ break;
+ }
+ echo $data;
+ ob_flush();
+ flush();
+ }
+ fclose($tmpfp);
}
/**
@@ -413,3 +606,214 @@ public function filter($in, $out, &$consumed, $closing)
return \PSFS_PASS_ON;
}
}
+
+/**
+ * Consider a source of data, which writes to a standard file stream, and
+ * a destination of data, which reads from that stream, via this pattern:
+ * $stream = fopen(..., 'w+');
+ * write_all_to_stream($stream);
+ * rewind($stream);
+ * read_entire_stream($stream);
+ * The stream must be backed by something, typically a temp file or a
+ * php://memory file. As an alternative, a FiberStream can be used:
+ * $fs = new FiberStream('read_entire_stream'); // arg is a callable
+ * write_all_to_stream($fs->get_file());
+ * $fs->close();
+ * The FiberStream allows the source to fwrite() up to a certain limit (the
+ * chunk_size, defaulting to 512 KiB), and then transfers control to the
+ * destination, which will fread() until the buffer is emptied, after which
+ * it will transfer control back to the source to begin writing again. By
+ * using a Fiber to switch back and forth, only a limited amount of buffer
+ * space is required, typically around 1 to 2 times the chunk_size.
+ */
+final class FiberStream implements StreamInterface
+{
+ public const DEFAULT_CHUNK_SIZE = 512 * 1024;
+ private $chunk_size;
+ private $dest_fiber;
+ private $write_file;
+ private $read_file;
+ private $buffer = '';
+ private $read_pos = 0;
+ private $last_two = '';
+ private $closing = false;
+ private $closed = false;
+
+ /**
+ * @param callable(resource): mixed $dest Called by FiberStream to read an entire file stream
+ * @param int $chunk_size The stream's chunk size and desired buffer limit
+ */
+ public function __construct(callable $dest, $chunk_size = self::DEFAULT_CHUNK_SIZE)
+ {
+ $this->chunk_size = $chunk_size;
+ $this->dest_fiber = new \Fiber(function () use ($dest) {
+ $this->read_file = StreamWrapper::getResource($this);
+ stream_set_chunk_size($this->read_file, $this->chunk_size);
+ $dest($this->read_file);
+ });
+ $this->write_file = StreamWrapper::getResource($this);
+ stream_set_chunk_size($this->write_file, $chunk_size);
+ }
+
+ /**
+ * @return resource A file stream an entire file should be written to
+ */
+ public function get_file()
+ {
+ return $this->write_file;
+ }
+
+ /**
+ * Start or resume the destination fiber
+ */
+ private function run_dest_fiber()
+ {
+ if (!$this->dest_fiber->isStarted()) {
+ $this->dest_fiber->start();
+ } else {
+ $this->dest_fiber->resume();
+ }
+ }
+
+ private function check_not_closed()
+ {
+ if ($this->closed || $this->dest_fiber->isTerminated()) {
+ throw new \RuntimeException('FiberStream is closed');
+ }
+ }
+
+ #[\Override]
+ public function write($string): int
+ {
+ $this->check_not_closed();
+ $this->buffer .= $string;
+ $last = substr($this->buffer, -2);
+ if (strlen($last) == 2) {
+ $this->last_two = $last;
+ } elseif (strlen($last) == 1) {
+ $this->last_two = substr($this->last_two, -1) . $last;
+ }
+ if (strlen($this->buffer) >= $this->chunk_size) {
+ $this->run_dest_fiber(); // suspend writer/source and allow dest to read() the buffer
+ }
+ return strlen($string);
+ }
+
+ public function get_last_two()
+ {
+ return $this->last_two; // for _write_mbox_stream()
+ }
+
+ #[\Override]
+ public function read($length): string
+ {
+ $this->check_not_closed();
+ if (\Fiber::getCurrent() !== $this->dest_fiber) {
+ throw new \RuntimeException('Only the dest callable may call FiberStream::read');
+ }
+ if (strlen($this->buffer) == 0 && !$this->closing) {
+ $this->dest_fiber->suspend(); // suspend reader/dest and allow source to write() more
+ }
+ $result = substr($this->buffer, $this->read_pos, $length);
+ $this->read_pos += $length;
+ if ($this->read_pos >= strlen($this->buffer)) {
+ $this->buffer = '';
+ $this->read_pos = 0;
+ }
+ return $result;
+ }
+
+ #[\Override]
+ public function eof(): bool
+ {
+ return $this->closing && $this->read_pos >= strlen($this->buffer);
+ }
+
+ /**
+ * Do not call fclose() on any files received from a FiberStream (e.g. from get_file()),
+ * instead call this which will close the files in the correct order.
+ */
+ #[\Override]
+ public function close(): void
+ {
+ if (!$this->closed) {
+ fclose($this->write_file); // Can cause more calls to our write()/read()/eof(),
+ $this->closing = true; // so wait until those calls have completed and only then set this flag.
+ $this->check_not_closed(); // Ensure dest_fiber hasn't returned early;
+ $this->run_dest_fiber(); // runs until dest returns, it's expected to read() until eof.
+ fclose($this->read_file);
+ $this->buffer = '';
+ $this->closed = true;
+ }
+ }
+
+ #[\Override]
+ public function __toString(): string
+ {
+ return $this->getContents();
+ }
+
+ #[\Override]
+ public function getContents(): string
+ {
+ $buffer = $this->buffer;
+ $this->buffer = '';
+ $this->read_pos = 0;
+ return $buffer;
+ }
+
+ #[\Override]
+ public function detach()
+ {
+ $this->close();
+ return null;
+ }
+
+ #[\Override]
+ public function getSize(): ?int
+ {
+ return null;
+ }
+
+ #[\Override]
+ public function isReadable(): bool
+ {
+ return \Fiber::getCurrent() === $this->dest_fiber;
+ }
+
+ #[\Override]
+ public function isWritable(): bool
+ {
+ return \Fiber::getCurrent() !== $this->dest_fiber;
+ }
+
+ #[\Override]
+ public function isSeekable(): bool
+ {
+ return false;
+ }
+
+ #[\Override]
+ public function rewind(): void
+ {
+ $this->seek(0);
+ }
+
+ #[\Override]
+ public function seek($offset, $whence = \SEEK_SET): void
+ {
+ throw new \RuntimeException('Cannot seek a FiberStream');
+ }
+
+ #[\Override]
+ public function tell(): int
+ {
+ throw new \RuntimeException('Cannot determine the position of a FiberStream');
+ }
+
+ #[\Override]
+ public function getMetadata($key = null)
+ {
+ return $key ? null : [];
+ }
+}
diff --git a/program/lib/Roundcube/rcube_message.php b/program/lib/Roundcube/rcube_message.php
index 83f753c3ef..d756969db8 100644
--- a/program/lib/Roundcube/rcube_message.php
+++ b/program/lib/Roundcube/rcube_message.php
@@ -285,7 +285,10 @@ public function get_part_body($mime_id, $formatted = false, $max_bytes = 0, $mod
if (is_resource($mode)) {
fwrite($mode, $body);
- @rewind($mode);
+ $stat = fstat($mode);
+ if ($stat && $stat['size']) { // try to check if this file is seekable
+ @rewind($mode);
+ }
return true;
}
@@ -305,7 +308,10 @@ public function get_part_body($mime_id, $formatted = false, $max_bytes = 0, $mod
!($mode && $formatted), $max_bytes, $mode && $formatted);
if (is_resource($mode)) {
- @rewind($mode);
+ $stat = fstat($mode);
+ if ($stat && $stat['size']) { // try to check if this file is seekable
+ @rewind($mode);
+ }
return $body !== false;
}
diff --git a/public_html/.htaccess b/public_html/.htaccess
index e6d4b02a55..0f50a831a8 100644
--- a/public_html/.htaccess
+++ b/public_html/.htaccess
@@ -5,15 +5,21 @@ RewriteEngine On
RewriteRule ^favicon\.ico$ static.php/skins/elastic/images/favicon.ico
+# https://httpd.apache.org/docs/2.4/env.html#cgilike
+SetEnv ap_trust_cgilike_cl 1
+
SetOutputFilter DEFLATE
+# some assets have been compressed, so no need to do it again
+SetEnvIfNoCase Request_URI \.(?:gif|jpe?g|png|web[pm]|woff2?)$ no-gzip
+SetEnvIfExpr "%{QUERY_STRING} =~ /(?:^|&)_action=plugin\.zipdownload\.(?:attachments|messages)(?:&|$)/" no-gzip
# prefer to brotli over gzip if brotli is available
SetOutputFilter BROTLI_COMPRESS
-# some assets have been compressed, so no need to do it again
SetEnvIfNoCase Request_URI \.(?:gif|jpe?g|png|web[pm]|woff2?)$ no-brotli
+SetEnvIfExpr "%{QUERY_STRING} =~ /(?:^|&)_action=plugin\.zipdownload\.(?:attachments|messages)(?:&|$)/" no-brotli