feat(demo): add story 1 — Sorano: Rock and Time
This commit is contained in:
@@ -0,0 +1,890 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Grav\Plugin\Api\Controllers;
|
||||
|
||||
use Grav\Common\Page\Interfaces\PageInterface;
|
||||
use Grav\Framework\Psr7\Response;
|
||||
use Grav\Plugin\Api\Exceptions\NotFoundException;
|
||||
use Grav\Plugin\Api\Exceptions\ValidationException;
|
||||
use Grav\Plugin\Api\Response\ApiResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
class MediaController extends AbstractApiController
|
||||
{
|
||||
use HandlesMediaUploads;
|
||||
|
||||
/**
|
||||
* GET /pages/{route}/media - List all media for a page.
|
||||
*/
|
||||
public function pageMedia(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$this->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<array{name: string, path: string, children_count: int, file_count: int}>}
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user