requirePermission($request, 'api.media.read'); $page = $this->findPageOrFail($request); $pagePath = $page->path(); // Create fresh Media object to avoid stale page cache $media = new \Grav\Common\Page\Media($pagePath); $serialized = $this->getSerializer()->serializeCollection($media->all()); return ApiResponse::create($serialized); } /** * POST /pages/{route}/media - Upload file(s) to a page. */ public function uploadPageMedia(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.media.write'); $page = $this->findPageOrFail($request); $pagePath = $page->path(); if (!$pagePath || !is_dir($pagePath)) { throw new NotFoundException('Page directory does not exist on disk.'); } $uploadedFiles = $this->flattenUploadedFiles($request->getUploadedFiles()); if ($uploadedFiles === []) { throw new ValidationException('No files were uploaded.'); } // Honor per-field upload settings (random_name, accept, ...) when the // file field forwards them; absent, this is an inert no-op. $settings = $this->parseUploadFieldSettings($request); $uploadedNames = []; foreach ($uploadedFiles as $file) { // Fire before event — plugins can throw to reject specific files $this->fireEvent('onApiBeforeMediaUpload', [ 'page' => $page, 'filename' => $file->getClientFilename(), 'type' => $file->getClientMediaType(), 'size' => $file->getSize(), ]); $uploadedNames[] = $this->processUploadedFile($file, $pagePath, $settings); } // Create fresh Media object to pick up newly uploaded files $media = new \Grav\Common\Page\Media($pagePath); $serialized = $this->getSerializer()->serializeCollection($media->all()); $this->fireAdminEvent('onAdminAfterAddMedia', ['object' => $page, 'page' => $page]); $this->fireEvent('onApiMediaUploaded', [ 'page' => $page, 'filenames' => $uploadedNames, ]); $baseUrl = $this->getApiBaseUrl(); $route = $this->getRouteParam($request, 'route') ?? ''; $location = "{$baseUrl}/pages/{$route}/media"; return ApiResponse::created( $serialized, $location, $this->invalidationHeaders([ 'media:update:pages/' . $route, 'pages:update:/' . $route, ]), ); } /** * DELETE /pages/{route}/media/{filename} - Delete a media file from a page. */ public function deletePageMedia(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.media.write'); $page = $this->findPageOrFail($request); $filename = $this->getSafeFilename($request); $pagePath = $page->path(); if (!$pagePath) { throw new NotFoundException('Page directory does not exist on disk.'); } // Verify the file exists on disk $filePath = $pagePath . '/' . $filename; if (!file_exists($filePath)) { throw new NotFoundException("Media file '{$filename}' not found on this page."); } $this->fireEvent('onApiBeforeMediaDelete', ['page' => $page, 'filename' => $filename]); unlink($filePath); // Also remove any metadata file (.meta.yaml) if it exists $metaPath = $filePath . '.meta.yaml'; if (file_exists($metaPath)) { unlink($metaPath); } // Build fresh media object for admin event compatibility $media = new \Grav\Common\Page\Media($pagePath); $this->fireAdminEvent('onAdminAfterDelMedia', [ 'object' => $page, 'page' => $page, 'media' => $media, 'filename' => $filename, ]); $this->fireEvent('onApiMediaDeleted', ['page' => $page, 'filename' => $filename]); $route = $this->getRouteParam($request, 'route') ?? ''; return ApiResponse::noContent( $this->invalidationHeaders([ 'media:delete:pages/' . $route . '/' . $filename, 'media:update:pages/' . $route, 'pages:update:/' . $route, ]), ); } /** * GET /media - List site-level media with folder browsing, search, and type filter. */ public function siteMedia(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.media.read'); $mediaPath = $this->getSiteMediaPath(); $queryParams = $request->getQueryParams(); // Validate optional path parameter $relativePath = ''; if (!empty($queryParams['path'])) { $relativePath = $this->validateRelativePath($queryParams['path'], $mediaPath); } $currentPath = $relativePath !== '' ? $mediaPath . '/' . $relativePath : $mediaPath; // Handle search mode if (!empty($queryParams['search'])) { return $this->handleMediaSearch($request, $mediaPath, $queryParams); } // Verify directory exists if (!is_dir($currentPath)) { // Return empty result for non-existent paths $baseUrl = $this->getApiBaseUrl() . '/media'; return ApiResponse::paginated([], 0, 1, 20, $baseUrl, 200, [], [ 'path' => $relativePath, 'folders' => [], ]); } $result = $this->scanMediaDirectoryWithFolders($currentPath, $relativePath); $pagination = $this->getPagination($request); // Apply type filter $typeFilter = $queryParams['type'] ?? null; $files = $result['files']; if ($typeFilter) { $files = array_values(array_filter($files, function (string $file) use ($currentPath, $typeFilter) { $mime = mime_content_type($currentPath . '/' . $file) ?: ''; return match ($typeFilter) { 'image' => str_starts_with($mime, 'image/'), 'video' => str_starts_with($mime, 'video/'), 'audio' => str_starts_with($mime, 'audio/'), 'document' => !str_starts_with($mime, 'image/') && !str_starts_with($mime, 'video/') && !str_starts_with($mime, 'audio/'), default => true, }; })); } $total = count($files); $pagedFiles = array_slice($files, $pagination['offset'], $pagination['limit']); $serialized = array_map( fn(string $file) => $this->serializeSiteFile($currentPath, $file, $relativePath), $pagedFiles, ); $baseUrl = $this->getApiBaseUrl() . '/media'; return ApiResponse::paginated( $serialized, $total, $pagination['page'], $pagination['per_page'], $baseUrl, 200, [], [ 'path' => $relativePath, 'folders' => $result['folders'], ], ); } /** * POST /media - Upload file(s) to the site media folder (with optional subfolder path). */ public function uploadSiteMedia(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.media.write'); $mediaPath = $this->getSiteMediaPath(); $queryParams = $request->getQueryParams(); // Validate optional subfolder path $relativePath = ''; if (!empty($queryParams['path'])) { $relativePath = $this->validateRelativePath($queryParams['path'], $mediaPath); } $targetDir = $relativePath !== '' ? $mediaPath . '/' . $relativePath : $mediaPath; if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) { throw new ValidationException('Unable to create upload directory.'); } $uploadedFiles = $this->flattenUploadedFiles($request->getUploadedFiles()); if ($uploadedFiles === []) { throw new ValidationException('No files were uploaded.'); } $settings = $this->parseUploadFieldSettings($request); $created = []; foreach ($uploadedFiles as $file) { $filename = $this->processUploadedFile($file, $targetDir, $settings); $created[] = $this->serializeSiteFile($targetDir, $filename, $relativePath); } $location = $this->getApiBaseUrl() . '/media'; return ApiResponse::created( $created, $location, $this->invalidationHeaders(['media:update:' . ($relativePath !== '' ? $relativePath : '/'), 'media:list']), ); } /** * DELETE /media/{filename} - Delete a site media file (supports subfolder paths). */ public function deleteSiteMedia(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.media.write'); $mediaPath = $this->getSiteMediaPath(); $relativePath = $this->getSafeRelativeFilePath($request, $mediaPath); $filePath = $mediaPath . '/' . $relativePath; if (!file_exists($filePath)) { throw new NotFoundException("Media file not found."); } unlink($filePath); // Also remove any metadata file $metaPath = $filePath . '.meta.yaml'; if (file_exists($metaPath)) { unlink($metaPath); } $parentDir = ltrim(dirname($relativePath), '.'); return ApiResponse::noContent( $this->invalidationHeaders([ 'media:delete:' . $relativePath, 'media:update:' . ($parentDir !== '' ? $parentDir : '/'), 'media:list', ]), ); } /** * POST /media/folders - Create a new folder. */ public function createFolder(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.media.write'); $mediaPath = $this->getSiteMediaPath(); $body = json_decode((string) $request->getBody(), true) ?? []; if (empty($body['path'])) { throw new ValidationException('Folder path is required.'); } $relativePath = $this->validateRelativePath($body['path'], $mediaPath); $absolutePath = $mediaPath . '/' . $relativePath; if (is_dir($absolutePath)) { throw new ValidationException('Folder already exists.'); } if (!mkdir($absolutePath, 0775, true)) { throw new ValidationException('Unable to create folder.'); } $name = basename($relativePath); $data = [ 'name' => $name, 'path' => $relativePath, 'children_count' => 0, 'file_count' => 0, ]; return ApiResponse::created( $data, $this->getApiBaseUrl() . '/media?path=' . urlencode($relativePath), $this->invalidationHeaders(['media:create:' . $relativePath, 'media:list']), ); } /** * DELETE /media/folders/{path} - Delete an empty folder. */ public function deleteFolder(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.media.write'); $mediaPath = $this->getSiteMediaPath(); $path = $this->getRouteParam($request, 'path'); if ($path === null || $path === '') { throw new ValidationException('Folder path is required.'); } $relativePath = $this->validateRelativePath($path, $mediaPath); $absolutePath = $mediaPath . '/' . $relativePath; if (!is_dir($absolutePath)) { throw new NotFoundException('Folder not found.'); } // Check if folder is empty (only . and ..) $isEmpty = true; foreach (new \DirectoryIterator($absolutePath) as $item) { if (!$item->isDot()) { $isEmpty = false; break; } } if (!$isEmpty) { throw new ValidationException('Folder is not empty. Delete all files first.'); } if (!rmdir($absolutePath)) { throw new ValidationException('Unable to delete folder.'); } return ApiResponse::noContent( $this->invalidationHeaders(['media:delete:' . $relativePath, 'media:list']), ); } /** * POST /media/rename - Rename or move a media file. */ public function renameFile(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.media.write'); $mediaPath = $this->getSiteMediaPath(); $body = json_decode((string) $request->getBody(), true) ?? []; if (empty($body['from']) || empty($body['to'])) { throw new ValidationException("Both 'from' and 'to' paths are required."); } $from = $this->validateRelativePath($body['from'], $mediaPath); $to = $this->validateRelativePath($body['to'], $mediaPath); $fromAbsolute = $mediaPath . '/' . $from; $toAbsolute = $mediaPath . '/' . $to; if (!file_exists($fromAbsolute)) { throw new NotFoundException("Source file not found."); } if (file_exists($toAbsolute)) { throw new ValidationException("A file already exists at the destination."); } // Ensure target directory exists $targetDir = dirname($toAbsolute); if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) { throw new ValidationException('Unable to create destination directory.'); } if (!rename($fromAbsolute, $toAbsolute)) { throw new ValidationException('Unable to rename file.'); } // Also rename metadata sidecar if it exists $fromMeta = $fromAbsolute . '.meta.yaml'; $toMeta = $toAbsolute . '.meta.yaml'; if (file_exists($fromMeta)) { rename($fromMeta, $toMeta); } $toDir = ltrim(dirname($to) === '.' ? '' : dirname($to), '/'); $toFilename = basename($to); $targetPath = $toDir !== '' ? $mediaPath . '/' . $toDir : $mediaPath; return ApiResponse::ok( $this->serializeSiteFile($targetPath, $toFilename, $toDir), $this->invalidationHeaders([ 'media:delete:' . $from, 'media:create:' . $to, 'media:list', ]), ); } /** * POST /media/folders/rename - Rename a folder. */ public function renameFolder(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.media.write'); $mediaPath = $this->getSiteMediaPath(); $body = json_decode((string) $request->getBody(), true) ?? []; if (empty($body['from']) || empty($body['to'])) { throw new ValidationException("Both 'from' and 'to' paths are required."); } $from = $this->validateRelativePath($body['from'], $mediaPath); $to = $this->validateRelativePath($body['to'], $mediaPath); $fromAbsolute = $mediaPath . '/' . $from; $toAbsolute = $mediaPath . '/' . $to; if (!is_dir($fromAbsolute)) { throw new NotFoundException("Source folder not found."); } if (file_exists($toAbsolute)) { throw new ValidationException("A folder already exists at the destination."); } if (!rename($fromAbsolute, $toAbsolute)) { throw new ValidationException('Unable to rename folder.'); } $name = basename($to); $data = [ 'name' => $name, 'path' => $to, 'children_count' => 0, 'file_count' => 0, ]; return ApiResponse::ok( $data, $this->invalidationHeaders([ 'media:delete:' . $from, 'media:create:' . $to, 'media:list', ]), ); } // ------------------------------------------------------------------------- // Private helpers // ------------------------------------------------------------------------- /** * GET /thumbnails/{hash}.{ext} - Serve a cached thumbnail image. */ public function thumbnail(ServerRequestInterface $request): ResponseInterface { $file = $this->getRouteParam($request, 'file'); if (!$file) { throw new NotFoundException('Thumbnail not found.'); } $cacheDir = $this->grav['locator']->findResource('cache://') . '/api/thumbnails'; $cachePath = $cacheDir . '/' . basename($file); if (!file_exists($cachePath)) { throw new NotFoundException('Thumbnail not found.'); } $mime = mime_content_type($cachePath) ?: 'application/octet-stream'; $content = file_get_contents($cachePath); return new Response( 200, [ 'Content-Type' => $mime, 'Content-Length' => (string) strlen($content), 'Cache-Control' => 'public, max-age=31536000, immutable', ], $content ); } /** * Resolve a page from the route parameter or throw a 404. */ private function findPageOrFail(ServerRequestInterface $request): PageInterface { $route = $this->getRouteParam($request, 'route'); if ($route === null || $route === '') { throw new NotFoundException('Page route is required.'); } $pages = $this->grav['pages']; // Enable pages if they were disabled (e.g. in admin context) if (method_exists($pages, 'enablePages')) { $pages->enablePages(); } $page = $pages->find('/' . ltrim($route, '/')); if (!$page) { throw new NotFoundException("Page '/{$route}' not found."); } return $page; } /** * Validate a relative path is safe and within the media directory. * Returns the sanitized relative path. */ private function validateRelativePath(string $path, string $basePath): string { // Normalize separators $path = str_replace('\\', '/', $path); $path = trim($path, '/'); if ($path === '') { return ''; } // Check each segment foreach (explode('/', $path) as $segment) { if ( $segment === '' || $segment === '.' || $segment === '..' || str_contains($segment, "\0") || str_starts_with($segment, '.') ) { throw new ValidationException("Invalid path: '{$path}'."); } } // Verify resolved path is within base $absolute = $basePath . '/' . $path; // For existing paths, use realpath if (file_exists($absolute)) { $real = realpath($absolute); $realBase = realpath($basePath); if ($real === false || $realBase === false || !str_starts_with($real, $realBase . '/')) { throw new ValidationException("Invalid path: '{$path}'."); } } return $path; } /** * Extract and validate a relative file path from route parameters. * Unlike getSafeFilename() which strips directories with basename(), * this preserves path components for subfolder support. */ private function getSafeRelativeFilePath(ServerRequestInterface $request, string $basePath): string { $filename = $this->getRouteParam($request, 'filename'); if ($filename === null || $filename === '') { throw new ValidationException('Filename is required.'); } // Normalize $filename = str_replace('\\', '/', $filename); $filename = trim($filename, '/'); // Validate each path segment foreach (explode('/', $filename) as $segment) { if ( $segment === '' || $segment === '.' || $segment === '..' || str_contains($segment, "\0") || str_starts_with($segment, '.') ) { throw new ValidationException('Invalid filename.'); } } // Verify resolved path is within base $absolute = $basePath . '/' . $filename; if (file_exists($absolute)) { $real = realpath($absolute); $realBase = realpath($basePath); if ($real === false || $realBase === false || !str_starts_with($real, $realBase . '/')) { throw new ValidationException('Invalid filename.'); } } return $filename; } /** * Resolve the absolute path to the site-level media directory. */ private function getSiteMediaPath(): string { /** @var \Grav\Common\Locator $locator */ $locator = $this->grav['locator']; $path = $locator->findResource('user://media', true, true); if (!$path) { throw new NotFoundException('Site media directory could not be resolved.'); } return $path; } /** * Handle recursive media search across all subfolders. */ private function handleMediaSearch( ServerRequestInterface $request, string $mediaPath, array $queryParams ): ResponseInterface { $search = strtolower($queryParams['search']); $typeFilter = $queryParams['type'] ?? null; $pagination = $this->getPagination($request); $matches = []; if (is_dir($mediaPath)) { $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($mediaPath, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST ); foreach ($iterator as $item) { if ($item->isDir()) { continue; } $name = $item->getFilename(); // Skip hidden and metadata files if (str_starts_with($name, '.') || str_ends_with($name, '.meta.yaml')) { continue; } // Match filename if (!str_contains(strtolower($name), $search)) { continue; } // Apply type filter if ($typeFilter) { $mime = mime_content_type($item->getPathname()) ?: ''; $passesFilter = match ($typeFilter) { 'image' => str_starts_with($mime, 'image/'), 'video' => str_starts_with($mime, 'video/'), 'audio' => str_starts_with($mime, 'audio/'), 'document' => !str_starts_with($mime, 'image/') && !str_starts_with($mime, 'video/') && !str_starts_with($mime, 'audio/'), default => true, }; if (!$passesFilter) { continue; } } // Calculate relative path $fullPath = $item->getPathname(); $relDir = ltrim(str_replace($mediaPath, '', dirname($fullPath)), '/'); $matches[] = ['filename' => $name, 'dir' => $relDir, 'fullPath' => $fullPath]; } } // Sort matches usort($matches, fn($a, $b) => strnatcasecmp($a['filename'], $b['filename'])); $total = count($matches); $paged = array_slice($matches, $pagination['offset'], $pagination['limit']); $serialized = array_map(function (array $match) { return $this->serializeSiteFile(dirname($match['fullPath']), $match['filename'], $match['dir']); }, $paged); $baseUrl = $this->getApiBaseUrl() . '/media'; return ApiResponse::paginated( $serialized, $total, $pagination['page'], $pagination['per_page'], $baseUrl, 200, [], [ 'path' => '', 'folders' => [], 'search' => $queryParams['search'], ], ); } /** * Scan a directory for media files, returning just the filenames sorted alphabetically. * * @return string[] */ private function scanMediaDirectory(string $path): array { if (!is_dir($path)) { return []; } $files = []; /** @var \SplFileInfo $item */ foreach (new \DirectoryIterator($path) as $item) { if ($item->isDot() || $item->isDir()) { continue; } // Skip hidden files and metadata files $name = $item->getFilename(); if (str_starts_with($name, '.') || str_ends_with($name, '.meta.yaml')) { continue; } $files[] = $name; } sort($files, SORT_NATURAL | SORT_FLAG_CASE); return $files; } /** * Scan a directory for media files and subdirectories. * * @return array{files: string[], folders: array} */ private function scanMediaDirectoryWithFolders(string $absolutePath, string $relativePath = ''): array { $files = []; $folders = []; if (!is_dir($absolutePath)) { return ['files' => $files, 'folders' => $folders]; } foreach (new \DirectoryIterator($absolutePath) as $item) { if ($item->isDot()) { continue; } $name = $item->getFilename(); // Skip hidden files/dirs if (str_starts_with($name, '.')) { continue; } if ($item->isDir()) { $folderPath = $relativePath !== '' ? $relativePath . '/' . $name : $name; $childPath = $absolutePath . '/' . $name; // Count immediate children $childrenCount = 0; $fileCount = 0; if (is_dir($childPath)) { foreach (new \DirectoryIterator($childPath) as $child) { if ($child->isDot() || str_starts_with($child->getFilename(), '.')) { continue; } if ($child->isDir()) { $childrenCount++; } elseif (!str_ends_with($child->getFilename(), '.meta.yaml')) { $fileCount++; } } } $folders[] = [ 'name' => $name, 'path' => $folderPath, 'children_count' => $childrenCount, 'file_count' => $fileCount, ]; } else { // Skip metadata files if (str_ends_with($name, '.meta.yaml')) { continue; } $files[] = $name; } } sort($files, SORT_NATURAL | SORT_FLAG_CASE); usort($folders, fn(array $a, array $b) => strnatcasecmp($a['name'], $b['name'])); return ['files' => $files, 'folders' => $folders]; } /** * Build a serialized array for a raw file in the site media directory. * Used when we don't have Grav Medium objects available. */ private function serializeSiteFile(string $basePath, string $filename, string $relativePath = ''): array { $filePath = $basePath . '/' . $filename; $mime = mime_content_type($filePath) ?: 'application/octet-stream'; $fullRelativePath = $relativePath !== '' ? $relativePath . '/' . $filename : $filename; $data = [ 'filename' => $filename, 'path' => $relativePath, 'url' => '/user/media/' . $fullRelativePath, 'type' => $mime, 'size' => (int) filesize($filePath), ]; if (str_starts_with($mime, 'image/') && $mime !== 'image/svg+xml') { if ($imageSize = @getimagesize($filePath)) { $data['dimensions'] = [ 'width' => $imageSize[0], 'height' => $imageSize[1], ]; } // Generate thumbnail try { $thumbnailService = $this->getThumbnailService(); $hash = $thumbnailService->getOrCreate($filePath); if ($hash) { $data['thumbnail_url'] = $this->getApiBaseUrl() . '/thumbnails/' . $hash; } } catch (\Throwable) { // Thumbnail generation failed — skip it } } $mtime = filemtime($filePath); $data['modified'] = date(\DateTimeInterface::ATOM, $mtime ?: time()); return $data; } }