foldershare-8.x-1.2/src/Controller/FolderShareDownload.php
src/Controller/FolderShareDownload.php
<?php
namespace Drupal\foldershare\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Component\Utility\Unicode;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException;
use Drupal\foldershare\ManageLog;
use Drupal\foldershare\Settings;
use Drupal\foldershare\Utilities\FileUtilities;
use Drupal\foldershare\Entity\FolderShare;
use Drupal\foldershare\Entity\Exception\LockException;
use Drupal\foldershare\Entity\Exception\SystemException;
/**
* Defines a class to handle downloading one or more FolderShare entities.
*
* <B>Warning:</B> This class is strictly internal to the FolderShare
* module. The class's existance, name, and content may change from
* release to release without any promise of backwards compatability.
*
* This class handles translating stored FolderShare content into a byte
* stream to download to a browser via HTTP. It handles two cases:
*
* - Download a single FolderShare entity for a file.
*
* - Download one or more FolderShare entities for files and folders.
*
* In both cases, FolderShare entities for files wrap File entities, which
* in turn reference stored files. For security reasons, those files are
* stored with numeric file names and no extensions. To send them to a browser,
* these files have to be processed to send their human-readable names,
* file extensions, and MIME types so that a browser or client OS knows what
* to do with them.
*
* When downloading a single FolderShare entity for a file, the file's data
* is retrieved and sent to the browser as a file attachment.
*
* When downloading a single FolderShare entity for a folder, or multiple
* FolderShare entities, the entities are compressed into a temporary ZIP
* archive, and that archive's data sent to the browser as a file
* attachment.
*
* @ingroup foldershare
*/
class FolderShareDownload extends ControllerBase {
/*--------------------------------------------------------------------
*
* Constants.
*
*--------------------------------------------------------------------*/
/**
* The name of the ZIP archive downloaded for groups of entities.
*
* @var string
*/
const DOWNLOAD_NAME = 'Download.zip';
/*--------------------------------------------------------------------
*
* Fields.
*
*--------------------------------------------------------------------*/
/**
* The MIME type guesser.
*
* @var \Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface
*/
private $mimeTypeGuesser;
/*--------------------------------------------------------------------
*
* Construction.
*
*--------------------------------------------------------------------*/
/**
* Constructs a new form.
*
* @param \Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface $mimeTypeGuesser
* The MIME type guesser.
*/
public function __construct(
MimeTypeGuesserInterface $mimeTypeGuesser) {
$this->mimeTypeGuesser = $mimeTypeGuesser;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('file.mime_type.guesser')
);
}
/*--------------------------------------------------------------------
*
* Download.
*
*--------------------------------------------------------------------*/
/**
* Downloads the file by transfering the file in binary.
*
* The file is sent with a custom HTTP header that includes the full
* human-readable name of the file and its MIME type. If the $style argument
* is "show", the file is sent so that a browser may display the file
* directly. If the $style argument is "download" (the default), the file
* is sent with a special HTTP header to encourage the browser to save
* the file instead of displaying it.
*
* <B>Post-operation hooks</B>
* This method calls the "hook_foldershare_post_operation_download" hook
* for each item downloaded.
*
* <B>Activity log</B>
* If the site hs enabled logging of operations, this method posts a
* log message for each item downloaded.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object that contains the entity ID of the
* file being requested. The entity ID is included in the URL
* for links to the file.
* @param string $encoded
* A string containing a comma-separated list of entity IDs.
* NOTE: Because this function is the target of a route with a string
* argument, the name of the function argument here *must be* named
* after the argument name: 'encoded'.
*
* @return \Symfony\Component\HttpFoundation\BinaryFileResponse
* A binary transfer response is returned to send the file to the
* user's browser.
*
* @throws \Symfony\Component\HttpKernel\Exxception\AccessDeniedHttpException
* Thrown when the user does not have access to the entities.
*
* @throws \Symfony\Component\HttpKernel\Exxception\NotFoundHttpException
* Thrown if the URL argument is empty or malformed, if any entity ID
* in that encoded argument is invalid, if the entities don't all have
* the same parent, if the file's those entities refer to cannot be
* found, or if a ZIP archive of those entities could not be created.
*
* @todo Support media entity download.
*
* @todo Support large ZIP files that may take longer to create than the
* PHP or web server time limits allow.
*/
public function download(
Request $request,
string $encoded = NULL) {
//
// Validate arguments
// ------------------
// Decode the argument an array of FolderShare entity IDs.
if (empty($encoded) === TRUE) {
throw new BadRequestHttpException($this->t(
"Malformed request. Nothing selected to download."));
}
$entityIds = explode(',', $encoded);
if (empty($entityIds) === TRUE) {
throw new BadRequestHttpException($this->t(
"Malformed request. Nothing selected to download."));
}
// Load all of those entities and make sure the user has access
// permission.
$entities = FolderShare::loadMultiple($entityIds);
foreach ($entities as $entityId => $entity) {
if ($entity === NULL) {
throw new NotFoundHttpException($this->t(
"The entity with ID @id could not be found to download.",
[
'@id' => $entityId,
]));
}
if ($entity->isSystemHidden() === TRUE) {
// Hidden items do not exist.
throw new NotFoundHttpException(
FolderShare::getStandardHiddenMessage($entity->getName()));
}
if ($entity->isSystemDisabled() === TRUE) {
// Disabled items cannot be edited.
throw new ConflictHttpException(
FolderShare::getStandardDisabledMessage('downloaded', $entity->getName()));
}
if ($entity->access('view') === FALSE) {
throw new AccessDeniedHttpException($this->t(
"You do not have permission to download '@name'.",
[
'@name' => $entity->getName(),
]));
}
}
//
// Prepare to download
// -------------------
// Get the file to download.
//
// Note that downloading Media objects is not supported.
$entity = reset($entities);
$zipFirst = FALSE;
if (count($entities) !== 1 || $entity->isFolder() === TRUE) {
// There is more than one entity to download, or there is just one
// entity but it is a folder. ZIP them together.
$zipFirst = TRUE;
}
elseif (count($entities) === 1) {
// There is just one entity to download. If it is a file or image,
// we can download it directly.
if ($entity->isFile() === TRUE || $entity->isImage() === TRUE) {
// Download a single file.
//
// Get the file's URI, human-readable name, MIME type, and size.
if ($entity->isFile() === TRUE) {
$file = $entity->getFile();
}
else {
$file = $entity->getImage();
}
$uri = $file->getFileUri();
$filename = $file->getFilename();
$mimeType = Unicode::mimeHeaderEncode($file->getMimeType());
$realPath = FileUtilities::realpath($uri);
if ($realPath === FALSE || file_exists($realPath) === FALSE) {
throw new NotFoundHttpException($this->t(
"The file '@name' (ID '@id') could not be found to download.",
[
'@name' => $file->getFilename(),
'@id' => $file->id(),
]));
}
$filesize = FileUtilities::filesize($realPath);
}
elseif ($entity->isMedia() === TRUE) {
// Media entities are currently not supported for download.
//
// @todo Support media entity download.
throw UnsupportedMediaTypeHttpException($this->t(
"The media item '@name' currently does not support downloading.",
[
'@name' => $entity->getName(),
]));
}
else {
// The entity is of an unknown type.
throw UnsupportedMediaTypeHttpException($this->t(
"The item '@name' does not support downloading.",
[
'@name' => $entity->getName(),
]));
}
}
if ($zipFirst === TRUE) {
// Download multiple files and/or folders. Create a ZIP archive.
try {
// ZIP the entities.
//
// @todo Support (somehow) large ZIP archives that either have a lot
// of entries, or the entries are big. When ZIPs are large, this can
// take a long time and get interrupted by a PHP or web server timeout.
$uri = FolderShare::createZipArchive($entities);
// Use a generic name for the download ZIP.
$filename = self::DOWNLOAD_NAME;
$filesize = FileUtilities::filesize($uri);
$mimeType = Unicode::mimeHeaderEncode(
$this->mimeTypeGuesser->guess($filename));
}
catch (LockException $e) {
throw new ConflictHttpException($e->getMessage());
}
catch (SystemException $e) {
throw new HttpException(
Response::HTTP_INTERNAL_SERVER_ERROR,
$e->getMessage());
}
catch (\Exception $e) {
throw new NotFoundHttpException($this->t(
"The items could not be download."));
}
}
//
// Build header
// ------------
// Build an HTTP header for the file by getting the user-visible
// file name and MIME type. Both of these are essential in the HTTP
// header since they tell the browser what type of file it is getting,
// and the name of the file if the user wants to save it their disk.
$headers = [
// Use the File object's MIME type.
'Content-Type' => $mimeType,
// Use the human-visible file name.
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
// Use the saved file size, in bytes.
'Content-Length' => $filesize,
// Don't cache the file because permissions and content may
// change.
'Pragma' => 'no-cache',
'Cache-Control' => 'must-revalidate, post-check=0, pre-check=0',
'Expires' => '0',
'Accept-Ranges' => 'bytes',
];
$scheme = Settings::getFileScheme();
$isPrivate = ($scheme === 'private');
$requestingUid = (int) $this->currentUser()->id();
foreach ($entities as $entity) {
FolderShare::postOperationHook(
'download',
[
$entity,
$requestingUid,
]);
ManageLog::activity(
"Downloaded @kind '@name' (# @id).",
[
'@id' => $entity->id(),
'@kind' => $entity->getKind(),
'@name' => $entity->getName(),
'entity' => $entity,
'uid' => $requestingUid,
]);
}
//
// Respond
// -------
// \Drupal\Core\EventSubscriber\FinishResponseSubscriber::onRespond()
// sets response as not cacheable if the Cache-Control header is not
// already modified. We pass in FALSE for non-private schemes for the
// $public parameter to make sure we don't change the headers.
return new BinaryFileResponse($uri, 200, $headers, !$isPrivate);
}
}
