Skip to content

Commit

Permalink
[TASK] Add missing configuration options for file download
Browse files Browse the repository at this point in the history
With #90548 the filelist module has been extended
for the possibility to download files and folders.

Since file download might not be enabled for all
users, or should at least be limited to a set of
file extensions, this patch adds the necessary
configuration options.

It's therefore now possible to either specify
allowed / disallowed file extensions or to
completely disable the file download.

In case the download can not be prepared due
to no valid files were found, the user
receives a notification.

Resolves: #95113
Related: #90548
Releases: master
Change-Id: Ifc4b4a5cd4ac8cb49b14e8eb6239ece37596e7e4
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/71013
Tested-by: core-ci <typo3@b13.com>
Tested-by: Jochen <rothjochen@gmail.com>
Tested-by: Benni Mack <benni@typo3.org>
Reviewed-by: Jochen <rothjochen@gmail.com>
Reviewed-by: Benni Mack <benni@typo3.org>
  • Loading branch information
o-ba authored and bmack committed Sep 13, 2021
1 parent 1072c56 commit 1c7d745
Show file tree
Hide file tree
Showing 12 changed files with 166 additions and 32 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ class ContextMenuActions {
URL.revokeObjectURL(downloadUrl);
}
document.body.removeChild(anchorTag);
// Add notification about successful preparation
Notification.success(lll('file_download.success'), '', 2);
}

