&scope=&accept=&preview_images=1 */ public function list(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.media.read'); $query = $request->getQueryParams(); $folder = (string)($query['folder'] ?? ''); $scope = (string)($query['scope'] ?? ''); $acceptRaw = (string)($query['accept'] ?? ''); if ($folder === '') { throw new ValidationException('folder is required.'); } $resolver = $this->resolver(); $resolver->assertSafe($folder); // `@self` / `self@` literals are page-media — the client has that already. if ($resolver->isSelfLiteral($folder)) { return ApiResponse::create([ 'error' => 'PAGE_MEDIA_ONLY', 'message' => 'Use /pages/{route}/media for @self / self@ folders.', ], 422); } $abs = $resolver->resolve($folder, $scope, $this->getUser($request)); $logicalFolder = $resolver->logicalParent($folder, $scope); // Resolve the file list (or empty list when the folder doesn't exist // yet — common for fresh installs targeting `theme://images` on a // theme that ships no images). $items = []; if (is_dir($abs)) { $accept = $this->parseAccept($acceptRaw); foreach ($this->iterateMedia($abs) as $name => $medium) { if (!$this->matchesAccept((string)$name, (string)($medium->get('mime') ?? ''), $accept)) { continue; } $items[] = $this->serializer()->serialize($medium); } } // Use the paginated envelope (`{ data: [...], meta: { pagination, … } }`) // even though we don't actually paginate — it matches the shape the // admin-next client already expects from `/media` and avoids the // double-wrap that `ApiResponse::create` would impose on a hand-built // `{ data, meta }` payload. $total = count($items); return ApiResponse::paginated( $items, $total, 1, max($total, 1), $this->getApiBaseUrl() . '/blueprint-files', 200, [], [ 'folder' => $logicalFolder, 'scope' => $scope !== '' ? $scope : null, 'exists' => is_dir($abs), ], ); } /** * Seam for tests. Yields `filename => Medium` over the given absolute * directory. Production path delegates to Grav's real Media class. */ protected function iterateMedia(string $absoluteDir): iterable { return (new Media($absoluteDir))->all(); } /** * Parse the comma-separated `accept` query param into an array of * patterns. Empty input → no filtering. */ private function parseAccept(string $raw): array { if ($raw === '') return []; $parts = array_filter(array_map('trim', explode(',', $raw)), static fn($s) => $s !== ''); return array_values($parts); } /** * Mirror admin-classic's accept regex: extension form (`.pdf`, `*.jpg`) * matches the filename; mime form (`image/png`, `image/*`) matches the * Grav-detected mime. The `*` / `+` / `.` escaping mirrors * AdminBaseController::taskFilesUpload. * * @param string[] $patterns */ private function matchesAccept(string $filename, string $mime, array $patterns): bool { if ($patterns === []) return true; foreach ($patterns as $type) { if ($type === '*') return true; $find = str_replace(['.', '*', '+'], ['\.', '.*', '\+'], $type); $isMime = str_contains($type, '/'); if ($isMime) { if (preg_match('#' . $find . '$#', $mime)) return true; } else { if (preg_match('#' . $find . '$#', $filename)) return true; } } return false; } private function resolver(): BlueprintPathResolver { return $this->resolver ??= new BlueprintPathResolver($this->grav); } private function serializer(): MediaSerializer { if (!$this->serializer) { $cacheDir = $this->grav['locator']->findResource('cache://') . '/api/thumbnails'; $thumb = new ThumbnailService($cacheDir); $this->serializer = new MediaSerializer($thumb, $this->getApiBaseUrl()); } return $this->serializer; } }