768 lines
29 KiB
PHP
768 lines
29 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Grav\Plugin\Api\Controllers;
|
|
|
|
use Grav\Common\Backup\Backups;
|
|
use Grav\Common\Language\LanguageCodes;
|
|
use Grav\Plugin\Api\Exceptions\NotFoundException;
|
|
use Grav\Plugin\Api\Exceptions\ValidationException;
|
|
use Grav\Plugin\Api\Response\ApiResponse;
|
|
use Grav\Plugin\Api\Services\DisabledPluginLangIndex;
|
|
use Grav\Plugin\Api\Services\EnvironmentService;
|
|
use Psr\Http\Message\ResponseInterface;
|
|
use Psr\Http\Message\ServerRequestInterface;
|
|
|
|
class SystemController extends AbstractApiController
|
|
{
|
|
/**
|
|
* GET /system/environments — list writable environment targets.
|
|
*
|
|
* Response shape:
|
|
* {
|
|
* detected: "host.example", // what Grav inferred from the URL
|
|
* environments: [
|
|
* { name: "", label: "Default", exists: true, hasOverrides: true|false },
|
|
* { name: "staging", exists: true, hasOverrides: true }
|
|
* ]
|
|
* }
|
|
*
|
|
* `name: ""` represents the base user/config target. Any other entry is an
|
|
* existing user/env/<name>/ folder that can be selected as a write target.
|
|
* Legacy user/<host>/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/<name>/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/<name>/ folder.
|
|
*
|
|
* Refuses to delete the env that Grav resolved for the current request, and
|
|
* refuses to act on legacy user/<name>/ 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<int, array{file: string, label: string}>
|
|
*/
|
|
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/
|
|
// <lang>.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 `<key>` entries when an `ICU.<key>` 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;
|
|
}
|
|
}
|