public static renameFile(table: string, uid: string): void {
Expand Down Expand Up @@ -96,12 +98,19 @@ class ContextMenuActions {
}

public static downloadFolder(table: string, uid: string): void {
// Add notification about the download being prepared
Notification.info(lll('file_download.prepare'), '', 2);
const actionUrl: string = $(this).data('action-url');
(new AjaxRequest(actionUrl)).post({items: [uid]})
.then(async (response): Promise<any> => {
let fileName = response.response.headers.get('Content-Disposition');
if (!fileName) {
Notification.error(lll('file_download.error'));
const data = await response.resolve();
if (data.success === false && data.status) {
Notification.warning(lll('file_download.' + data.status), lll('file_download.' + data.status + '.message'), 10);
} else {
Notification.error(lll('file_download.error'));
}
return;
}
fileName = fileName.substring(fileName.indexOf(' filename=') + 10);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,12 @@ class Filelist {
.then(async (response: AjaxResponse): Promise<any> => {
let fileName = response.response.headers.get('Content-Disposition');
if (!fileName) {
Notification.error(lll('file_download.error'));
const data = await response.resolve();
if (data.success === false && data.status) {
Notification.warning(lll('file_download.' + data.status), lll('file_download.' + data.status + '.message'), 10);
} else {
Notification.error(lll('file_download.error'));
}
return;
}
fileName = fileName.substring(fileName.indexOf(' filename=') + 10);
Expand All @@ -293,6 +298,8 @@ class Filelist {
anchorTag.click();
URL.revokeObjectURL(downloadUrl);
document.body.removeChild(anchorTag);
// Add notification about successful preparation
Notification.success(lll('file_download.success'), '', 2);
})
.catch(() => {
Notification.error(lll('file_download.error'));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,9 @@ public function filterFileList($itemName, $itemIdentifier, $parentIdentifier, ar
*
* @param string $fileExt
* @return bool
* @internal this is used internally for TYPO3 core only
*/
protected function isAllowed($fileExt)
public function isAllowed($fileExt)
{
$fileExt = strtolower($fileExt);
$result = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,24 @@ The "Download" option has furthermore been added to the context menu as
well as the secondary menu. Those options can be used to download
a single file or folder.

Administrators can furthermore specify, which file extensions are allowed
for their users to be downloaded. Therefore, following user TSconfig is
available, expecting a comma-separated list of file extensions:

.. code-block:: typoscript
# Either an allow list
options.file_list.fileDownload.allowedFileExtensions = png,svg,pdf
# or a deny list
options.file_list.fileDownload.disallowedFileExtensions = yaml,exe,html
It's also possible to completely disable the file download for users:

.. code-block:: typoscript
options.file_list.fileDownload.enabled = 0
.. note::

When downloading folders, all readable subfolders and their files
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -685,9 +685,18 @@ Do you want to continue WITHOUT saving?</source>
<trans-unit id="file_download.invalidSelection" resname="file_download.invalidSelection">
<source>The selected elements can not be downloaded</source>
</trans-unit>
<trans-unit id="file_download.success" resname="file_download.success">
<source>File download successfully prepared</source>
</trans-unit>
<trans-unit id="file_download.error" resname="file_download.error">
<source>Could not prepare files for download</source>
</trans-unit>
<trans-unit id="file_download.noFiles" resname="file_download.noFiles">
<source>Cound not find any allowed file</source>
</trans-unit>
<trans-unit id="file_download.noFiles.message" resname="file_download.noFiles">
<source>Please check your selection, since there might be file extension limitations, defined by your administrator.</source>
</trans-unit>
<trans-unit id="online_media.new_media" resname="online_media.new_media">
<source>Add new media asset</source>
</trans-unit>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Resource\Exception\ResourceDoesNotExistException;
use TYPO3\CMS\Core\Resource\File;
use TYPO3\CMS\Core\Resource\Filter\FileExtensionFilter;
use TYPO3\CMS\Core\Resource\Folder;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Type\Bitmask\JsConfirmation;
Expand Down Expand Up @@ -317,7 +318,31 @@ protected function canBePastedInto(): bool

protected function canBeDownloaded(): bool
{
return $this->record->checkActionPermission('read');
if (!$this->record->checkActionPermission('read')) {
// Early return if no read access
return false;
}

$fileDownloadConfiguration = (array)($this->backendUser->getTSConfig()['options.']['file_list.']['fileDownload.'] ?? []);
if (!($fileDownloadConfiguration['enabled'] ?? true)) {
// File download is disabled
return false;
}

if ($fileDownloadConfiguration === [] || $this->isFolder()) {
// In case no configuration exists, or we deal with a folder, download is allowed at this point
return true;
}

// Initialize file extension filter
$filter = GeneralUtility::makeInstance(FileExtensionFilter::class);
$filter->setAllowedFileExtensions(
GeneralUtility::trimExplode(',', (string)($fileDownloadConfiguration['allowedFileExtensions'] ?? ''), true)
);
$filter->setDisallowedFileExtensions(
GeneralUtility::trimExplode(',', (string)($fileDownloadConfiguration['disallowedFileExtensions'] ?? ''), true)
);
return $filter->isAllowed($this->record->getExtension());
}

/**
Expand Down
59 changes: 45 additions & 14 deletions typo3/sysext/filelist/Classes/Controller/FileDownloadController.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,14 @@
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamFactoryInterface;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Http\Stream;
use TYPO3\CMS\Core\Resource\FileInterface;
use TYPO3\CMS\Core\Resource\Filter\FileExtensionFilter;
use TYPO3\CMS\Core\Resource\Folder;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\PathUtility;

/**
Expand All @@ -35,25 +38,46 @@ class FileDownloadController
{
protected ResourceFactory $resourceFactory;
protected ResponseFactoryInterface $responseFactory;
protected StreamFactoryInterface $streamFactory;
protected Context $context;

public function __construct(
ResourceFactory $resourceFactory,
ResponseFactoryInterface $responseFactory,
StreamFactoryInterface $streamFactory,
Context $context
) {
$this->resourceFactory = $resourceFactory;
$this->responseFactory = $responseFactory;
$this->streamFactory = $streamFactory;
$this->context = $context;
}

public function handleRequest(ServerRequestInterface $request): ResponseInterface
{
$items = (array)($request->getParsedBody()['items'] ?? []);
if ($items === []) {
// Return in case no items are given
return $this->responseFactory->createResponse(400);
}

$fileExtensionFilter = null;
$fileDownloadConfiguration = (array)($this->getBackendUser()->getTSConfig()['options.']['file_list.']['fileDownload.'] ?? []);
if ($fileDownloadConfiguration !== []) {
if (!($fileDownloadConfiguration['enabled'] ?? true)) {
// Return if file download is disabled
return $this->responseFactory->createResponse(403);
}
// Initialize file extension filter, if configured
$fileExtensionFilter = GeneralUtility::makeInstance(FileExtensionFilter::class);
$fileExtensionFilter->setAllowedFileExtensions(
GeneralUtility::trimExplode(',', (string)($fileDownloadConfiguration['allowedFileExtensions'] ?? ''), true)
);
$fileExtensionFilter->setDisallowedFileExtensions(
GeneralUtility::trimExplode(',', (string)($fileDownloadConfiguration['disallowedFileExtensions'] ?? ''), true)
);
}

$zipStream = tmpfile();
if (!is_resource($zipStream)) {
throw new \RuntimeException('Could not open temporary resource for creating archive', 1630346631);
Expand All @@ -63,35 +87,37 @@ public function handleRequest(ServerRequestInterface $request): ResponseInterfac
$zipFile->open($zipFileName, \ZipArchive::OVERWRITE);
$filesAdded = 0;
foreach ($this->collectFiles($items) as $fileName => $fileObject) {
// Add files with read permission
if (!$fileObject->getStorage()->checkFileActionPermission('read', $fileObject)) {
// Add files with read permission and allowed file extension
if (!$fileObject->getStorage()->checkFileActionPermission('read', $fileObject)
|| ($fileExtensionFilter !== null && !$fileExtensionFilter->isAllowed($fileObject->getExtension()))
) {
continue;
}
$filesAdded++;
$zipFile->addFile($fileObject->getForLocalProcessing(false), $fileName);
}
if ($filesAdded === 0) {
$zipFile->addFromString('No files found.txt', 'No files found to create a zip file');
}
$zipFile->close();
$response = $this->createResponse($zipFileName);
$response = $this->createResponse($zipFileName, $filesAdded);
unlink($zipFileName);
return $response;
}

protected function createResponse($temporaryFileName): ResponseInterface
protected function createResponse(string $temporaryFileName, int $filesAdded): ResponseInterface
{
if ($filesAdded === 0) {
return $this->responseFactory->createResponse()
->withHeader('Content-Type', 'application/json; charset=utf-8')
->withBody($this->streamFactory->createStream(json_encode(['success' => false, 'status' => 'noFiles'])));
}

$downloadFileName = 'typo3_download_' . $this->context->getAspect('date')->getDateTime()->format('Y-m-d-His') . '.zip';
$response = $this->responseFactory
->createResponse()
return $this->responseFactory->createResponse()
->withHeader('Content-Type', 'application/zip')
->withHeader('Content-Disposition', 'attachment; filename=' . $downloadFileName)
->withHeader('Content-Transfer-Encoding', 'binary')
->withHeader('Pragma', 'no-cache')
->withHeader('Cache-Control', 'public, must-revalidate');
$body = new Stream('php://temp', 'rw');
$body->write(file_get_contents($temporaryFileName));
return $response->withBody($body);
->withHeader('Cache-Control', 'public, must-revalidate')
->withBody($this->streamFactory->createStreamFromFile($temporaryFileName));
}

/**
Expand Down Expand Up @@ -129,4 +155,9 @@ protected function getFilesAndFoldersRecursive(Folder $folder): iterable
yield $file;
}
}

protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
17 changes: 12 additions & 5 deletions typo3/sysext/filelist/Classes/Controller/FileListController.php
Original file line number Diff line number Diff line change
Expand Up @@ -409,13 +409,20 @@ protected function generateFileList(): void
'title' => $lang->sL('LLL:EXT:filelist/Resources/Private/Language/locallang_mod_file_list.xlf:clip_deleteMarked'),
'content' => $lang->sL('LLL:EXT:filelist/Resources/Private/Language/locallang_mod_file_list.xlf:clip_deleteMarkedWarning')
], true),
'downloadActionConfiguration' => GeneralUtility::jsonEncodeForHtmlAttribute([
'fileIdentifier' => 'fileUid',
'folderIdentifier' => 'combinedIdentifier',
'downloadUrl' => (string)$this->uriBuilder->buildUriFromRoute('file_download')
], true),
]);

// Add download button configuration, if file download is enabled
if ($this->getBackendUser()->getTSConfig()['options.']['file_list.']['fileDownload.']['enabled'] ?? true) {
$this->view->assign(
'downloadActionConfiguration',
GeneralUtility::jsonEncodeForHtmlAttribute([
'fileIdentifier' => 'fileUid',
'folderIdentifier' => 'combinedIdentifier',
'downloadUrl' => (string)$this->uriBuilder->buildUriFromRoute('file_download')
], true)
);
}

// Add column selector information if enabled
if ($this->getBackendUser()->getTSConfig()['options.']['file_list.']['displayColumnSelector'] ?? true) {
$this->view->assign('columnSelector', [
Expand Down
31 changes: 28 additions & 3 deletions typo3/sysext/filelist/Classes/FileList.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
use TYPO3\CMS\Core\Resource\Exception\ResourceDoesNotExistException;
use TYPO3\CMS\Core\Resource\File;
use TYPO3\CMS\Core\Resource\FileInterface;
use TYPO3\CMS\Core\Resource\Filter\FileExtensionFilter;
use TYPO3\CMS\Core\Resource\Folder;
use TYPO3\CMS\Core\Resource\FolderInterface;
use TYPO3\CMS\Core\Resource\InaccessibleFolder;
Expand Down Expand Up @@ -179,6 +180,7 @@ class FileList
protected $uriBuilder;

protected ?FileSearchDemand $searchDemand = null;
protected ?FileExtensionFilter $fileExtensionFilter = null;

/**
* A runtime first-level cache to avoid unneeded calls to BackendUtility::getRecord()
Expand Down Expand Up @@ -207,6 +209,17 @@ public function __construct(?ServerRequestInterface $request = null)
$this->getLanguageService()->includeLLFile('EXT:core/Resources/Private/Language/locallang_common.xlf');
$this->uriBuilder = GeneralUtility::makeInstance(UriBuilder::class);
$this->spaceIcon = '<span class="btn btn-default disabled">' . $this->iconFactory->getIcon('empty-empty', Icon::SIZE_SMALL)->render() . '</span>';
// Initialize file extension filter, if configured
$fileDownloadConfiguration = (array)($this->getBackendUser()->getTSConfig()['options.']['file_list.']['fileDownload.'] ?? []);
if ($fileDownloadConfiguration !== []) {
$this->fileExtensionFilter = GeneralUtility::makeInstance(FileExtensionFilter::class);
$this->fileExtensionFilter->setAllowedFileExtensions(
GeneralUtility::trimExplode(',', (string)($fileDownloadConfiguration['allowedFileExtensions'] ?? ''), true)
);
$this->fileExtensionFilter->setDisallowedFileExtensions(
GeneralUtility::trimExplode(',', (string)($fileDownloadConfiguration['disallowedFileExtensions'] ?? ''), true)
);
}
}

/**
Expand Down Expand Up @@ -990,14 +1003,16 @@ public function makeEdit($fileOrFolderObject)
}

// file download
if ($fileOrFolderObject->checkActionPermission('read')) {
if ($fileOrFolderObject instanceof File) {
if ($fileOrFolderObject->checkActionPermission('read') && $this->fileDownloadEnabled()) {
if ($fileOrFolderObject instanceof File
&& ($this->fileExtensionFilter === null || $this->fileExtensionFilter->isAllowed($fileOrFolderObject->getExtension()))
) {
$fileUrl = $fileOrFolderObject->getPublicUrl();
if ($fileUrl) {
$cells['download'] = '<a href="' . htmlspecialchars($fileUrl) . '" download="' . htmlspecialchars($fileOrFolderObject->getName()) . '" class="btn btn-default" title="' . htmlspecialchars($this->getLanguageService()->sL('LLL:EXT:filelist/Resources/Private/Language/locallang.xlf:download')) . '">' . $this->iconFactory->getIcon('actions-download', Icon::SIZE_SMALL)->render() . '</a>';
}
// Folder download
} elseif ($fileOrFolderObject instanceof FolderInterface) {
} elseif ($fileOrFolderObject instanceof Folder) {
$cells['download'] = '<button type="button" data-folder-download="' . htmlspecialchars($this->uriBuilder->buildUriFromRoute('file_download')) . '" data-folder-identifier="' . htmlspecialchars($fileOrFolderObject->getCombinedIdentifier()) . '" class="btn btn-default" title="' . htmlspecialchars($this->getLanguageService()->sL('LLL:EXT:filelist/Resources/Private/Language/locallang.xlf:download')) . '">' . $this->iconFactory->getIcon('actions-download', Icon::SIZE_SMALL)->render() . '</button>';
}
}
Expand Down Expand Up @@ -1413,6 +1428,16 @@ protected function getConcreteTableName(string $fieldName): string
return ($GLOBALS['TCA']['sys_file']['columns'][$fieldName] ?? false) ? 'sys_file' : 'sys_file_metadata';
}

/**
* Whether file download is enabled for the user
*
* @return bool
*/
protected function fileDownloadEnabled(): bool
{
return (bool)($this->getBackendUser()->getTSConfig()['options.']['file_list.']['fileDownload.']['enabled'] ?? true);
}

/**
* Returns an instance of LanguageService
*
Expand Down
10 changes: 6 additions & 4 deletions typo3/sysext/filelist/Resources/Private/Templates/File/List.html
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,15 @@ <h1>
</span>
</button>
</div>
<div class="col">
<button type="button" class="btn btn-default btn-sm" data-multi-record-selection-action="download" data-multi-record-selection-action-config="{downloadActionConfiguration -> f:format.raw()}">
<f:if condition="{downloadActionConfiguration}">
<div class="col">
<button type="button" class="btn btn-default btn-sm" data-multi-record-selection-action="download" data-multi-record-selection-action-config="{downloadActionConfiguration -> f:format.raw()}">
<span title="{f:translate(key: 'LLL:EXT:filelist/Resources/Private/Language/locallang.xlf:download')}">
<core:icon identifier="actions-download" size="small" /> <f:translate key="LLL:EXT:filelist/Resources/Private/Language/locallang.xlf:download" />
</span>
</button>
</div>
</button>
</div>
</f:if>
<f:if condition="{enableClipBoard.enabled}">
<div class="col">
<button type="button" class="btn btn-default btn-sm {f:if(condition: '{enableClipBoard.mode} == normal', then: 'disabled')}" data-multi-record-selection-action="setCB">
Expand Down
Loading

0 comments on commit 1c7d745

Please sign in to comment.