Files
zoneminder/web/views/download.php
T
Isaac ConnorandClaude Opus 5 dc8e22d42a fix: quote the download filename so Chrome doesn't save the export as index.php
The merged mp4 export is named '<Monitor> <start> to <end>.mp4', so it contains
spaces and colons, and download.php emitted it as a bare unquoted filename=
parameter. That is not a valid RFC 6266 token, so browsers that parse
Content-Disposition strictly find no filename and fall back to naming the
download after the last path segment of the URL - index.php. Firefox is lenient
and accepted it, which is why the report was Chrome-on-Windows only.

Add contentDispositionAttachment(), which emits a quoted ASCII filename with the
Windows-illegal characters folded to '_', plus the untouched name as RFC 5987
filename* whenever that folding changed anything, so unicode monitor names still
arrive intact.

Also in that path:
- urlencode the file and export_root query parameters; a monitor name containing
  '&' or '+' would otherwise split or mis-decode the download URL. Read them back
  in export.js with URLSearchParams so the link text shows the decoded name.
- drop the stray ';' from Content-Length, which made the value unparseable.
- silence the shutdown unlink()s, whose warnings would be appended to the body
  of a download that had already started.
- log $this->filenamePath, not an undefined local, on the unreadable-file path.

Tests: tests/php/test_download_content_disposition.php, 12 assertions, all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nr76CednxtDt2nPuq6WrbL
2026-09-03 20:17:16 -04:00

149 lines
5.5 KiB
PHP

<?php
//
// ZoneMinder file download processing
// Copyright (C) 2026
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
//
require_once('includes/download_functions.php');
class downloadingGeneratedEventFile {
private $filenamePath;
private $exportDir;
private $exportRoot;
private $filename;
public $handle = NULL;
public $fileType;
private $mimetype;
private $fileExt;
private $connkey;
public function __construct() {
register_shutdown_function(array($this, 'callRegisteredShutdown'));
$this->fileType = isset($_REQUEST['type']) ? $_REQUEST['type'] : '';
switch ($this->fileType) {
case 'tar.gz':
$this->mimetype = 'gzip';
$this->fileExt = 'tar.gz';
break;
case 'tar':
$this->mimetype = 'tar';
$this->fileExt = 'tar';
break;
case 'zip':
$this->mimetype = 'zip';
$this->fileExt = 'zip';
break;
case 'mp4':
$this->mimetype = 'mp4';
$this->fileExt = 'mp4';
break;
default:
$this->mimetype = NULL;
$this->fileExt = NULL;
}
$this->connkey = isset($_REQUEST['connkey'])?$_REQUEST['connkey']:'';
$this->filename = isset($_REQUEST['file'])?$_REQUEST['file']:"zmExport_$this->connkey.$this->fileExt";
$this->filename = str_replace('/', '', $this->filename); # protect system files. must be a filename, not a path
$this->exportRoot = isset($_REQUEST['export_root'])?$_REQUEST['export_root']."/":"";
$this->exportRoot = str_replace('/', '', $this->exportRoot); # protect system files. must be a export_root, not a path
if ($this->exportRoot) {
$this->filenamePath = DIR_EXPORTS_DOWNLOAD.'/'. $this->exportRoot . '/' . $this->filename;
$this->exportDir = DIR_EXPORTS_DOWNLOAD.'/'.$this->exportRoot;
} else {
$this->filenamePath = DIR_EXPORTS_DOWNLOAD.'/'.$this->filename;
$this->exportDir = DIR_EXPORTS_DOWNLOAD;
}
}
public function callRegisteredShutdown() {
$this->removeTmpFiles();
}
public function removeTmpFiles () {
if ($this->handle) fclose($this->handle);
# Silenced: this runs at shutdown, after the body has been sent, so a
# warning about an already-missing file would be appended to the download.
@unlink($this->exportDir.'/'.$this->filename.'.lock'); # Delete download flag file
@unlink($this->filenamePath); # Delete the downloaded file
# We try to delete the directory (if it is not the main export directory) where the downloaded file was stored.
if ($this->exportDir != ZM_DIR_EXPORTS)
if (@rmdir($this->exportDir)) @rmdir(DIR_EXPORTS_DOWNLOAD);
}
public function download() {
if (is_readable($this->filenamePath)) {
# Let's set a flag that the file has started downloading and mark the download start time.
# In the future, we will delete generated files that have not started downloading within XX minutes.
file_put_contents($this->exportDir.'/'.$this->filename.'.lock', strtotime(date("Y-m-d H:i:s")), LOCK_EX);
while (ob_get_level()) {
ob_end_clean();
}
header("Content-Type: application/$this->mimetype");
header('Content-Length: '.filesize($this->filenamePath));
header('Content-Disposition: '.contentDispositionAttachment($this->filename));
header('Content-Transfer-Encoding: binary');
header("Connection: close"); # Close connection after downloading
@ini_set( 'max_execution_time', 0 );
@set_time_limit(0);
if (0) { # ToDo It needs to be moved to the settings
# DOWNLOAD IN ONE SEGMENT
if ( !@readfile($this->filenamePath) ) {
ZM\Error("Error sending $this->filenamePath");
}
} else {
# DOWNLOAD IN PARTS
$this->handle = fopen($this->filenamePath, "r");
$chunk_size = 1000000;
$bytes_sent = 0;
ignore_user_abort(false); # Disable ignoring user interruptions to prevent the script from running indefinitely if the user interrupts the download.
$canceled = false;
while ($chunk = fread($this->handle, $chunk_size)) {
print $chunk;
$bytes_sent += strlen($chunk);
}
}
} else {
header('HTTP/1.0 204 No Content'); # So that there is no blank page! And we need to visually indicate that the file is missing!
ZM\Error($this->filenamePath.' does not exist or is not readable.');
}
}
}
if (!(canView('Events') or canView('Snapshots'))) {
$view = 'error';
return;
}
$downloading = new downloadingGeneratedEventFile();
if ( !$downloading->fileType ) {
ZM\Error("No file type given to download.php. Please specify a 'mp4', 'tar' or 'zip' file.");
return;
}
$downloading->download();
?>