Skip to content
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"twig/string-extra": "^3.0"
},
"require-dev": {
"ext-zip": "*",
"dama/doctrine-test-bundle": "^v6.7",
"ibexa/ci-scripts": "^0.2@dev",
"ibexa/behat": "~4.6.0@dev",
Expand Down
320 changes: 320 additions & 0 deletions src/bundle/Controller/DownloadImageController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,320 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\Bundle\AdminUi\Controller;

use DateTimeImmutable;
use Ibexa\Contracts\Core\Repository\SearchService;
use Ibexa\Contracts\Core\Repository\Values\Content\Content;
use Ibexa\Contracts\Core\Repository\Values\Content\Query;
use Ibexa\Contracts\Core\Repository\Values\Content\Search\SearchResult;
use Ibexa\Core\FieldType\Image\Value;
use Ibexa\Rest\Server\Controller;
use Ibexa\User\UserSetting\DateTimeFormat\FormatterInterface;
use RuntimeException;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Response;
use ZipArchive;

final class DownloadImageController extends Controller
{
private const EXTENSION_ZIP = '.zip';

private const ARCHIVE_NAME_PATTERN = 'images_%s' . self::EXTENSION_ZIP;

private int $downloadLimit;

private FormatterInterface $formatter;

/** @var array<string, mixed> */
private array $imageMappings;

private SearchService $searchService;

/**
* @param array<string, mixed> $imageMappings
*/
public function __construct(
int $downloadLimit,
FormatterInterface $formatter,
array $imageMappings,
SearchService $searchService
) {
$this->downloadLimit = $downloadLimit;
$this->formatter = $formatter;
$this->imageMappings = $imageMappings;
$this->searchService = $searchService;
}

/**
* @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidArgumentException
* @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidCriterionArgumentException
* @throws \Exception
*/
public function downloadAction(string $contentIdList): Response
{
$splitContentIdList = array_map(
static fn (string $value): int => (int)$value,
explode(',', $contentIdList)
);

$this->assertDownloadLimitNotExceeded($splitContentIdList);

$images = $this->loadImages($splitContentIdList);
if (0 === $images->totalCount) {
return new Response(
'No results found.',
Response::HTTP_NOT_FOUND
);
}

return $this->processDownloading($images);
}

/**
* @param array<int> $contentIdList
*/
private function assertDownloadLimitNotExceeded(array $contentIdList): void
{
if (count($contentIdList) > $this->downloadLimit) {
throw new RuntimeException(
sprintf(
'Total download limit in one request is %d.',
$this->downloadLimit
)
);
}
}

/**
* @throws \Random\RandomException
* @throws \Exception
*/
private function processDownloading(SearchResult $result): Response
{
if (1 === $result->totalCount) {
return $this->downloadSingleImage(
$result->getIterator()->current()->valueObject
);
}

if (!extension_loaded('zip')) {
throw new RuntimeException(
'ZIP extension is not loaded. Enable the extension or use single download instead.'
);
}

$contentList = [];

/** @var \Ibexa\Contracts\Core\Repository\Values\Content\Search\SearchHit $image */
foreach ($result as $image) {
/** @var \Ibexa\Contracts\Core\Repository\Values\Content\Content $content */
$content = $image->valueObject;
$contentList[] = $content;
}

return $this->downloadArchiveWithImages($contentList);
}

/**
* @throws \Random\RandomException
*/
private function downloadSingleImage(Content $content): Response
{
$value = $this->getImageValue($content);
$uri = $this->getImageUri($value);

$content = file_get_contents($uri);
if (false === $content) {
throw new RuntimeException(
sprintf(
'Failed to read data from "%s"',
$uri
)
);
}

$response = $this->createResponse(
$content,
$this->getImageFileName($value)
);

$response->headers->set('Content-Type', $value->mime);

return $response;
}

/**
* @param array<\Ibexa\Contracts\Core\Repository\Values\Content\Content> $contentList
*
* @throws \Random\RandomException
*/
private function downloadArchiveWithImages(array $contentList): Response
{
$archiveName = sprintf(
self::ARCHIVE_NAME_PATTERN,
$this->generateRandomFileName()
);

$this->createArchive($archiveName, $contentList);

$content = file_get_contents($archiveName);
if (false === $content) {
throw new RuntimeException('Failed to read archive with images.');
}

$fileName = $this->formatter->format(new DateTimeImmutable()) . self::EXTENSION_ZIP;
$response = $this->createResponse($content, $fileName);
$response->headers->set('Content-Type', 'application/zip');

unlink($archiveName);

return $response;
}

private function getImageValue(Content $content): Value
{
$imageFieldIdentifier = $this->getImageFieldIdentifier($content->getContentType()->identifier);
$value = $content->getFieldValue($imageFieldIdentifier);

if (null === $value) {
throw new RuntimeException(
sprintf(
'Missing field with identifier: "%s"',
$imageFieldIdentifier
)
);
}

if (!$value instanceof Value) {
throw new RuntimeException(
sprintf(
'Field value should be of type %s. "%s" given.',
Value::class,
get_debug_type($value)
)
);
}

return $value;
}

private function getImageFieldIdentifier(string $contentTypeIdentifier): string
{
$imageFieldIdentifier = $this->imageMappings[$contentTypeIdentifier]['imageFieldIdentifier'];
if (null === $imageFieldIdentifier) {
throw new RuntimeException(
sprintf(
'Missing key imageFieldIdentifier for content type mapping "%s".',
$contentTypeIdentifier
)
);
}

return $imageFieldIdentifier;
}

private function getImageUri(Value $value): string
{
$uri = $value->uri;
if (null === $uri) {
throw new RuntimeException('Missing image uri');
}

return ltrim($uri, '/');
}

/**
* @throws \Random\RandomException
*/
private function getImageFileName(Value $value): string
{
return $value->fileName ?? 'image_' . $this->generateRandomFileName();
}

/**
* @param array<int> $contentIdList
*
* @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidArgumentException;
* @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidCriterionArgumentException;
*/
private function loadImages(array $contentIdList): SearchResult
{
$query = new Query();
$query->filter = new Query\Criterion\LogicalAnd(
[
new Query\Criterion\ContentId($contentIdList),
new Query\Criterion\ContentTypeIdentifier($this->getContentTypeIdentifiers()),
]
);
$query->limit = $this->downloadLimit;

return $this->searchService->findContent($query, [], false);
}

/**
* @return array<string>
*/
private function getContentTypeIdentifiers(): array
{
$contentTypeIdentifiers = [];
foreach ($this->imageMappings as $contentTypeIdentifier => $mapping) {
$contentTypeIdentifiers[] = $contentTypeIdentifier;
}

return $contentTypeIdentifiers;
}

private function createResponse(
string $content,
string $fileName
): Response {
$disposition = HeaderUtils::makeDisposition(
HeaderUtils::DISPOSITION_ATTACHMENT,
$fileName
);

return new Response(
$content,
200,
[
'Content-Disposition' => $disposition,
'Content-Length' => strlen($content),
]
);
}

/**
* @param array<\Ibexa\Contracts\Core\Repository\Values\Content\Content> $contentList
*
* @throws \Random\RandomException
*/
private function createArchive(string $name, array $contentList): void
{
$zipArchive = new ZipArchive();
$zipArchive->open($name, ZipArchive::CREATE);

foreach ($contentList as $content) {
$value = $this->getImageValue($content);
$zipArchive->addFile(
$this->getImageUri($value),
$this->getImageFileName($value)
);
}

$zipArchive->close();
}

/**
* @throws \Random\RandomException
*/
private function generateRandomFileName(): string
{
return bin2hex(random_bytes(12));
}
}
2 changes: 2 additions & 0 deletions src/bundle/Resources/config/default_parameters.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,6 @@ parameters:
contentTypeIdentifier: image
fieldDefinitionIdentifier: tags

ibexa.dam_widget.image.download_limit: 25

ibexa.dam_widget.folder.content_type_identifier: folder
7 changes: 7 additions & 0 deletions src/bundle/Resources/config/routing_rest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,10 @@ ibexa.rest.application_config:
methods: [GET]
options:
expose: true

ibexa.rest.image.download:
path: /image/download/{contentIdList}
controller: 'Ibexa\Bundle\AdminUi\Controller\DownloadImageController::downloadAction'
methods: GET
requirements:
contentIdList: '^\d+(,\d+)*$'
8 changes: 8 additions & 0 deletions src/bundle/Resources/config/services/controllers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -244,3 +244,11 @@ services:
autowire: true
tags:
- controller.service_arguments

Ibexa\Bundle\AdminUi\Controller\DownloadImageController:
arguments:
$downloadLimit: '%ibexa.dam_widget.image.download_limit%'
$formatter: '@ibexa.user.settings.full_datetime_format.formatter'
$imageMappings: '%ibexa.dam_widget.image.mappings%'
tags:
- controller.service_arguments
2 changes: 2 additions & 0 deletions src/lib/UI/Config/Provider/Module/DamWidget.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
* contentTypeIdentifiers: array<string>,
* aggregations: array<string, array<string, string>>,
* showImageFilters: bool,
* enableMultipleDownload: bool,
* mappings: array<
* string,
* array{
Expand Down Expand Up @@ -100,6 +101,7 @@ private function getImageConfig(): array
$imageConfig = [
'showImageFilters' => $this->showImageFilters(),
'aggregations' => $this->config['image']['aggregations'],
'enableMultipleDownload' => extension_loaded('zip'),
];

$mappings = [];
Expand Down
1 change: 1 addition & 0 deletions tests/lib/UI/Config/Provider/Module/DamWidgetTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ private function getExpectedConfig(bool $showImageFilters): array
],
'aggregations' => self::IMAGE_AGGREGATIONS,
'showImageFilters' => $showImageFilters,
'enableMultipleDownload' => true,
'mappings' => self::IMAGE_MAPPINGS,
],
'folder' => [
Expand Down