Files
intotheeast-com-content/plugins/api/classes/Api/Controllers/HandlesMediaUploads.php
T

197 lines
7.0 KiB
PHP

<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Serializers\MediaSerializer;
use Grav\Plugin\Api\Services\ThumbnailService;
use Grav\Plugin\Api\Services\UploadFieldSettings;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UploadedFileInterface;
/**
* Shared file-upload pipeline for media endpoints: validating uploads,
* moving them into a target folder, and serializing the resulting media.
*
* The logic is storage-agnostic — it only needs a resolved filesystem
* directory to write into. That makes it reusable for any object that can
* yield a media folder: a page (its content folder) or a folder-stored Flex
* object (its storage folder, e.g. user-data://flex-objects/contacts/{id}).
*
* Used by MediaController (pages + site media) and by the flex-objects
* plugin's FlexApiController via the shared AbstractApiController base.
*/
trait HandlesMediaUploads
{
/** Maximum upload size: 64 MB */
private const int MAX_UPLOAD_SIZE = 67_108_864;
private ?MediaSerializer $mediaSerializer = null;
protected function getThumbnailService(): ThumbnailService
{
$cacheDir = $this->grav['locator']->findResource('cache://') . '/api/thumbnails';
return new ThumbnailService($cacheDir);
}
protected function getSerializer(): MediaSerializer
{
if (!$this->mediaSerializer) {
$thumbnailService = $this->getThumbnailService();
$baseUrl = $this->getApiBaseUrl();
$this->mediaSerializer = new MediaSerializer($thumbnailService, $baseUrl);
}
return $this->mediaSerializer;
}
/**
* Extract and validate a safe filename from the route parameters.
*/
protected function getSafeFilename(ServerRequestInterface $request): string
{
$filename = $this->getRouteParam($request, 'filename');
if ($filename === null || $filename === '') {
throw new ValidationException('Filename is required.');
}
$filename = basename($filename);
if (
str_contains($filename, '..')
|| str_contains($filename, "\0")
|| str_starts_with($filename, '.')
) {
throw new ValidationException('Invalid filename.');
}
return $filename;
}
/**
* Parse blueprint file-field upload settings (random_name, avoid_overwriting,
* accept, filesize) from a request's form fields. Absent settings yield an
* inert object, so callers without field context keep current behavior.
*/
protected function parseUploadFieldSettings(ServerRequestInterface $request): UploadFieldSettings
{
$body = $request->getParsedBody();
return is_array($body) ? UploadFieldSettings::fromParams($body) : UploadFieldSettings::none();
}
/**
* Process a single uploaded file: validate it and move to the target directory.
*
* Optional per-field $settings (from a blueprint `type: file` field) layer
* filename randomization, overwrite avoidance, an accept allowlist, and a
* per-field size limit *on top of* the immovable security floor enforced
* here (size cap, traversal guard, dangerous-extension denylist).
*
* Returns the safe filename that was written.
*/
protected function processUploadedFile(
UploadedFileInterface $file,
string $targetDir,
?UploadFieldSettings $settings = null,
): string {
// Check for upload errors
if ($file->getError() !== UPLOAD_ERR_OK) {
$message = match ($file->getError()) {
UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'File exceeds maximum upload size.',
UPLOAD_ERR_PARTIAL => 'File was only partially uploaded.',
UPLOAD_ERR_NO_FILE => 'No file was uploaded.',
UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder.',
UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk.',
default => 'Unknown upload error.',
};
throw new ValidationException($message);
}
// Validate file size against the hard cap, then the per-field limit.
$size = $file->getSize();
if ($size !== null && $size > self::MAX_UPLOAD_SIZE) {
throw new ValidationException(
sprintf('File exceeds maximum allowed size of %d MB.', self::MAX_UPLOAD_SIZE / 1_048_576)
);
}
$settings?->assertFilesize($size);
// Sanitize the filename
$originalName = $file->getClientFilename() ?? 'upload';
$filename = basename($originalName);
if (
str_contains($filename, '..')
|| str_contains($filename, "\0")
|| str_starts_with($filename, '.')
) {
throw new ValidationException("Invalid filename: '{$filename}'.");
}
// Validate extension against dangerous extensions list, then the
// field's accept allowlist (matched on the original name's extension).
$this->validateFileExtension($filename);
$settings?->assertAccepted($filename);
// Apply random_name / avoid_overwriting last — both preserve the
// already-validated extension, so the floor checks above still hold.
if ($settings !== null) {
$filename = $settings->resolveFilename($filename, $targetDir);
}
// Move the file to the target directory
$targetPath = $targetDir . '/' . $filename;
$file->moveTo($targetPath);
return $filename;
}
/**
* Validate that a filename's extension is not on the dangerous list.
*/
protected function validateFileExtension(string $filename): void
{
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if ($extension === '') {
throw new ValidationException('Uploaded file must have a file extension.');
}
$dangerousExtensions = $this->config->get('security.uploads_dangerous_extensions', []);
// Normalize to lowercase for comparison
$dangerousExtensions = array_map('strtolower', $dangerousExtensions);
if (in_array($extension, $dangerousExtensions, true)) {
throw new ValidationException(
"File extension '.{$extension}' is not allowed for security reasons."
);
}
}
/**
* Flatten a potentially nested array of uploaded files into a flat list.
*
* PSR-7 allows uploaded files to be nested (e.g. files[avatar], files[gallery][]).
*
* @return UploadedFileInterface[]
*/
protected function flattenUploadedFiles(array $files): array
{
$result = [];
foreach ($files as $file) {
if ($file instanceof UploadedFileInterface) {
$result[] = $file;
} elseif (is_array($file)) {
$result = [...$result, ...$this->flattenUploadedFiles($file)];
}
}
return $result;
}
}