diff --git a/system/src/Grav/Common/Flex/Types/Users/UserObject.php b/system/src/Grav/Common/Flex/Types/Users/UserObject.php index 32e321036..e82b15f0a 100644 --- a/system/src/Grav/Common/Flex/Types/Users/UserObject.php +++ b/system/src/Grav/Common/Flex/Types/Users/UserObject.php @@ -662,8 +662,10 @@ class UserObject extends FlexObject implements UserInterface, MediaManipulationI */ protected function setUpdatedMedia(array $files): void { - // For shared media folder we need to keep path for backwards compatibility. - $folder = $this->getMediaFolder(); + /** @var UniformResourceLocator $locator */ + $locator = Grav::instance()['locator']; + + $media = $this->getMedia(); $list = []; $list_original = []; @@ -679,32 +681,35 @@ class UserObject extends FlexObject implements UserInterface, MediaManipulationI $data = null; } - $settings = $this->getBlueprint()->schema()->getProperty($field); + // Load configuration for the field. + $schema = $this->getBlueprint()->schema(); + $settings = $schema ? (array)$schema->getProperty($field) : []; - // Generate random name if required - if ($settings['random_name'] ?? false) { - $extension = pathinfo($filename, PATHINFO_EXTENSION); - $data['name'] = $filename = Utils::generateRandomString(15) . '.' . $extension; + // Set destination folder. + $self = false; + if (empty($settings['destination']) || in_array($settings['destination'], ['@self', 'self@', '@self@'], true)) { + $settings['destination'] = $this->getMediaFolder(); + $self = true; } + $folder = $settings['destination']; - if ($this->_loadMedia) { + // Check file upload against media limits. + $filename = $media->checkUploadedFile($file, $filename, $settings); + + if ($this->_loadMedia && $self) { $filepath = $filename; } else { - if (!$folder) { - throw new \RuntimeException('No media folder support'); - } + $filepath = "{$folder}/{$filename}"; - /** @var UniformResourceLocator $locator */ - $locator = Grav::instance()['locator']; - $filepath = $locator->findResource($folder, false, true) . '/' . $filename; - if ($data) { - $data['path'] = $filepath; + // For backwards compatibility we are always using relative path from the installation root. + if ($locator->isStream($folder)) { + $filepath = $locator->findResource($filepath, false, true); } } // Special handling for original images. if (strpos($field, '/original')) { - if ($this->_loadMedia) { + if ($this->_loadMedia && $self) { $list_original[$filename] = $file; } continue; @@ -712,7 +717,10 @@ class UserObject extends FlexObject implements UserInterface, MediaManipulationI $list[$filename] = $file; - if ($data) { + if (null !== $data) { + $data['name'] = $filename; + $data['path'] = $filepath; + $this->setNestedProperty("{$field}\n{$filepath}", $data, "\n"); } else { $this->unsetNestedProperty("{$field}\n{$filepath}", "\n"); @@ -736,7 +744,7 @@ class UserObject extends FlexObject implements UserInterface, MediaManipulationI foreach ($this->_uploads_original ?? [] as $name => $file) { $name = 'original/' . $name; if ($file) { - $media->uploadFile($file, $name); + $media->copyUploadedFile($file, $name); } else { $media->deleteFile($name); } @@ -748,7 +756,7 @@ class UserObject extends FlexObject implements UserInterface, MediaManipulationI */ foreach ($this->getUpdatedMedia() as $filename => $file) { if ($file) { - $media->uploadFile($file, $filename); + $media->copyUploadedFile($file, $filename); } else { $media->deleteFile($filename); } diff --git a/system/src/Grav/Common/Media/Interfaces/MediaUploadInterface.php b/system/src/Grav/Common/Media/Interfaces/MediaUploadInterface.php index e776e462a..099d88dc0 100644 --- a/system/src/Grav/Common/Media/Interfaces/MediaUploadInterface.php +++ b/system/src/Grav/Common/Media/Interfaces/MediaUploadInterface.php @@ -18,23 +18,43 @@ use RuntimeException; interface MediaUploadInterface { /** + * 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): string; + public function checkUploadedFile(UploadedFileInterface $uploadedFile, string $filename = null, array $settings = null): string; /** - * Upload file to the media collection. + * Copy uploaded file to the media collection. + * + * WARNING: Always check uploaded file before copying it! + * + * @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 string $filename * @return void * @throws RuntimeException */ - public function uploadFile(UploadedFileInterface $uploadedFile, string $filename = null): void; + public function copyUploadedFile(UploadedFileInterface $uploadedFile, string $filename): void; /** + * Delete real file from the media collection. + * * @param string $filename * @return void */ diff --git a/system/src/Grav/Common/Media/Traits/MediaUploadTrait.php b/system/src/Grav/Common/Media/Traits/MediaUploadTrait.php index b5ebd64ec..45e872500 100644 --- a/system/src/Grav/Common/Media/Traits/MediaUploadTrait.php +++ b/system/src/Grav/Common/Media/Traits/MediaUploadTrait.php @@ -13,6 +13,7 @@ use Grav\Common\Config\Config; use Grav\Common\Filesystem\Folder; use Grav\Common\Grav; use Grav\Common\Language\Language; +use Grav\Common\Security; use Grav\Common\Utils; use Grav\Framework\Filesystem\Filesystem; use Grav\Framework\Form\FormFlashFile; @@ -26,35 +27,84 @@ use RuntimeException; */ trait MediaUploadTrait { + /** @var array */ + private $_upload_defaults = [ + '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. + ]; + /** + * 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): string + public function checkUploadedFile(UploadedFileInterface $uploadedFile, string $filename = null, array $settings = null): string { + // Add the defaults to the settings. + if (!$settings) { + $settings = $this->_upload_defaults; + } else { + $settings += $this->_upload_defaults; + } + + // Destination is always needed (but it can be set in defaults). + if (!isset($settings['destination'])) { + throw new RuntimeException($this->translate('PLUGIN_ADMIN.DESTINATION_NOT_SPECIFIED'), 400); + } + // 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_INI_SIZE: - case UPLOAD_ERR_FORM_SIZE: - throw new RuntimeException($this->translate('PLUGIN_ADMIN.EXCEEDED_FILESIZE_LIMIT'), 400); 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); } - if (null === $filename) { + // Decide which filename to use. + if ($settings['random_name']) { + // Generate random filename if asked for. + $extension = pathinfo($filename, PATHINFO_EXTENSION); + $filename = Utils::generateRandomString(15) . '.' . $extension; + } elseif (null === $filename) { + // If no filename is given, use the filename from the uploaded file. $filename = $uploadedFile->getClientFilename() ?? ''; } + // Handle conflicting filename if needed. + if ($settings['avoid_overwriting']) { + $destination = $settings['destination']; + if (file_exists("{$destination}/{$filename}")) { + $filename = date('YmdHis') . '-' . $filename; + } + } + // Check if the filename is allowed. if (!Utils::checkFilename($filename)) { throw new RuntimeException( @@ -63,41 +113,101 @@ trait MediaUploadTrait ); } - // Check size against the upload limit. + // Check if the file extension is allowed. + $extension = mb_strtolower(pathinfo($filename, PATHINFO_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). + if ($settings['filesize']) { + $max_filesize = $settings['filesize'] * 1048576; + if ($uploadedFile->getSize() > $max_filesize) { + // TODO: use own language string + throw new RuntimeException($this->translate('PLUGIN_ADMIN.EXCEEDED_GRAV_FILESIZE_LIMIT'), 400); + } + } + + // Check size against the Grav upload limit. $grav_limit = Utils::getUploadLimit(); if ($grav_limit > 0 && $uploadedFile->getSize() > $grav_limit) { throw new RuntimeException($this->translate('PLUGIN_ADMIN.EXCEEDED_GRAV_FILESIZE_LIMIT'), 400); } - $this->checkFileExtension($filename); + // Handle Accepted file types. Accept can only be mime types (image/png | image/*) or file extensions (.pdf | .jpg) + $accepted = false; + $errors = []; + // Do not trust mime type sent by the browser. + $mime = Utils::getMimeByFilename($filename); + 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 "' . $filename . '" 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 "' . $filename . '" is not an accepted.'; + } else { + $accepted = true; + break; + } + } + } + if (!$accepted) { + throw new RuntimeException(implode('
', $errors), 400); + } return $filename; } /** - * Upload file to the media collection. + * Copy uploaded file to the media collection. + * + * WARNING: Always check uploaded file before copying it! + * + * @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 string $filename * @return void * @throws RuntimeException */ - public function uploadFile(UploadedFileInterface $uploadedFile, string $filename = null): void + public function copyUploadedFile(UploadedFileInterface $uploadedFile, string $filename): void { - // First check if the file is a valid upload (throws error if not). - $filename = $this->checkUploadedFile($uploadedFile, $filename); - $path = $this->getPath(); if (!$path) { throw new RuntimeException($this->translate('PLUGIN_ADMIN.FAILED_TO_MOVE_UPLOADED_FILE'), 400); } + /** @var UniformResourceLocator $locator */ + $locator = $this->getGrav()['locator']; + try { - /** @var UniformResourceLocator $locator */ - $locator = $this->getGrav()['locator']; + // Do not use streams internally. if ($locator->isStream($path)) { $path = (string)$locator->findResource($path, true, true); - $locator->clearCache($path); + $locator->clearCache(); } $filepath = sprintf('%s/%s', $path, $filename); @@ -127,15 +237,24 @@ trait MediaUploadTrait } else { $uploadedFile->moveTo($filepath); } + + // Special content sanitization for SVG. + $mime = Utils::getMimeByFilename($filename); + if (Utils::contains($mime, 'svg', false)) { + Security::sanitizeSVG($filepath); + } } catch (\Exception $e) { throw new RuntimeException($this->translate('PLUGIN_ADMIN.FAILED_TO_MOVE_UPLOADED_FILE'), 400); } // Finally clear media cache. + $locator->clearCache(); $this->clearCache(); } /** + * Delete real file from the media collection. + * * @param string $filename * @return void * @throws RuntimeException @@ -164,19 +283,21 @@ trait MediaUploadTrait /** @var UniformResourceLocator $locator */ $locator = $grav['locator']; + if ($locator->isStream($targetFile)) { $targetPath = (string)$locator->findResource($targetPath, true, true); $targetFile = (string)$locator->findResource($targetFile, true, true); - $locator->clearCache($targetPath); - $locator->clearCache($targetFile); + $locator->clearCache(); } $fileParts = (array)$filesystem->pathinfo($basename); + // If path doesn't exist, there's nothing to do. if (!file_exists($targetPath)) { return; } + // Remove media file. if (file_exists($targetFile)) { $result = unlink($targetFile); if (!$result) { @@ -184,12 +305,11 @@ trait MediaUploadTrait } } + // Remove associated .meta.yaml files. $dir = scandir($targetPath, SCANDIR_SORT_NONE); if (false === $dir) { - throw new RuntimeException('Internal error'); + throw new RuntimeException('Internal error (M102)'); } - - // Remove associated .meta.yaml files. foreach ($dir as $file) { $preg_name = preg_quote($fileParts['filename'], '`'); $preg_ext = preg_quote($fileParts['extension'] ?? '.', '`'); @@ -211,38 +331,10 @@ trait MediaUploadTrait } } + // Finally clear media cache. $this->clearCache(); } - /** - * Check the file extension. - * - * @param string $filename - * @return void - * @throws RuntimeException - */ - protected function checkFileExtension(string $filename) - { - $extension = strtolower(pathinfo($filename, PATHINFO_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); - } - } - - protected function clearMediaCache(): void - { - $grav = $this->getGrav(); - $path = $this->getPath(); - - /** @var UniformResourceLocator $locator */ - $locator = $grav['locator']; - if ($path && $locator->isStream($path)) { - $path = (string)$locator->findResource($path, true, true); - $locator->clearCache($path); - } - } - /** * @param string $string * @return string