canView()) { global $user; ZM\Warning('User '.($user?$user['Username']:'').' cannot view event '.$event->Id()); continue; } if (!isset($events_by_monitor_id[$event->MonitorId()])) $events_by_monitor_id[$event->MonitorId()] = []; $events_by_monitor_id[$event->MonitorId()][] = $event; if (!$event->DefaultVideo()) $event->GenerateVideo(); } $exportFileList = []; $archive_path = ''; if ($exportFormat == 'tar' || $exportFormat == 'zip') { $archiveFileName = $export_root.'.'.$exportFormat; $archive_path = DIR_EXPORTS_DOWNLOAD.'/'.$archiveFileName; } foreach (array_keys($events_by_monitor_id) as $mid) { $monitor = ZM\Monitor::find_one(['Id'=>$mid]); if (!$monitor) { ZM\Error("No monitor found for id=$mid"); continue; } usort($events_by_monitor_id[$mid], function($a, $b) { return strtotime($a->StartDateTime) <=> strtotime($b->StartDateTime); }); $eventFileList = ''; $minTimeSecs = -1; $minTime = ''; $maxTimeSecs = -1; $maxTime = ''; foreach ($events_by_monitor_id[$mid] as $event) { $filePath = findVideoEventFile($event, "mp4"); if ($filePath ==='') { ZM\Warning('The file path for event '.$event->Id().' was not found.'); continue; } if ($minTimeSecs == -1 or $minTimeSecs > $event->StartDateTimeSecs()) { $minTimeSecs = $event->StartDateTimeSecs(); $minTime = $event->StartDateTime(); } $endSecs = $event->EndDateTimeSecs(); if ($endSecs and ($maxTimeSecs == -1 or $maxTimeSecs < $endSecs)) { $maxTimeSecs = $endSecs; $maxTime = $event->EndDateTime(); } $fileName = basename($filePath); if (strpos($fileName, 'incomplete') !== false && !$endSecs) $maxTime = date('Y-m-d H:i:s'); # Probably incomplete event. $eventFileList .= 'file \''.$event->Path().'/'.$fileName.'\''.PHP_EOL; } if ($eventFileList === '') { ZM\Warning('No event files were found for exporting monitor events with ID='.$mid); continue; } if (!$maxTime) $maxTime = date('Y-m-d H:i:s'); # For example, we download a single non-incomlete event, but it's missing EndDateTimeSecs() due to a crash $mergedFileName = $monitor->Name().' '.$minTime.' to '.$maxTime.'.mp4'; if (($fp = fopen('event_files.txt', 'w'))) { fwrite($fp, $eventFileList); fclose($fp); } else { ZM\Error("Can't open event images export file 'event_files.txt'"); } $cmd = ZM_PATH_FFMPEG.' -f concat -safe 0 -i event_files.txt -c copy '.escapeshellarg($export_dir.'/'.$mergedFileName). ' 2>&1'; exec($cmd, $output, $return); ZM\Debug($cmd.' return code: '.$return.' output: '.print_r($output,true)); $exportFileList[] = $mergedFileName; @unlink('event_files.txt'); # We're sending one file at a time to the archive. This will significantly save disk space. $command = ''; if ($exportFormat == 'tar') { # We can't just create a tar.gz file and add files to it. We first add everything to the 'tar' file, and then to the 'gz' file. $command = 'tar --append --dereference'; if ($exportStructure == 'flat') { $command .= getFlatCommandForTar(); } $command .= ' --file='.escapeshellarg($archive_path); } else if ($exportFormat == 'zip') { $command .= 'zip '; $command .= ($exportStructure == 'flat' ? ' -j ' : '').escapeshellarg($archive_path); $command .= $exportCompressed ? ' -9' : ' -0'; } else if ($exportFormat == 'noArchive') { } if ($command) { $command .= ' -- '.escapeshellarg($mergedFileName); # Name of the file to be added if (executeShelCommand($command, $deleteFile = $mergedFileName) === false) return false; } } # end foreach monitor if (count($exportFileList) === 0) { ZM\Warning('No events were found for export.'); return false; } generateFileList($exportFormat, $exportStructure, $archive_path, $exportCompressed, $export_dir, $export_root, $exportFileList); chdir(DIR_EXPORTS_DOWNLOAD); if ($exportFormat == 'tar') { # Create an archive if necessary //$exportCompressed = true; // For debugging if ($exportCompressed) { $command = 'gzip -- '.escapeshellarg($archive_path); # Name of the file to be archived if (executeShelCommand($command) === false) return false; $archiveFileName .= '.gz'; } } else if ($exportFormat == 'zip') { } else if ($exportFormat == 'noArchive') { } $linkExportFile = []; if ($exportFormat == 'noArchive') { $returnString = []; foreach ($exportFileList as $link) { # The merged name carries spaces, colons and whatever the monitor is # called, so it has to be escaped to survive as a query parameter. $returnString[] = '?view=download&type=mp4&file='.urlencode($link).'&export_root='.urlencode($export_root); } } else { $returnString = '?view=download&type='.$exportFormat.'&file='.urlencode($archiveFileName); } return $returnString; } # end function downloadEvents function generateFileList ($exportFormat, $exportStructure, $archive_path, $exportCompressed, $export_dir, $export_root, $exportFileList) { if ($exportFormat != 'tar' && $exportFormat != 'zip') return false; $export_listFile = 'FileList.txt'; $listFile = $export_dir.'/'.$export_listFile; if (!($fp = fopen($listFile, 'w'))) { ZM\Error("Can't open event export list file '$listFile'"); return false; } foreach ($exportFileList as $exportFile) { $exportFile = $export_root.'/'.$exportFile; fwrite($fp, $exportFile.PHP_EOL); } fwrite($fp, $export_listFile.PHP_EOL); fclose($fp); # Let's add a text file to the archive if ($exportFormat == 'tar') { $command = 'tar --append --dereference'; $command .= getFlatCommandForTar(); # We add one file, which means FLAT $command .= ' --file='.escapeshellarg($archive_path); } else if ($exportFormat == 'zip') { $command = 'zip -j '.escapeshellarg($archive_path); $command .= $exportCompressed ? ' -9' : ' -0'; } $command .= ' '.escapeshellarg($export_listFile); # Name of the file to be added if (executeShelCommand($command, $deleteFile = $export_listFile) === false) return false; # Let's delete the directory, it should already be empty. if (!@rmdir($export_dir)) { ZM\Error("Cannot remove '$export_dir' - directory is not empty"); } } function executeShelCommand($command, $deleteFile = '') { if (!$command) return false; exec($command, $output, $status); ZM\Debug("Executing a command: $command"); $deleteFile = preg_replace('@[/\\\]@', '', $deleteFile); # Let's allow deletion only in the current directory, clear the paths. if ($deleteFile) { if (!@unlink($deleteFile)) {; ZM\Error("Cannot delete file '".DOWNLOAD_DIR_EXPORTS."/$deleteFile'"); } } if ($status) { ZM\Error("Command '$command' returned with status $status"); if (isset($output[0])) { ZM\Error('First line of output is \''.$output[0].'\''); } return false; } return true; } function getFlatCommandForTar() { $version = @shell_exec('tar --version'); ZM\Debug("Version tar=$version"); if (preg_match('/BSD/i', $version)) { $command = ' -s \'#^.*/##\''; } else { $command = ' --xform=\'s#^.+/##x\''; } return $command; } # Build the value of a Content-Disposition header offering $filename as an # attachment. # # The plain filename= parameter is an RFC 6266 quoted-string of ASCII # characters. Merged exports are named ' to .mp4', so they # carry spaces and colons: emitted unquoted, browsers that parse the header # strictly find no usable filename and fall back to naming the download after # the last path segment of the URL, which is index.php. Characters that are not # legal in a Windows filename are folded to '_' so the quoted name is usable # there too, and whenever that folding changed anything the untouched name is # offered alongside it as RFC 5987 filename*, which takes precedence in browsers # that understand it and keeps non-ASCII monitor names intact. function contentDispositionAttachment($filename) { $filename = basename($filename); # The Windows-illegal set - of which '"' and '\' would also terminate or # escape the quoted-string - plus runs of anything outside printable ASCII. # Runs, so that one multi-byte character costs one underscore, not one per # byte. $ascii = preg_replace('/["*\/:<>?\\\\|]|[^\x20-\x7e]+/', '_', $filename); $header = 'attachment; filename="'.$ascii.'"'; if ($ascii !== $filename) { $header .= "; filename*=UTF-8''".rawurlencode($filename); } return $header; }