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; } }