891 lines
29 KiB
PHP
891 lines
29 KiB
PHP
<?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;
|
|
}
|
|
}
|