123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674 |
- <?php
- /**
- * @package Grav\Common\Media
- *
- * @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved.
- * @license MIT License; see LICENSE file for details.
- */
- namespace Grav\Common\Media\Traits;
- use Exception;
- use Grav\Common\Config\Config;
- use Grav\Common\Filesystem\Folder;
- use Grav\Common\Grav;
- use Grav\Common\Language\Language;
- use Grav\Common\Page\Medium\Medium;
- use Grav\Common\Page\Medium\MediumFactory;
- use Grav\Common\Security;
- use Grav\Common\Utils;
- use Grav\Framework\Filesystem\Filesystem;
- use Grav\Framework\Form\FormFlashFile;
- use Grav\Framework\Mime\MimeTypes;
- use Psr\Http\Message\UploadedFileInterface;
- use RocketTheme\Toolbox\File\YamlFile;
- use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
- use RuntimeException;
- use function dirname;
- use function in_array;
- /**
- * Implements media upload and delete functionality.
- */
- trait MediaUploadTrait
- {
- /** @var array */
- private $_upload_defaults = [
- 'self' => true, // Whether path is in the media collection path itself.
- 'avoid_overwriting' => false, // Do not override existing files (adds datetime postfix if conflict).
- 'random_name' => false, // True if name needs to be randomized.
- 'accept' => ['image/*'], // Accepted mime types or file extensions.
- 'limit' => 10, // Maximum number of files.
- 'filesize' => null, // Maximum filesize in MB.
- 'destination' => null // Destination path, if empty, exception is thrown.
- ];
- /**
- * Create Medium from an uploaded file.
- *
- * @param UploadedFileInterface $uploadedFile
- * @param array $params
- * @return Medium|null
- */
- public function createFromUploadedFile(UploadedFileInterface $uploadedFile, array $params = [])
- {
- return MediumFactory::fromUploadedFile($uploadedFile, $params);
- }
- /**
- * Checks that uploaded file meets the requirements. Returns new filename.
- *
- * @example
- * $filename = null; // Override filename if needed (ignored if randomizing filenames).
- * $settings = ['destination' => 'user://pages/media']; // Settings from the form field.
- * $filename = $media->checkUploadedFile($uploadedFile, $filename, $settings);
- * $media->copyUploadedFile($uploadedFile, $filename);
- *
- * @param UploadedFileInterface $uploadedFile
- * @param string|null $filename
- * @param array|null $settings
- * @return string
- * @throws RuntimeException
- */
- public function checkUploadedFile(UploadedFileInterface $uploadedFile, string $filename = null, array $settings = null): string
- {
- // Check if there is an upload error.
- switch ($uploadedFile->getError()) {
- case UPLOAD_ERR_OK:
- break;
- case UPLOAD_ERR_INI_SIZE:
- case UPLOAD_ERR_FORM_SIZE:
- throw new RuntimeException($this->translate('PLUGIN_ADMIN.EXCEEDED_FILESIZE_LIMIT'), 400);
- case UPLOAD_ERR_PARTIAL:
- case UPLOAD_ERR_NO_FILE:
- if (!$uploadedFile instanceof FormFlashFile) {
- throw new RuntimeException($this->translate('PLUGIN_ADMIN.NO_FILES_SENT'), 400);
- }
- break;
- case UPLOAD_ERR_NO_TMP_DIR:
- throw new RuntimeException($this->translate('PLUGIN_ADMIN.UPLOAD_ERR_NO_TMP_DIR'), 400);
- case UPLOAD_ERR_CANT_WRITE:
- case UPLOAD_ERR_EXTENSION:
- default:
- throw new RuntimeException($this->translate('PLUGIN_ADMIN.UNKNOWN_ERRORS'), 400);
- }
- $metadata = [
- 'filename' => $uploadedFile->getClientFilename(),
- 'mime' => $uploadedFile->getClientMediaType(),
- 'size' => $uploadedFile->getSize(),
- ];
- return $this->checkFileMetadata($metadata, $filename, $settings);
- }
- /**
- * Checks that file metadata meets the requirements. Returns new filename.
- *
- * @param array $metadata
- * @param array|null $settings
- * @return string|null
- * @throws RuntimeException
- */
- public function checkFileMetadata(array $metadata, string $filename = null, array $settings = null): string
- {
- // Add the defaults to the settings.
- $settings = $this->getUploadSettings($settings);
- // Destination is always needed (but it can be set in defaults).
- $self = $settings['self'] ?? false;
- if (!isset($settings['destination']) && $self === false) {
- throw new RuntimeException($this->translate('PLUGIN_ADMIN.DESTINATION_NOT_SPECIFIED'), 400);
- }
- if (null === $filename) {
- // If no filename is given, use the filename from the uploaded file (path is not allowed).
- $folder = '';
- $filename = $metadata['filename'] ?? '';
- } else {
- // If caller sets the filename, we will accept any custom path.
- $folder = dirname($filename);
- if ($folder === '.') {
- $folder = '';
- }
- $filename = basename($filename);
- }
- $extension = pathinfo($filename, PATHINFO_EXTENSION);
- // Decide which filename to use.
- if ($settings['random_name']) {
- // Generate random filename if asked for.
- $filename = mb_strtolower(Utils::generateRandomString(15) . '.' . $extension);
- }
- // Handle conflicting filename if needed.
- if ($settings['avoid_overwriting']) {
- $destination = $settings['destination'];
- if ($destination && $this->fileExists($filename, $destination)) {
- $filename = date('YmdHis') . '-' . $filename;
- }
- }
- $filepath = $folder . $filename;
- // Check if the filename is allowed.
- if (!Utils::checkFilename($filename)) {
- throw new RuntimeException(
- sprintf($this->translate('PLUGIN_ADMIN.FILEUPLOAD_UNABLE_TO_UPLOAD'), $filepath, $this->translate('PLUGIN_ADMIN.BAD_FILENAME'))
- );
- }
- // Check if the file extension is allowed.
- $extension = mb_strtolower($extension);
- if (!$extension || !$this->getConfig()->get("media.types.{$extension}")) {
- // Not a supported type.
- throw new RuntimeException($this->translate('PLUGIN_ADMIN.UNSUPPORTED_FILE_TYPE') . ': ' . $extension, 400);
- }
- // Calculate maximum file size (from MB).
- $filesize = $settings['filesize'];
- if ($filesize) {
- $max_filesize = $filesize * 1048576;
- if ($metadata['size'] > $max_filesize) {
- // TODO: use own language string
- throw new RuntimeException($this->translate('PLUGIN_ADMIN.EXCEEDED_GRAV_FILESIZE_LIMIT'), 400);
- }
- } elseif (null === $filesize) {
- // Check size against the Grav upload limit.
- $grav_limit = Utils::getUploadLimit();
- if ($grav_limit > 0 && $metadata['size'] > $grav_limit) {
- throw new RuntimeException($this->translate('PLUGIN_ADMIN.EXCEEDED_GRAV_FILESIZE_LIMIT'), 400);
- }
- }
- $grav = Grav::instance();
- /** @var MimeTypes $mimeChecker */
- $mimeChecker = $grav['mime'];
- // Handle Accepted file types. Accept can only be mime types (image/png | image/*) or file extensions (.pdf | .jpg)
- // Do not trust mime type sent by the browser.
- $mime = $metadata['mime'] ?? $mimeChecker->getMimeType($extension);
- $validExtensions = $mimeChecker->getExtensions($mime);
- if (!in_array($extension, $validExtensions, true)) {
- throw new RuntimeException('The mime type does not match to file extension', 400);
- }
- $accepted = false;
- $errors = [];
- foreach ((array)$settings['accept'] as $type) {
- // Force acceptance of any file when star notation
- if ($type === '*') {
- $accepted = true;
- break;
- }
- $isMime = strstr($type, '/');
- $find = str_replace(['.', '*', '+'], ['\.', '.*', '\+'], $type);
- if ($isMime) {
- $match = preg_match('#' . $find . '$#', $mime);
- if (!$match) {
- // TODO: translate
- $errors[] = 'The MIME type "' . $mime . '" for the file "' . $filepath . '" is not an accepted.';
- } else {
- $accepted = true;
- break;
- }
- } else {
- $match = preg_match('#' . $find . '$#', $filename);
- if (!$match) {
- // TODO: translate
- $errors[] = 'The File Extension for the file "' . $filepath . '" is not an accepted.';
- } else {
- $accepted = true;
- break;
- }
- }
- }
- if (!$accepted) {
- throw new RuntimeException(implode('<br />', $errors), 400);
- }
- return $filepath;
- }
- /**
- * Copy uploaded file to the media collection.
- *
- * WARNING: Always check uploaded file before copying it!
- *
- * @example
- * $settings = ['destination' => 'user://pages/media']; // Settings from the form field.
- * $filename = $media->checkUploadedFile($uploadedFile, $filename, $settings);
- * $media->copyUploadedFile($uploadedFile, $filename, $settings);
- *
- * @param UploadedFileInterface $uploadedFile
- * @param string $filename
- * @param array|null $settings
- * @return void
- * @throws RuntimeException
- */
- public function copyUploadedFile(UploadedFileInterface $uploadedFile, string $filename, array $settings = null): void
- {
- // Add the defaults to the settings.
- $settings = $this->getUploadSettings($settings);
- $path = $settings['destination'] ?? $this->getPath();
- if (!$path || !$filename) {
- throw new RuntimeException($this->translate('PLUGIN_ADMIN.FAILED_TO_MOVE_UPLOADED_FILE'), 400);
- }
- /** @var UniformResourceLocator $locator */
- $locator = $this->getGrav()['locator'];
- try {
- // Clear locator cache to make sure we have up to date information from the filesystem.
- $locator->clearCache();
- $this->clearCache();
- $filesystem = Filesystem::getInstance(false);
- // Calculate path without the retina scaling factor.
- $basename = $filesystem->basename($filename);
- $pathname = $filesystem->pathname($filename);
- // Get name for the uploaded file.
- [$base, $ext,,] = $this->getFileParts($basename);
- $name = "{$pathname}{$base}.{$ext}";
- // Upload file.
- if ($uploadedFile instanceof FormFlashFile) {
- // FormFlashFile needs some additional logic.
- if ($uploadedFile->getError() === \UPLOAD_ERR_OK) {
- // Move uploaded file.
- $this->doMoveUploadedFile($uploadedFile, $filename, $path);
- } elseif (strpos($filename, 'original/') === 0 && !$this->fileExists($filename, $path) && $this->fileExists($basename, $path)) {
- // Original image support: override original image if it's the same as the uploaded image.
- $this->doCopy($basename, $filename, $path);
- }
- // FormFlashFile may also contain metadata.
- $metadata = $uploadedFile->getMetaData();
- if ($metadata) {
- // TODO: This overrides metadata if used with multiple retina image sizes.
- $this->doSaveMetadata(['upload' => $metadata], $name, $path);
- }
- } else {
- // Not a FormFlashFile.
- $this->doMoveUploadedFile($uploadedFile, $filename, $path);
- }
- // Post-processing: Special content sanitization for SVG.
- $mime = Utils::getMimeByFilename($filename);
- if (Utils::contains($mime, 'svg', false)) {
- $this->doSanitizeSvg($filename, $path);
- }
- // Add the new file into the media.
- // TODO: This overrides existing media sizes if used with multiple retina image sizes.
- $this->doAddUploadedMedium($name, $filename, $path);
- } catch (Exception $e) {
- throw new RuntimeException($this->translate('PLUGIN_ADMIN.FAILED_TO_MOVE_UPLOADED_FILE') . $e->getMessage(), 400);
- } finally {
- // Finally clear media cache.
- $locator->clearCache();
- $this->clearCache();
- }
- }
- /**
- * Delete real file from the media collection.
- *
- * @param string $filename
- * @param array|null $settings
- * @return void
- * @throws RuntimeException
- */
- public function deleteFile(string $filename, array $settings = null): void
- {
- // Add the defaults to the settings.
- $settings = $this->getUploadSettings($settings);
- $filesystem = Filesystem::getInstance(false);
- // First check for allowed filename.
- $basename = $filesystem->basename($filename);
- if (!Utils::checkFilename($basename)) {
- throw new RuntimeException($this->translate('PLUGIN_ADMIN.FILE_COULD_NOT_BE_DELETED') . ": {$this->translate('PLUGIN_ADMIN.BAD_FILENAME')}: " . $filename, 400);
- }
- $path = $settings['destination'] ?? $this->getPath();
- if (!$path) {
- return;
- }
- /** @var UniformResourceLocator $locator */
- $locator = $this->getGrav()['locator'];
- $locator->clearCache();
- $pathname = $filesystem->pathname($filename);
- // Get base name of the file.
- [$base, $ext,,] = $this->getFileParts($basename);
- $name = "{$pathname}{$base}.{$ext}";
- // Remove file and all all the associated metadata.
- $this->doRemove($name, $path);
- // Finally clear media cache.
- $locator->clearCache();
- $this->clearCache();
- }
- /**
- * Rename file inside the media collection.
- *
- * @param string $from
- * @param string $to
- * @param array|null $settings
- */
- public function renameFile(string $from, string $to, array $settings = null): void
- {
- // Add the defaults to the settings.
- $settings = $this->getUploadSettings($settings);
- $filesystem = Filesystem::getInstance(false);
- $path = $settings['destination'] ?? $this->getPath();
- if (!$path) {
- // TODO: translate error message
- throw new RuntimeException('Failed to rename file: Bad destination', 400);
- }
- /** @var UniformResourceLocator $locator */
- $locator = $this->getGrav()['locator'];
- $locator->clearCache();
- // Get base name of the file.
- $pathname = $filesystem->pathname($from);
- // Remove @2x, @3x and .meta.yaml
- [$base, $ext,,] = $this->getFileParts($filesystem->basename($from));
- $from = "{$pathname}{$base}.{$ext}";
- [$base, $ext,,] = $this->getFileParts($filesystem->basename($to));
- $to = "{$pathname}{$base}.{$ext}";
- $this->doRename($from, $to, $path);
- // Finally clear media cache.
- $locator->clearCache();
- $this->clearCache();
- }
- /**
- * Internal logic to move uploaded file.
- *
- * @param UploadedFileInterface $uploadedFile
- * @param string $filename
- * @param string $path
- */
- protected function doMoveUploadedFile(UploadedFileInterface $uploadedFile, string $filename, string $path): void
- {
- $filepath = sprintf('%s/%s', $path, $filename);
- /** @var UniformResourceLocator $locator */
- $locator = $this->getGrav()['locator'];
- // Do not use streams internally.
- if ($locator->isStream($filepath)) {
- $filepath = (string)$locator->findResource($filepath, true, true);
- }
- Folder::create(dirname($filepath));
- $uploadedFile->moveTo($filepath);
- }
- /**
- * Get upload settings.
- *
- * @param array|null $settings Form field specific settings (override).
- * @return array
- */
- public function getUploadSettings(?array $settings = null): array
- {
- return null !== $settings ? $settings + $this->_upload_defaults : $this->_upload_defaults;
- }
- /**
- * Internal logic to copy file.
- *
- * @param string $src
- * @param string $dst
- * @param string $path
- */
- protected function doCopy(string $src, string $dst, string $path): void
- {
- $src = sprintf('%s/%s', $path, $src);
- $dst = sprintf('%s/%s', $path, $dst);
- /** @var UniformResourceLocator $locator */
- $locator = $this->getGrav()['locator'];
- // Do not use streams internally.
- if ($locator->isStream($dst)) {
- $dst = (string)$locator->findResource($dst, true, true);
- }
- Folder::create(dirname($dst));
- copy($src, $dst);
- }
- /**
- * Internal logic to rename file.
- *
- * @param string $from
- * @param string $to
- * @param string $path
- */
- protected function doRename(string $from, string $to, string $path): void
- {
- /** @var UniformResourceLocator $locator */
- $locator = $this->getGrav()['locator'];
- $fromPath = $path . '/' . $from;
- if ($locator->isStream($fromPath)) {
- $fromPath = $locator->findResource($fromPath, true, true);
- }
- if (!is_file($fromPath)) {
- return;
- }
- $mediaPath = dirname($fromPath);
- $toPath = $mediaPath . '/' . $to;
- if ($locator->isStream($toPath)) {
- $toPath = $locator->findResource($toPath, true, true);
- }
- if (is_file($toPath)) {
- // TODO: translate error message
- throw new RuntimeException(sprintf('File could not be renamed: %s already exists (%s)', $to, $mediaPath), 500);
- }
- $result = rename($fromPath, $toPath);
- if (!$result) {
- // TODO: translate error message
- throw new RuntimeException(sprintf('File could not be renamed: %s -> %s (%s)', $from, $to, $mediaPath), 500);
- }
- // TODO: Add missing logic to handle retina files.
- if (is_file($fromPath . '.meta.yaml')) {
- $result = rename($fromPath . '.meta.yaml', $toPath . '.meta.yaml');
- if (!$result) {
- // TODO: translate error message
- throw new RuntimeException(sprintf('Meta could not be renamed: %s -> %s (%s)', $from, $to, $mediaPath), 500);
- }
- }
- }
- /**
- * Internal logic to remove file.
- *
- * @param string $filename
- * @param string $path
- */
- protected function doRemove(string $filename, string $path): void
- {
- $filesystem = Filesystem::getInstance(false);
- /** @var UniformResourceLocator $locator */
- $locator = $this->getGrav()['locator'];
- // If path doesn't exist, there's nothing to do.
- $pathname = $filesystem->pathname($filename);
- if (!$this->fileExists($pathname, $path)) {
- return;
- }
- $folder = $locator->isStream($path) ? (string)$locator->findResource($path, true, true) : $path;
- // Remove requested media file.
- if ($this->fileExists($filename, $path)) {
- $result = unlink("{$folder}/{$filename}");
- if (!$result) {
- throw new RuntimeException($this->translate('PLUGIN_ADMIN.FILE_COULD_NOT_BE_DELETED') . ': ' . $filename, 500);
- }
- }
- // Remove associated metadata.
- $this->doRemoveMetadata($filename, $path);
- // Remove associated 2x, 3x and their .meta.yaml files.
- $targetPath = rtrim(sprintf('%s/%s', $folder, $pathname), '/');
- $dir = scandir($targetPath, SCANDIR_SORT_NONE);
- if (false === $dir) {
- throw new RuntimeException($this->translate('PLUGIN_ADMIN.FILE_COULD_NOT_BE_DELETED') . ': ' . $filename, 500);
- }
- /** @var UniformResourceLocator $locator */
- $locator = $this->getGrav()['locator'];
- $basename = $filesystem->basename($filename);
- $fileParts = (array)$filesystem->pathinfo($filename);
- foreach ($dir as $file) {
- $preg_name = preg_quote($fileParts['filename'], '`');
- $preg_ext = preg_quote($fileParts['extension'] ?? '.', '`');
- $preg_filename = preg_quote($basename, '`');
- if (preg_match("`({$preg_name}@\d+x\.{$preg_ext}(?:\.meta\.yaml)?$|{$preg_filename}\.meta\.yaml)$`", $file)) {
- $testPath = $targetPath . '/' . $file;
- if ($locator->isStream($testPath)) {
- $testPath = (string)$locator->findResource($testPath, true, true);
- $locator->clearCache($testPath);
- }
- if (is_file($testPath)) {
- $result = unlink($testPath);
- if (!$result) {
- throw new RuntimeException($this->translate('PLUGIN_ADMIN.FILE_COULD_NOT_BE_DELETED') . ': ' . $filename, 500);
- }
- }
- }
- }
- }
- /**
- * @param array $metadata
- * @param string $filename
- * @param string $path
- */
- protected function doSaveMetadata(array $metadata, string $filename, string $path): void
- {
- $filepath = sprintf('%s/%s', $path, $filename);
- /** @var UniformResourceLocator $locator */
- $locator = $this->getGrav()['locator'];
- // Do not use streams internally.
- if ($locator->isStream($filepath)) {
- $filepath = (string)$locator->findResource($filepath, true, true);
- }
- $file = YamlFile::instance($filepath . '.meta.yaml');
- $file->save($metadata);
- }
- /**
- * @param string $filename
- * @param string $path
- */
- protected function doRemoveMetadata(string $filename, string $path): void
- {
- $filepath = sprintf('%s/%s', $path, $filename);
- /** @var UniformResourceLocator $locator */
- $locator = $this->getGrav()['locator'];
- // Do not use streams internally.
- if ($locator->isStream($filepath)) {
- $filepath = (string)$locator->findResource($filepath, true);
- if (!$filepath) {
- return;
- }
- }
- $file = YamlFile::instance($filepath . '.meta.yaml');
- if ($file->exists()) {
- $file->delete();
- }
- }
- /**
- * @param string $filename
- * @param string $path
- */
- protected function doSanitizeSvg(string $filename, string $path): void
- {
- $filepath = sprintf('%s/%s', $path, $filename);
- /** @var UniformResourceLocator $locator */
- $locator = $this->getGrav()['locator'];
- // Do not use streams internally.
- if ($locator->isStream($filepath)) {
- $filepath = (string)$locator->findResource($filepath, true, true);
- }
- Security::sanitizeSVG($filepath);
- }
- /**
- * @param string $name
- * @param string $filename
- * @param string $path
- */
- protected function doAddUploadedMedium(string $name, string $filename, string $path): void
- {
- $filepath = sprintf('%s/%s', $path, $filename);
- $medium = $this->createFromFile($filepath);
- $realpath = $path . '/' . $name;
- $this->add($realpath, $medium);
- }
- /**
- * @param string $string
- * @return string
- */
- protected function translate(string $string): string
- {
- return $this->getLanguage()->translate($string);
- }
- abstract protected function getPath(): ?string;
- abstract protected function getGrav(): Grav;
- abstract protected function getConfig(): Config;
- abstract protected function getLanguage(): Language;
- abstract protected function clearCache(): void;
- }
|