<?php
declare(strict_types=1);
/*
* This file is part of Contao.
*
* (c) Leo Feyer
*
* @license LGPL-3.0-or-later
*/
namespace Contao\CoreBundle\Image\Studio;
use Contao\CoreBundle\Event\FileMetadataEvent;
use Contao\CoreBundle\Exception\InvalidResourceException;
use Contao\CoreBundle\File\Metadata;
use Contao\CoreBundle\Framework\Adapter;
use Contao\CoreBundle\Util\LocaleUtil;
use Contao\FilesModel;
use Contao\Image\ImageInterface;
use Contao\Image\PictureConfiguration;
use Contao\Image\ResizeOptions;
use Contao\PageModel;
use Contao\StringUtil;
use Contao\Validator;
use Nyholm\Psr7\Uri;
use Psr\Container\ContainerInterface;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Filesystem\Path;
/**
* Use the FigureBuilder class to create Figure result objects. The class
* has a fluent interface to configure the desired output. When you are ready,
* call build() to get a Figure. If you need another instance with similar
* settings, you can alter values and call build() again - it will not affect
* your first instance.
*/
class FigureBuilder
{
private ContainerInterface $locator;
private string $projectDir;
private string $uploadPath;
private string $webDir;
private Filesystem $filesystem;
private ?InvalidResourceException $lastException = null;
/**
* @var array<string>
*/
private array $validExtensions;
/**
* The resource's absolute file path.
*/
private ?string $filePath = null;
/**
* The resource's file model if applicable.
*/
private ?FilesModel $filesModel = null;
/**
* User defined size configuration.
*
* @phpcsSuppress SlevomatCodingStandard.Classes.UnusedPrivateElements
*
* @var int|string|array|PictureConfiguration|null
*/
private $sizeConfiguration;
/**
* User defined resize options.
*
* @phpcsSuppress SlevomatCodingStandard.Classes.UnusedPrivateElements
*/
private ?ResizeOptions $resizeOptions = null;
/**
* User defined custom locale. This will overwrite the default if set.
*/
private ?string $locale = null;
/**
* User defined metadata. This will overwrite the default if set.
*/
private ?Metadata $metadata = null;
/**
* User defined metadata. This will be added to the default if set.
*/
private ?Metadata $overwriteMetadata = null;
/**
* Determines if a metadata should never be present in the output.
*/
private ?bool $disableMetadata = null;
/**
* User defined link attributes. These will add to or overwrite the default values.
*
* @var array<string, string|null>
*/
private array $additionalLinkAttributes = [];
/**
* User defined lightbox resource or url. This will overwrite the default if set.
*
* @var string|ImageInterface|null
*/
private $lightboxResourceOrUrl;
/**
* User defined lightbox size configuration. This will overwrite the default if set.
*
* @var int|string|array|PictureConfiguration|null
*/
private $lightboxSizeConfiguration;
/**
* User defined lightbox resize options.
*/
private ?ResizeOptions $lightboxResizeOptions = null;
/**
* User defined lightbox group identifier. This will overwrite the default if set.
*/
private ?string $lightboxGroupIdentifier = null;
/**
* Determines if a lightbox (or "fullsize") image should be created.
*/
private ?bool $enableLightbox = null;
/**
* User defined template options.
*
* @phpcsSuppress SlevomatCodingStandard.Classes.UnusedPrivateElements
*
* @var array<string, mixed>
*/
private array $options = [];
/**
* @internal Use the Contao\CoreBundle\Image\Studio\Studio factory to get an instance of this class
*/
public function __construct(ContainerInterface $locator, string $projectDir, string $uploadPath, string $webDir, array $validExtensions)
{
$this->locator = $locator;
$this->projectDir = $projectDir;
$this->uploadPath = $uploadPath;
$this->webDir = $webDir;
$this->validExtensions = $validExtensions;
$this->filesystem = new Filesystem();
}
/**
* Sets the image resource from a FilesModel.
*/
public function fromFilesModel(FilesModel $filesModel): self
{
$this->lastException = null;
if ('file' !== $filesModel->type) {
$this->lastException = new InvalidResourceException(sprintf('DBAFS item "%s" is not a file.', $filesModel->path));
return $this;
}
$this->filePath = Path::makeAbsolute($filesModel->path, $this->projectDir);
$this->filesModel = $filesModel;
if (!$this->filesystem->exists($this->filePath)) {
$this->lastException = new InvalidResourceException(sprintf('No resource could be located at path "%s".', $this->filePath));
}
return $this;
}
/**
* Sets the image resource from a tl_files UUID.
*/
public function fromUuid(string $uuid): self
{
$this->lastException = null;
$filesModel = $this->getFilesModelAdapter()->findByUuid($uuid);
if (null === $filesModel) {
$this->lastException = new InvalidResourceException(sprintf('DBAFS item with UUID "%s" could not be found.', $uuid));
return $this;
}
return $this->fromFilesModel($filesModel);
}
/**
* Sets the image resource from a tl_files ID.
*/
public function fromId(int $id): self
{
$this->lastException = null;
/** @var FilesModel|null $filesModel */
$filesModel = $this->getFilesModelAdapter()->findByPk($id);
if (null === $filesModel) {
$this->lastException = new InvalidResourceException(sprintf('DBAFS item with ID "%s" could not be found.', $id));
return $this;
}
return $this->fromFilesModel($filesModel);
}
/**
* Sets the image resource from an absolute or relative path.
*
* @param bool $autoDetectDbafsPaths Set to false to skip searching for a FilesModel
*/
public function fromPath(string $path, bool $autoDetectDbafsPaths = true): self
{
$this->lastException = null;
// Make sure path is absolute and in a canonical form
$path = Path::isAbsolute($path) ? Path::canonicalize($path) : Path::makeAbsolute($path, $this->projectDir);
// Only check for a FilesModel if the resource is inside the upload path
$getDbafsPath = function (string $path): ?string {
if (Path::isBasePath(Path::join($this->webDir, $this->uploadPath), $path)) {
return Path::makeRelative($path, $this->webDir);
}
if (Path::isBasePath(Path::join($this->projectDir, $this->uploadPath), $path)) {
return $path;
}
return null;
};
if ($autoDetectDbafsPaths && null !== ($dbafsPath = $getDbafsPath($path))) {
$filesModel = $this->getFilesModelAdapter()->findByPath($dbafsPath);
if (null !== $filesModel) {
return $this->fromFilesModel($filesModel);
}
}
$this->filePath = $path;
$this->filesModel = null;
if (!$this->filesystem->exists($this->filePath)) {
$this->lastException = new InvalidResourceException(sprintf('No resource could be located at path "%s".', $this->filePath));
}
return $this;
}
/**
* Sets the image resource from an absolute or relative URL.
*
* @param list<string> $baseUrls a list of allowed base URLs, the first match gets stripped from the resource URL
*/
public function fromUrl(string $url, array $baseUrls = []): self
{
$this->lastException = null;
$uri = new Uri($url);
$path = null;
foreach ($baseUrls as $baseUrl) {
$baseUri = new Uri($baseUrl);
if ($baseUri->getHost() === $uri->getHost() && Path::isBasePath($baseUri->getPath(), $uri->getPath())) {
$path = Path::makeRelative($uri->getPath(), $baseUri->getPath().'/');
break;
}
}
if (null === $path) {
if ('' !== $uri->getHost()) {
$this->lastException = new InvalidResourceException(sprintf('Resource URL "%s" outside of base URLs "%s".', $url, implode('", "', $baseUrls)));
return $this;
}
$path = $uri->getPath();
}
if (preg_match('/%2f|%5c/i', $path)) {
$this->lastException = new InvalidResourceException(sprintf('Resource URL path "%s" contains invalid percent encoding.', $path));
return $this;
}
// Prepend the web_dir (see #6123)
return $this->fromPath(Path::join($this->webDir, urldecode($path)));
}
/**
* Sets the image resource from an ImageInterface.
*/
public function fromImage(ImageInterface $image): self
{
return $this->fromPath($image->getPath());
}
/**
* Sets the image resource by guessing the identifier type.
*
* @param int|string|FilesModel|ImageInterface|null $identifier Can be a FilesModel, an ImageInterface, a tl_files UUID/ID/path or a file system path
*/
public function from($identifier): self
{
if (null === $identifier) {
$this->lastException = new InvalidResourceException('The defined resource is "null".');
return $this;
}
if ($identifier instanceof FilesModel) {
return $this->fromFilesModel($identifier);
}
if ($identifier instanceof ImageInterface) {
return $this->fromImage($identifier);
}
$isString = \is_string($identifier);
if ($isString && $this->getValidatorAdapter()->isUuid($identifier)) {
return $this->fromUuid($identifier);
}
if (is_numeric($identifier)) {
return $this->fromId((int) $identifier);
}
if ($isString) {
return $this->fromPath($identifier);
}
$type = \is_object($identifier) ? \get_class($identifier) : \gettype($identifier);
throw new \TypeError(sprintf('%s(): Argument #1 ($identifier) must be of type FilesModel|ImageInterface|string|int|null, %s given', __METHOD__, $type));
}
/**
* Sets a size configuration that will be applied to the resource.
*
* @param int|string|array|PictureConfiguration|null $size A picture size configuration or reference
*/
public function setSize($size): self
{
$this->sizeConfiguration = $size;
return $this;
}
/**
* Sets resize options.
*
* By default, or if the argument is set to null, resize options are derived
* from predefined image sizes.
*/
public function setResizeOptions(?ResizeOptions $resizeOptions): self
{
$this->resizeOptions = $resizeOptions;
return $this;
}
/**
* Sets custom metadata.
*
* By default, or if the argument is set to null, metadata is trying to be
* pulled from the FilesModel.
*/
public function setMetadata(?Metadata $metadata): self
{
$this->metadata = $metadata;
return $this;
}
/**
* Sets custom overwrite metadata.
*
* The metadata will be merged with the default metadata from the FilesModel.
*/
public function setOverwriteMetadata(?Metadata $metadata): self
{
$this->overwriteMetadata = $metadata;
return $this;
}
/**
* Disables creating/using metadata in the output even if it is present.
*/
public function disableMetadata(bool $disable = true): self
{
$this->disableMetadata = $disable;
return $this;
}
/**
* Sets a custom locale.
*
* By default, or if the argument is set to null, the locale is determined
* from the request context and/or system settings.
*/
public function setLocale(?string $locale): self
{
$this->locale = $locale;
return $this;
}
/**
* Adds a custom link attribute.
*
* Set the value to null to remove it. If you want to explicitly remove an
* auto-generated value from the results, set the $forceRemove flag to true.
*/
public function setLinkAttribute(string $attribute, ?string $value, bool $forceRemove = false): self
{
if (null !== $value || $forceRemove) {
$this->additionalLinkAttributes[$attribute] = $value;
} else {
unset($this->additionalLinkAttributes[$attribute]);
}
return $this;
}
/**
* Sets all custom link attributes as an associative array.
*
* This will overwrite previously set attributes. If you want to explicitly
* remove an auto-generated value from the results, set the respective
* attribute to null.
*/
public function setLinkAttributes(array $attributes): self
{
foreach ($attributes as $key => $value) {
if (!\is_string($key) || !\is_string($value)) {
throw new \InvalidArgumentException('Link attributes must be an array of type <string, string>.');
}
}
$this->additionalLinkAttributes = $attributes;
return $this;
}
/**
* Sets the link href attribute.
*
* Set the value to null to use the auto-generated default.
*/
public function setLinkHref(?string $url): self
{
$this->setLinkAttribute('href', $url);
return $this;
}
/**
* Sets a custom lightbox resource (file path or ImageInterface) or URL.
*
* By default, or if the argument is set to null, the image/target will be
* automatically determined from the metadata or base resource. For this
* setting to take effect, make sure you have enabled the creation of a
* lightbox by calling enableLightbox().
*
* @param string|ImageInterface|null $resourceOrUrl
*/
public function setLightboxResourceOrUrl($resourceOrUrl): self
{
$this->lightboxResourceOrUrl = $resourceOrUrl;
return $this;
}
/**
* Sets a size configuration that will be applied to the lightbox image.
*
* For this setting to take effect, make sure you have enabled the creation
* of a lightbox by calling enableLightbox().
*
* @param int|string|array|PictureConfiguration|null $size A picture size configuration or reference
*/
public function setLightboxSize($size): self
{
$this->lightboxSizeConfiguration = $size;
return $this;
}
/**
* Sets resize options for the lightbox image.
*
* By default, or if the argument is set to null, resize options are derived
* from predefined image sizes.
*/
public function setLightboxResizeOptions(?ResizeOptions $resizeOptions): self
{
$this->lightboxResizeOptions = $resizeOptions;
return $this;
}
/**
* Sets a custom lightbox group ID.
*
* By default, or if the argument is set to null, the ID will be empty. For
* this setting to take effect, make sure you have enabled the creation of
* a lightbox by calling enableLightbox().
*/
public function setLightboxGroupIdentifier(?string $identifier): self
{
$this->lightboxGroupIdentifier = $identifier;
return $this;
}
/**
* Enables the creation of a lightbox image (if possible) and/or
* outputting the respective link attributes.
*
* This setting is disabled by default.
*/
public function enableLightbox(bool $enable = true): self
{
$this->enableLightbox = $enable;
return $this;
}
/**
* Sets all template options as an associative array.
*/
public function setOptions(array $options): self
{
$this->options = $options;
return $this;
}
/**
* Returns the last InvalidResourceException that was captured when setting
* resources or null if there was none.
*/
public function getLastException(): ?InvalidResourceException
{
return $this->lastException;
}
/**
* @internal
*/
public function setLastException(InvalidResourceException $exception): self
{
$this->lastException = $exception;
return $this;
}
/**
* Creates a result object with the current settings, throws an exception
* if the currently defined resource is invalid.
*
* @throws InvalidResourceException
*/
public function build(): Figure
{
if (null !== $this->lastException) {
throw $this->lastException;
}
return $this->doBuild();
}
/**
* Creates a result object with the current settings, returns null if the
* currently defined resource is invalid.
*/
public function buildIfResourceExists(): ?Figure
{
if (null !== $this->lastException) {
return null;
}
$figure = $this->doBuild();
try {
// Make sure the resource can be processed
$figure->getImage()->getOriginalDimensions();
} catch (\Throwable $e) {
$this->lastException = new InvalidResourceException(sprintf('The file "%s" could not be opened as an image.', $this->filePath), 0, $e);
return null;
}
return $figure;
}
/**
* Creates a result object with the current settings.
*/
private function doBuild(): Figure
{
if (null === $this->filePath) {
throw new \LogicException('You need to set a resource before building the result.');
}
// Freeze settings to allow reusing this builder object
$settings = clone $this;
$imageResult = $this->locator
->get('contao.image.studio')
->createImage($settings->filePath, $settings->sizeConfiguration, $settings->resizeOptions)
;
// Define the values via closure to make their evaluation lazy
return new Figure(
$imageResult,
\Closure::bind(
function (Figure $figure): ?Metadata {
$event = new FileMetadataEvent($this->onDefineMetadata());
$this->locator->get('event_dispatcher')->dispatch($event);
return $event->getMetadata();
},
$settings
),
\Closure::bind(
fn (Figure $figure): array => $this->onDefineLinkAttributes($figure),
$settings
),
\Closure::bind(
fn (Figure $figure): ?LightboxResult => $this->onDefineLightboxResult($figure),
$settings
),
$settings->options
);
}
/**
* Defines metadata on demand.
*/
private function onDefineMetadata(): ?Metadata
{
if ($this->disableMetadata) {
return null;
}
$getUuid = static function (?FilesModel $filesModel): ?string {
if (null === $filesModel || null === $filesModel->uuid) {
return null;
}
// Normalize UUID to ASCII format
return Validator::isBinaryUuid($filesModel->uuid)
? StringUtil::binToUuid($filesModel->uuid)
: $filesModel->uuid;
};
$fileReferenceData = array_filter([Metadata::VALUE_UUID => $getUuid($this->filesModel)]);
if (null !== $this->metadata) {
return $this->metadata->with($fileReferenceData);
}
if (null === $this->filesModel) {
return null;
}
// Get fallback locale list or use without fallbacks if explicitly set
$locales = null !== $this->locale ? [$this->locale] : $this->getFallbackLocaleList();
$metadata = $this->filesModel->getMetadata(...$locales);
$overwriteMetadata = $this->overwriteMetadata ? $this->overwriteMetadata->all() : [];
if (null !== $metadata) {
return $metadata
->with($fileReferenceData)
->with($overwriteMetadata)
;
}
// If no metadata can be obtained from the model, we create a container
// from the default meta fields with empty values instead
$metaFields = $this->getFilesModelAdapter()->getMetaFields();
$data = array_merge(
array_combine($metaFields, array_fill(0, \count($metaFields), '')),
$fileReferenceData
);
return (new Metadata($data))->with($overwriteMetadata);
}
/**
* Defines link attributes on demand.
*/
private function onDefineLinkAttributes(Figure $result): array
{
$linkAttributes = [];
// Open in a new window if lightbox was requested but is invalid (fullsize)
if ($this->enableLightbox && !$result->hasLightbox()) {
$linkAttributes['target'] = '_blank';
}
return array_merge($linkAttributes, $this->additionalLinkAttributes);
}
/**
* Defines the lightbox result (if enabled) on demand.
*/
private function onDefineLightboxResult(Figure $result): ?LightboxResult
{
if (!$this->enableLightbox) {
return null;
}
$getMetadataUrl = static function () use ($result): ?string {
if (!$result->hasMetadata()) {
return null;
}
return $result->getMetadata()->getUrl() ?: null;
};
/**
* @param ImageInterface|string $target Image object, URL or absolute file path
*/
$getResourceOrUrl = function ($target): array {
if ($target instanceof ImageInterface) {
return [$target, null];
}
$validExtension = \in_array(Path::getExtension($target, true), $this->validExtensions, true);
$externalUrl = 1 === preg_match('#^https?://#', $target);
if (!$validExtension) {
return [null, null];
}
if ($externalUrl) {
return [null, $target];
}
if (Path::isAbsolute($target)) {
$filePath = Path::canonicalize($target);
} else {
// URL relative to the project directory
$filePath = Path::makeAbsolute(urldecode($target), $this->projectDir);
}
if (!is_file($filePath)) {
$filePath = null;
}
return [$filePath, null];
};
// Use explicitly set href (1) or lightbox resource (2), fall back to using metadata (3) or use the base resource (4) if empty
$lightboxResourceOrUrl = $this->additionalLinkAttributes['href'] ?? $this->lightboxResourceOrUrl ?? $getMetadataUrl() ?? $this->filePath;
[$filePathOrImage, $url] = $getResourceOrUrl($lightboxResourceOrUrl);
if (null === $filePathOrImage && null === $url) {
return null;
}
return $this->locator
->get('contao.image.studio')
->createLightboxImage(
$filePathOrImage,
$url,
$this->lightboxSizeConfiguration,
$this->lightboxGroupIdentifier,
$this->lightboxResizeOptions
)
;
}
/**
* @return FilesModel
*
* @phpstan-return Adapter<FilesModel>
*/
private function getFilesModelAdapter(): Adapter
{
$framework = $this->locator->get('contao.framework');
$framework->initialize();
return $framework->getAdapter(FilesModel::class);
}
/**
* @return Validator
*
* @phpstan-return Adapter<Validator>
*/
private function getValidatorAdapter(): Adapter
{
$framework = $this->locator->get('contao.framework');
$framework->initialize();
return $framework->getAdapter(Validator::class);
}
/**
* Returns a list of locales (if available) in the following order:
* 1. language of current page,
* 2. root page fallback language.
*/
private function getFallbackLocaleList(): array
{
$page = $GLOBALS['objPage'] ?? null;
if (!$page instanceof PageModel) {
return [];
}
$locales = [LocaleUtil::formatAsLocale($page->language)];
if (null !== $page->rootFallbackLanguage) {
$locales[] = LocaleUtil::formatAsLocale($page->rootFallbackLanguage);
}
return array_unique(array_filter($locales));
}
}