diff --git a/controllers/Download.php b/controllers/Download.php index b6e2e6c2..b5de5496 100644 --- a/controllers/Download.php +++ b/controllers/Download.php @@ -6,7 +6,7 @@ * Kabuki CMS (C) 2013-2019, Aaron van Geffen *****************************************************************************/ -class Download extends HTMLController +class Download { public function __construct() { @@ -15,64 +15,47 @@ class Download extends HTMLController if (!$user->isLoggedIn()) throw new NotAllowedException(); - if(!isset($_GET['tag'])) - throw new UnexpectedValueException('Must specify an album to download'); + if (!isset($_GET['tag'])) + throw new UserFacingException('No album or tag has been specified for download.'); $tag = (int)$_GET['tag']; $album = Tag::fromId($tag); - if($album->kind !== 'Album') - throw new UnexpectedValueException('Specified tag does not correspond to an album'); + if (isset($_SESSION['current_export'])) + throw new UserFacingException('You can only export one album at the same time. Please wait until the other download finishes, or try again later.'); - //Yes TOCTOU but it does not need to be perfect. - $lock_file = join('/', [sys_get_temp_dir(), 'pics-export.lock']); - if(!file_exists($lock_file)) - { - try - { - $fp = fopen($lock_file, 'x'); - - if(!$fp) - throw new UnexpectedValueException('Could not open lock-file'); - - $this->exportAlbum($album); - } - finally - { - fclose($fp); - unlink($lock_file); - } - } - else - throw new UnexpectedValueException('Another export is busy, please try again later'); - - exit(); + // So far so good? + $this->exportAlbum($album); + exit; } - private function exportAlbum($album) + private function exportAlbum(Tag $album) { $files = []; $album_ids = array_merge([$album->id_tag], $this->getChildAlbumIds($album->id_tag)); - foreach($album_ids as $album_id) + foreach ($album_ids as $album_id) { - $iterator = AssetIterator::getByOptions( - [ - 'id_tag' => $album_id - ] - ); - while($asset = $iterator->Next()) - { - $files[] = join(DIRECTORY_SEPARATOR, ['.', $asset->getSubdir(), $asset->getFilename()]); - } + $iterator = AssetIterator::getByOptions(['id_tag' => $album_id]); + while ($asset = $iterator->next()) + $files[] = join(DIRECTORY_SEPARATOR, [$asset->getSubdir(), $asset->getFilename()]); } $descriptorspec = [ - 0 => ['pipe', 'r'], - 1 => ['pipe', 'w'], + 0 => ['pipe', 'r'], // STDIN + 1 => ['pipe', 'w'], // STDOUT ]; - $command = 'tar --null -cf - -T -'; + // Prevent simultaneous exports. + $_SESSION['current_export'] = $album->id_tag; + + // Allow new exports if the connection is terminated unexpectedly (e.g. when a user aborts a download). + register_shutdown_function(function() { + if (isset($_SESSION['current_export'])) + unset($_SESSION['current_export']); + }); + + $command = 'tar -cf - -C ' . escapeshellarg(ASSETSDIR) . ' --null -T -'; $proc = proc_open($command, $descriptorspec, $pipes, ASSETSDIR); @@ -85,36 +68,53 @@ class Download extends HTMLController if(!$pipes[1]) throw new UnexpectedValueException('Could not open pipe for STDOUT'); - $album_name = $album->tag; + // STDOUT should not block. + stream_set_blocking($pipes[1], 0); header('Pragma: no-cache'); header('Content-Description: File Download'); - header('Content-disposition: attachment; filename="' . $album_name . '.tar"'); + header('Content-disposition: attachment; filename="' . $album->tag . '.tar"'); header('Content-Type: application/octet-stream'); header('Content-Transfer-Encoding: binary'); - foreach($files as $file) + // Write filenames to include to STDIN, separated by null bytes. + foreach ($files as $file) fwrite($pipes[0], $file . "\0"); + // Close STDIN pipe to start archiving. fclose($pipes[0]); - while($chunk = stream_get_contents($pipes[1], 4096)) - echo $chunk; + // At this point, end output buffering so we can enjoy more than ~62MB of photos. + ob_end_flush(); + do + { + // Read STDOUT as `tar` is doing its work. + echo stream_get_contents($pipes[1], 4096); + + // Are we still running? + $status = proc_get_status($proc); + } + while (!empty($status) && $status['running']); + + // Close STDOUT pipe and clean up process. fclose($pipes[1]); proc_close($proc); + + // Allow new exports from this point onward. + unset($_SESSION['current_export']); } private function getChildAlbumIds($parent_id) { $ids = []; - $albums = Tag::getAlbums($parent_id, 0, PHP_INT_MAX, 'object'); - foreach($albums as $album) + $albums = Tag::getAlbums($parent_id, 0, PHP_INT_MAX); + foreach ($albums as $album) { - $ids[] = $album->id_tag; - $ids = array_merge($ids, $this->getChildAlbumIds($album->id_tag)); + $ids[] = $album['id_tag']; + $ids = array_merge($ids, $this->getChildAlbumIds($album['id_tag'])); } return $ids;