/ folder that can be selected as a write target. * Legacy user//config/ layouts (Grav 1.6 fallback) are included too. */ public function environments(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.system.read'); $envService = new EnvironmentService($this->grav); $list = [[ 'name' => '', 'label' => 'Default', 'exists' => true, 'hasOverrides' => false, ]]; foreach ($envService->listEnvironments() as $name) { $list[] = [ 'name' => $name, 'label' => $name, 'exists' => true, 'hasOverrides' => $envService->envHasOverrides($name), ]; } return ApiResponse::create([ 'detected' => $this->grav['uri']->environment(), 'environments' => $list, ]); } /** * POST /system/environments — create a new env folder. * * Body: { "name": "staging.foo.com" } * Creates user/env//config/ (and user/env/ if missing). */ public function createEnvironment(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.config.write'); $body = $this->getRequestBody($request); $name = trim((string)($body['name'] ?? '')); $envService = new EnvironmentService($this->grav); try { $envService->createEnvironment($name); } catch (\InvalidArgumentException $e) { throw new ValidationException($e->getMessage()); } return ApiResponse::create([ 'name' => $name, 'label' => $name, 'exists' => true, 'hasOverrides' => false, ], 201, ['X-Invalidates' => 'system:environments']); } /** * DELETE /system/environments/{name} — remove a user/env// folder. * * Refuses to delete the env that Grav resolved for the current request, and * refuses to act on legacy user// layouts. See EnvironmentService for * the full safety rules. */ public function deleteEnvironment(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.config.write'); $name = (string) $this->getRouteParam($request, 'name'); $envService = new EnvironmentService($this->grav); try { $envService->deleteEnvironment($name); } catch (\InvalidArgumentException $e) { throw new ValidationException($e->getMessage()); } return ApiResponse::noContent(['X-Invalidates' => 'system:environments']); } public function info(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.system.read'); $plugins = $this->getPluginsInfo(); $themes = $this->getThemesInfo(); $data = [ 'grav_version' => GRAV_VERSION, 'php_version' => PHP_VERSION, 'php_extensions' => get_loaded_extensions(), 'server_software' => $_SERVER['SERVER_SOFTWARE'] ?? 'unknown', 'environment' => $this->config->get('system.environment') ?? $this->grav['uri']->environment(), 'plugins' => $plugins, 'themes' => $themes, 'php_config' => $this->getPhpConfig(), ]; return ApiResponse::create($data); } private function getPhpConfig(): array { $ini = function (string $key): string { return (string) ini_get($key); }; return [ 'Upload & POST' => [ 'file_uploads' => $ini('file_uploads'), 'upload_max_filesize' => $ini('upload_max_filesize'), 'max_file_uploads' => $ini('max_file_uploads'), 'post_max_size' => $ini('post_max_size'), ], 'Memory & Execution' => [ 'memory_limit' => $ini('memory_limit'), 'max_execution_time' => $ini('max_execution_time') . 's', 'max_input_time' => $ini('max_input_time') . 's', 'max_input_vars' => $ini('max_input_vars'), ], 'Error Handling' => [ 'display_errors' => $ini('display_errors'), 'error_reporting' => (string) error_reporting(), 'log_errors' => $ini('log_errors'), 'error_log' => $ini('error_log') ?: '(none)', ], 'Paths & Environment' => [ 'open_basedir' => $ini('open_basedir') ?: '(none)', 'sys_temp_dir' => sys_get_temp_dir(), 'doc_root' => $_SERVER['DOCUMENT_ROOT'] ?? '(unknown)', 'include_path' => $ini('include_path'), ], 'Session' => [ 'session.save_handler' => $ini('session.save_handler'), 'session.save_path' => $ini('session.save_path') ?: '(default)', 'session.gc_maxlifetime' => $ini('session.gc_maxlifetime') . 's', 'session.cookie_lifetime' => $ini('session.cookie_lifetime') . 's', 'session.cookie_secure' => $ini('session.cookie_secure'), 'session.cookie_httponly' => $ini('session.cookie_httponly'), ], 'OPcache' => function_exists('opcache_get_status') ? [ 'opcache.enable' => $ini('opcache.enable'), 'opcache.memory_consumption' => $ini('opcache.memory_consumption') . 'MB', 'opcache.max_accelerated_files' => $ini('opcache.max_accelerated_files'), 'opcache.validate_timestamps' => $ini('opcache.validate_timestamps'), 'opcache.revalidate_freq' => $ini('opcache.revalidate_freq') . 's', ] : ['opcache.enable' => '0'], 'Security' => [ 'allow_url_fopen' => $ini('allow_url_fopen'), 'allow_url_include' => $ini('allow_url_include'), 'disable_functions' => $ini('disable_functions') ?: '(none)', 'expose_php' => $ini('expose_php'), ], 'Date & Locale' => [ 'date.timezone' => $ini('date.timezone') ?: date_default_timezone_get(), 'default_charset' => $ini('default_charset'), 'mbstring.internal_encoding' => $ini('mbstring.internal_encoding') ?: '(default)', ], ]; } /** * GET /ping - Lightweight keep-alive endpoint. * Health/connectivity check. No auth required — session keep-alive * is handled by proactive token refresh on the client side. */ public function ping(ServerRequestInterface $request): ResponseInterface { return ApiResponse::create(['pong' => true]); } public function clearCache(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.system.write'); $query = $request->getQueryParams(); $scope = $query['scope'] ?? 'standard'; $allowedScopes = ['all', 'standard', 'images', 'assets', 'tmp']; if (!in_array($scope, $allowedScopes, true)) { throw new ValidationException( "Invalid cache scope '{$scope}'. Allowed: " . implode(', ', $allowedScopes), ); } $results = $this->grav['cache']->clearCache($scope); return ApiResponse::create([ 'scope' => $scope, 'message' => "Cache cleared successfully (scope: {$scope}).", 'details' => $results, ]); } /** * GET /system/logs/files — list log files registered for the admin viewer. * * Seeds with grav.log / email.log / scheduler.log, then fires * onApiLogFiles so plugins can append their own. The file names returned * here are the only values accepted by GET /system/logs?file=... */ public function logFiles(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.system.read'); $files = $this->getRegisteredLogFiles(); return ApiResponse::create([ 'files' => array_values($files), 'default' => 'grav.log', ]); } public function logs(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.system.read'); $pagination = $this->getPagination($request); $query = $request->getQueryParams(); $levelFilter = $query['level'] ?? null; $search = $query['search'] ?? null; // Validate ?file= against the registered whitelist. Without this an // attacker could read any file the locator can resolve. $registered = $this->getRegisteredLogFiles(); $allowed = array_column($registered, 'file'); $requested = $query['file'] ?? 'grav.log'; if (!in_array($requested, $allowed, true)) { throw new ValidationException('Unknown log file: ' . $requested, [ ['field' => 'file', 'message' => 'Must be one of: ' . implode(', ', $allowed)], ]); } $logFile = $this->grav['locator']->findResource('log://' . $requested); if (!$logFile || !file_exists($logFile)) { return ApiResponse::paginated([], 0, $pagination['page'], $pagination['per_page'], $this->getApiBaseUrl() . '/system/logs'); } $content = file_get_contents($logFile); $lines = explode("\n", $content); $entries = []; foreach ($lines as $line) { if ($line === '' || $line[0] !== '[') { continue; } // Extract date $closeBracket = strpos($line, ']'); if ($closeBracket === false) { continue; } $date = substr($line, 1, $closeBracket - 1); // Extract logger.LEVEL: message $rest = ltrim(substr($line, $closeBracket + 1)); $colonPos = strpos($rest, ':'); if ($colonPos === false) { continue; } $loggerLevel = substr($rest, 0, $colonPos); $dotPos = strpos($loggerLevel, '.'); if ($dotPos === false) { continue; } $logger = substr($loggerLevel, 0, $dotPos); $level = strtoupper(substr($loggerLevel, $dotPos + 1)); $message = trim(substr($rest, $colonPos + 1)); // Strip trailing [] [] $message = preg_replace('/\s*\[\]\s*\[\]\s*$/', '', $message); if ($levelFilter !== null && $level !== strtoupper($levelFilter)) { continue; } if ($search !== null && $search !== '' && stripos($message, $search) === false) { continue; } $entries[] = [ 'date' => $date, 'logger' => $logger, 'level' => $level, 'message' => $message, ]; } $entries = array_reverse($entries); $total = count($entries); $paged = array_slice($entries, $pagination['offset'], $pagination['limit']); return ApiResponse::paginated($paged, $total, $pagination['page'], $pagination['per_page'], $this->getApiBaseUrl() . '/system/logs'); } /** * Build the list of log files available to the admin viewer. * * Seeded with the core logs Grav writes itself, then plugins can append * via onApiLogFiles. Result is deduped by `file` (first wins) so plugins * cannot shadow core log labels. * * @return array */ private function getRegisteredLogFiles(): array { $files = [ ['file' => 'grav.log', 'label' => 'Grav System Log'], ['file' => 'email.log', 'label' => 'Email Log'], ['file' => 'scheduler.log', 'label' => 'Scheduler Log'], ]; $event = $this->fireEvent('onApiLogFiles', ['files' => $files]); $merged = $event['files'] ?? $files; // Dedupe by file name; first occurrence wins so core entries above // are preserved even if a plugin tries to re-register the same name. $seen = []; $result = []; foreach ($merged as $entry) { if (!is_array($entry) || empty($entry['file'])) { continue; } $name = (string) $entry['file']; if (isset($seen[$name])) { continue; } // Strip path components defensively — log names must be simple // basenames so they resolve through the log:// stream. if ($name !== basename($name)) { continue; } $seen[$name] = true; $result[] = [ 'file' => $name, 'label' => (string) ($entry['label'] ?? $name), ]; } return $result; } public function backup(ServerRequestInterface $request): ResponseInterface { // Backups archive the full Grav root, including user/accounts (admin // password hashes) and user/config secrets. Gate creation, listing, // download and deletion behind a dedicated api.system.backup permission // (or api.super) rather than the broader read/write tiers, so only // operators explicitly trusted with the credential-bearing archive can // touch it (GHSA-2f86-9cp8-6hcf). $this->requirePermission($request, 'api.system.backup'); // Ensure backup directory is initialized $backups = $this->grav['backups'] ?? new Backups(); if (method_exists($backups, 'init')) { $backups->init(); } $result = Backups::backup(); $filename = basename($result); $size = file_exists($result) ? filesize($result) : 0; return ApiResponse::created( data: [ 'filename' => $filename, 'path' => $result, 'size' => $size, 'date' => date('c'), ], location: $this->getApiBaseUrl() . '/system/backups', ); } public function backups(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.system.backup'); // Ensure backup directory is initialized before listing $backups = $this->grav['backups'] ?? new Backups(); if (method_exists($backups, 'init')) { $backups->init(); } $list = Backups::getAvailableBackups(true); $items = []; foreach ($list as $backup) { // getAvailableBackups returns stdClass objects, not arrays $b = is_object($backup) ? $backup : (object) $backup; $items[] = [ 'filename' => $b->filename ?? basename($b->path ?? ''), 'title' => $b->title ?? null, 'date' => $b->date ?? null, 'size' => $b->size ?? 0, ]; } // Include purge config for storage usage display $purge = Backups::getPurgeConfig(); return ApiResponse::create([ 'backups' => $items, 'purge' => $purge, 'profiles_count' => count(Backups::getBackupProfiles() ?? []), ]); } /** * DELETE /system/backups/{filename} - Delete a backup file. */ public function deleteBackup(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.system.backup'); $b = $this->grav['backups'] ?? new Backups(); if (method_exists($b, 'init')) { $b->init(); } $filename = $this->getRouteParam($request, 'filename'); // Validate filename (no path traversal) if (!$filename || $filename !== basename($filename) || !str_ends_with($filename, '.zip')) { throw new ValidationException(['filename' => ['Invalid backup filename.']]); } $backupDir = $this->grav['locator']->findResource('backup://', true); $filepath = $backupDir . '/' . $filename; if (!file_exists($filepath)) { throw new NotFoundException("Backup '{$filename}' not found."); } unlink($filepath); return ApiResponse::noContent(); } /** * GET /system/backups/{filename}/download - Download a backup file. */ public function downloadBackup(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.system.backup'); $b = $this->grav['backups'] ?? new Backups(); if (method_exists($b, 'init')) { $b->init(); } $filename = $this->getRouteParam($request, 'filename'); if (!$filename || $filename !== basename($filename) || !str_ends_with($filename, '.zip')) { throw new ValidationException(['filename' => ['Invalid backup filename.']]); } $backupDir = $this->grav['locator']->findResource('backup://', true); $filepath = $backupDir . '/' . $filename; if (!file_exists($filepath)) { throw new NotFoundException("Backup '{$filename}' not found."); } $stream = fopen($filepath, 'rb'); return new \Grav\Framework\Psr7\Response( 200, [ 'Content-Type' => 'application/zip', 'Content-Disposition' => 'attachment; filename="' . $filename . '"', 'Content-Length' => (string) filesize($filepath), ], $stream, ); } /** * GET /translations/{lang} - Get all translation strings for a language. * * Returns a flat key-value object of all translation strings for efficient * client-side caching. Optionally filter by prefix (e.g., ?prefix=PLUGIN_ADMIN). */ public function translations(ServerRequestInterface $request): ResponseInterface { // No auth required — translation strings are not sensitive $lang = $this->getRouteParam($request, 'lang'); $prefix = $request->getQueryParams()['prefix'] ?? null; /** @var \Grav\Common\Language\Language $language */ $language = $this->grav['language']; // Validate language code shape only — admin UI languages are a // different concept from site content languages, so we DO NOT gate // on $language->getLanguages() (which lists languages configured in // system.yaml for site content). Any plugin shipping a `languages/ // .yaml` should be loadable here, even if the site itself only // serves English content. if (!is_string($lang) || !preg_match('/^[a-zA-Z]{2,3}(-[a-zA-Z]{2,4})?$/', $lang)) { $lang = $language->getDefault() ?: 'en-US'; } // Coerce legacy short codes to their BCP 47 canonical form so a request // for `/translations/en` resolves to admin2's `en-US.yaml`. $lang = self::normalizeLangCode($lang); /** @var \Grav\Common\Config\Languages $languages */ $languages = $this->grav['languages']; try { $translations = $languages->flattenByLang($lang); } catch (\Throwable) { $translations = []; } // Strip strings contributed only by disabled plugins. Grav core's // `flattenByLang()` reads every plugin's lang yaml regardless of enabled // state — fine for the legacy admin, broken for admin2: a disabled plugin // would still influence what admin2 renders. The service walks each // plugin's lang yaml to determine provenance and returns keys unique to // disabled plugins. Keys also shipped by enabled sources stay. if (is_array($translations)) { $disabledIndex = new DisabledPluginLangIndex($this->grav); foreach ($disabledIndex->disabledOnlyKeys($lang) as $key) { unset($translations[$key]); } } // Drop flat `` entries when an `ICU.` shadow exists. Admin2 ships // the canonical PLUGIN_ADMIN.* vocabulary under ICU; if a 3rd-party plugin // still using the Grav 1 flat convention is also installed, its values // would otherwise leak into the dictionary served to the client. Keeping // only the ICU side guarantees admin2 is the source of truth. if (is_array($translations)) { foreach (array_keys($translations) as $key) { if (is_string($key) && !str_starts_with($key, 'ICU.') && isset($translations['ICU.' . $key])) { unset($translations[$key]); } } } // Filter by prefix if requested if ($prefix && is_array($translations)) { $prefixLower = strtolower($prefix) . '.'; $translations = array_filter( $translations, fn($key) => str_starts_with(strtolower($key), $prefixLower), ARRAY_FILTER_USE_KEY ); } // Include a checksum for cache invalidation $checksum = md5(json_encode($translations)); return ApiResponse::create([ 'lang' => $lang, 'dir' => LanguageCodes::getOrientation(self::primarySubtag($lang)), 'count' => count($translations), 'checksum' => $checksum, 'strings' => $translations, ]); } /** * GET /admin/languages - Locales the admin UI itself can be rendered in. * * Distinct from GET /languages, which returns *site content* languages * configured in system.yaml. This endpoint returns locales for which a * translation file exists in the admin2 plugin's languages directory — * i.e. languages a user can pick for their admin interface. */ public function adminLanguages(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.system.read'); $dir = GRAV_ROOT . '/user/plugins/admin2/languages'; $languages = []; if (is_dir($dir)) { foreach (glob($dir . '/*.yaml') ?: [] as $file) { $code = basename($file, '.yaml'); $languages[] = [ 'code' => $code, 'name' => LanguageCodes::getName($code) ?: $code, 'native_name' => LanguageCodes::getNativeName($code) ?: $code, 'rtl' => LanguageCodes::isRtl(self::primarySubtag($code)), ]; } } // Stable sort by native name so the dropdown order doesn't depend on // filesystem readdir order. usort($languages, fn($a, $b) => strcmp($a['native_name'], $b['native_name'])); return ApiResponse::create([ 'languages' => $languages, ]); } private function getPluginsInfo(): array { $plugins = []; $gpm = $this->grav['plugins']; foreach ($gpm as $plugin) { $name = $plugin->name; // Plugin::getBlueprint() asserts the plugin's metadata is in // the Plugins manager. On Grav 2.0-rc.2 a number of registered // plugin instances have no companion entry there (login, form, // error, several first-party + side-car plugins), and the // assert blows up for the whole /system/info request. Fall // back to a read-from-disk path so partial info still ships. $bpName = null; $bpVersion = null; if ($gpm->get($name) !== null) { try { $blueprint = $plugin->getBlueprint(); $bpName = $blueprint->get('name'); $bpVersion = $blueprint->get('version'); } catch (\Throwable $e) { // Defensive: even past the null check, blueprint // hydration can throw on malformed yaml. Treat as // metadata-unavailable. } } else { // Direct file read — bypasses Plugin::loadBlueprint() entirely. $file = GRAV_ROOT . "/user/plugins/{$name}/blueprints.yaml"; if (is_file($file)) { try { $raw = \Symfony\Component\Yaml\Yaml::parseFile($file); if (is_array($raw)) { $bpName = $raw['name'] ?? null; $bpVersion = $raw['version'] ?? null; } } catch (\Throwable $e) { // ignore — leave metadata blank } } } $plugins[] = [ 'name' => $bpName ?? $name, 'version' => $bpVersion ?? '0.0.0', 'enabled' => $this->config->get("plugins.{$name}.enabled", false), ]; } return $plugins; } private function getThemesInfo(): array { $themes = []; $activeTheme = $this->config->get('system.pages.theme'); $themesDir = $this->grav['locator']->findResource('themes://'); if (!$themesDir || !is_dir($themesDir)) { return $themes; } $iterator = new \DirectoryIterator($themesDir); foreach ($iterator as $item) { if ($item->isDot() || !$item->isDir()) { continue; } $blueprintFile = $item->getPathname() . '/blueprints.yaml'; if (!file_exists($blueprintFile)) { continue; } $blueprint = \Grav\Common\Yaml::parse(file_get_contents($blueprintFile)); $themeName = $item->getFilename(); $themes[] = [ 'name' => $blueprint['name'] ?? $themeName, 'version' => $blueprint['version'] ?? '0.0.0', 'active' => $themeName === $activeTheme, ]; } return $themes; } /** * Map a raw lang code (`en`, `fr`, `zh-hans`) to its BCP 47 canonical form * (`en-US`, `fr-FR`, `zh-Hans`). Admin2 + admin-next standardize on BCP 47 * for their UI surfaces, so any short or lowercase variant arriving on the * wire is coerced here before disk lookup. Anything not in the alias map * (or already in canonical region/script casing) passes through. */ /** * Primary language subtag of a BCP 47 code. `he-IL` → `he`, `zh-Hans` → * `zh`. Grav core's `LanguageCodes` table is keyed by short codes only, * so any lookup against it has to go through here when the input might * be region/script-qualified. */ private static function primarySubtag(string $code): string { return strtolower(explode('-', $code, 2)[0]); } private static function normalizeLangCode(string $code): string { static $aliases = [ 'en' => 'en-US', 'ar' => 'ar-SA', 'cs' => 'cs-CZ', 'de' => 'de-DE', 'es' => 'es-ES', 'es-mx' => 'es-MX', 'fi' => 'fi-FI', 'fr' => 'fr-FR', 'fr-ca' => 'fr-CA', 'he' => 'he-IL', 'it' => 'it-IT', 'nl' => 'nl-NL', 'pt' => 'pt-PT', 'ru' => 'ru-RU', 'sv' => 'sv-SE', 'uk' => 'uk-UA', 'zh-hans' => 'zh-Hans', 'zh-hant' => 'zh-Hant', ]; $key = strtolower(str_replace('_', '-', trim($code))); if (isset($aliases[$key])) { return $aliases[$key]; } if (preg_match('/^([a-z]{2,3})-([a-z0-9]{2,4})$/i', $code, $m)) { $tag = strlen($m[2]) === 4 ? ucfirst(strtolower($m[2])) : strtoupper($m[2]); return strtolower($m[1]) . '-' . $tag; } return $code; } }