Files
intotheeast-com-content/plugins/api/classes/Api/Controllers/GpmController.php
T

1531 lines
53 KiB
PHP

<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\Cache;
use Grav\Common\GPM\GPM;
use Grav\Common\GPM\Installer;
use Grav\Common\GPM\Licenses;
use Grav\Plugin\Api\Exceptions\ApiException;
use Grav\Plugin\Api\Exceptions\NotFoundException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Api\Serializers\PackageSerializer;
use Grav\Plugin\Api\Services\GpmService;
use Grav\Plugin\Api\Services\ThumbnailService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use RocketTheme\Toolbox\Event\Event;
class GpmController extends AbstractApiController
{
private const PERMISSION_READ = 'api.gpm.read';
private const PERMISSION_WRITE = 'api.gpm.write';
private readonly PackageSerializer $serializer;
private readonly ThumbnailService $thumbSmall;
private readonly ThumbnailService $thumbLarge;
public function __construct(\Grav\Common\Grav $grav, \Grav\Common\Config\Config $config)
{
parent::__construct($grav, $config);
$this->serializer = new PackageSerializer();
$cacheDir = $grav['locator']->findResource('cache://', true, true) . '/api/thumbnails';
$this->thumbSmall = new ThumbnailService($cacheDir, 500);
$this->thumbLarge = new ThumbnailService($cacheDir, 2000);
}
/**
* GET /gpm/plugins - List all installed plugins with update status.
*/
public function plugins(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
$gpm = $this->getGpm();
$installed = $gpm->getInstalledPlugins();
$updatable = $gpm->getUpdatablePlugins();
$plugins = [];
foreach ($installed as $slug => $plugin) {
$data = $this->serializer->serialize($plugin, ['type' => 'plugin', 'installed' => true]);
if (isset($updatable[$slug])) {
$data['available_version'] = $updatable[$slug]->available;
$data['updatable'] = true;
} else {
$data['updatable'] = false;
}
$plugins[] = $data;
}
return ApiResponse::create($plugins);
}
/**
* GET /gpm/plugins/{slug} - Get details for a specific installed plugin.
*/
public function plugin(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
$slug = $this->getRouteParam($request, 'slug');
$gpm = $this->getGpm();
$plugin = $gpm->getInstalledPlugin($slug);
if (!$plugin) {
throw new NotFoundException("Plugin '{$slug}' is not installed.");
}
$data = $this->serializer->serialize($plugin, ['type' => 'plugin', 'installed' => true]);
if ($gpm->isPluginUpdatable($slug)) {
$updatable = $gpm->getUpdatablePlugins();
$data['available_version'] = $updatable[$slug]->available ?? null;
$data['updatable'] = true;
} else {
$data['updatable'] = false;
}
// Discover custom admin-next field components
$customFields = $this->discoverCustomFields($slug, 'plugins');
if ($customFields) {
$data['custom_fields'] = $customFields;
}
return $this->respondWithEtag($data);
}
/**
* GET /gpm/themes - List all installed themes with update status.
*/
public function themes(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
$gpm = $this->getGpm();
$installed = $gpm->getInstalledThemes();
$updatable = $gpm->getUpdatableThemes();
$themes = [];
foreach ($installed as $slug => $theme) {
$data = $this->serializer->serialize($theme, ['type' => 'theme', 'installed' => true]);
if (isset($updatable[$slug])) {
$data['available_version'] = $updatable[$slug]->available;
$data['updatable'] = true;
} else {
$data['updatable'] = false;
}
$images = $this->getThemeImages($slug);
$data['thumbnail'] = $images['thumbnail'];
$data['screenshot'] = $images['screenshot'];
$themes[] = $data;
}
return ApiResponse::create($themes);
}
/**
* GET /gpm/themes/{slug} - Get details for a specific installed theme.
*/
public function theme(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
$slug = $this->getRouteParam($request, 'slug');
$gpm = $this->getGpm();
$theme = $gpm->getInstalledTheme($slug);
if (!$theme) {
throw new NotFoundException("Theme '{$slug}' is not installed.");
}
$data = $this->serializer->serialize($theme, ['type' => 'theme', 'installed' => true]);
if ($gpm->isThemeUpdatable($slug)) {
$updatable = $gpm->getUpdatableThemes();
$data['available_version'] = $updatable[$slug]->available ?? null;
$data['updatable'] = true;
} else {
$data['updatable'] = false;
}
$images = $this->getThemeImages($slug);
$data['thumbnail'] = $images['thumbnail'];
$data['screenshot'] = $images['screenshot'];
// Discover custom admin-next field components (same as plugins)
$customFields = $this->discoverCustomFields($slug, 'themes');
if ($customFields) {
$data['custom_fields'] = $customFields;
}
return $this->respondWithEtag($data);
}
/**
* GET /gpm/updates - Check for available updates (plugins, themes, grav).
*/
public function updates(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
$query = $request->getQueryParams();
$flush = filter_var($query['flush'] ?? false, FILTER_VALIDATE_BOOLEAN);
$gpm = $this->getGpm($flush);
$updatable = $gpm->getUpdatable();
$gravInfo = $gpm->getGrav();
$gravUpdatable = $gravInfo ? $gravInfo->isUpdatable() : false;
$total = ($updatable['total'] ?? 0) + ($gravUpdatable ? 1 : 0);
$data = [
'grav' => [
'current' => GRAV_VERSION,
'available' => $gravInfo ? $gravInfo->getVersion() : null,
'updatable' => $gravUpdatable,
'date' => $gravInfo ? $gravInfo->getDate() : null,
'is_symlink' => $gravInfo ? $gravInfo->isSymlink() : false,
],
'plugins' => $this->serializer->serializeCollection(
$updatable['plugins'] ?? [],
['type' => 'plugin', 'installed' => true]
),
'themes' => $this->serializer->serializeCollection(
$updatable['themes'] ?? [],
['type' => 'theme', 'installed' => true]
),
'total' => $total,
'installed' => $gpm->countInstalled(),
];
return ApiResponse::create($data);
}
/**
* POST /gpm/install - Install a plugin or theme by slug.
*/
public function install(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_WRITE);
$body = $this->getRequestBody($request);
$this->requireFields($body, ['package']);
$package = $body['package'];
$type = $body['type'] ?? 'plugin';
if (!in_array($type, ['plugin', 'theme'], true)) {
throw new ValidationException("Invalid package type '{$type}'. Must be 'plugin' or 'theme'.");
}
// Check if already installed
$gpm = $this->getGpm();
$alreadyInstalled = $type === 'plugin'
? $gpm->isPluginInstalled($package)
: $gpm->isThemeInstalled($package);
if ($alreadyInstalled) {
throw new ValidationException(ucfirst($type) . " '{$package}' is already installed. Use the update endpoint to update it.");
}
// Handle premium license — store if provided, check if needed
$license = $body['license'] ?? null;
if ($license) {
if (!Licenses::validate($license)) {
throw new ValidationException(
"Invalid license format. Expected: XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX (uppercase hex)."
);
}
Licenses::set($package, $license);
}
// Check if premium package has a license available
$repoPackage = $gpm->findPackage($package, true);
if ($repoPackage && !empty($repoPackage->premium) && !Licenses::get($package)) {
throw new ValidationException(
"'{$package}' is a premium package and requires a license. Pass a 'license' field in the request body, or upload a license via the license-manager plugin/API."
);
}
$this->fireEvent('onApiBeforePackageInstall', [
'package' => $package,
'type' => $type,
]);
try {
$gpm->checkPackagesCanBeInstalled([$package]);
$dependencies = $gpm->getDependencies([$package]);
} catch (\Throwable $e) {
throw new ValidationException($this->stripGpmColorTags($e->getMessage()));
}
$depsToInstall = [];
foreach ($dependencies as $slug => $action) {
if ($action === 'install' || $action === 'update') {
$depsToInstall[] = (string) $slug;
}
}
// Install each dependency individually so we can report exactly which
// ones succeeded if a later one fails partway through.
$installedDeps = [];
foreach ($depsToInstall as $depSlug) {
try {
$depResult = GpmService::install($depSlug, ['theme' => false]);
} catch (\Throwable $e) {
throw new ApiException(
500,
'Installation Failed',
$this->partialFailureMessage(
sprintf(
"Failed to install dependency '%s' for %s '%s': %s",
$depSlug,
$type,
$package,
$this->stripGpmColorTags($e->getMessage())
),
$installedDeps
)
);
}
if ($depResult !== true && !is_string($depResult)) {
throw new ApiException(
500,
'Installation Failed',
$this->partialFailureMessage(
"Failed to install dependency '{$depSlug}' for {$type} '{$package}'.",
$installedDeps
)
);
}
$installedDeps[] = $depSlug;
}
try {
$result = GpmService::install($package, [
'theme' => $type === 'theme',
'install_deps' => false,
]);
} catch (\Throwable $e) {
throw new ApiException(
500,
'Installation Failed',
$this->partialFailureMessage(
$this->stripGpmColorTags($e->getMessage()),
$installedDeps
)
);
}
if ($result !== true && !is_string($result)) {
throw new ApiException(500, 'Installation Failed', "Failed to install {$type} '{$package}'.");
}
$this->fireEvent('onApiPackageInstalled', [
'package' => $package,
'type' => $type,
'dependencies' => $installedDeps,
]);
$tags = $type === 'plugin'
? ['plugins:create:' . $package, 'plugins:list', 'gpm:update']
: ['themes:create:' . $package, 'themes:list', 'gpm:update'];
foreach ($installedDeps as $depSlug) {
$tags[] = 'plugins:create:' . $depSlug;
}
if (!empty($installedDeps)) {
$tags[] = 'plugins:list';
}
$message = ucfirst($type) . " '{$package}' installed successfully.";
if (!empty($installedDeps)) {
$message .= ' Dependencies installed: ' . implode(', ', $installedDeps) . '.';
}
return ApiResponse::create(
[
'message' => $message,
'package' => $package,
'type' => $type,
'dependencies' => $installedDeps,
],
201,
$this->invalidationHeaders(array_values(array_unique($tags))),
);
}
/**
* POST /gpm/remove - Remove a plugin or theme.
*/
public function remove(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_WRITE);
$body = $this->getRequestBody($request);
$this->requireFields($body, ['package']);
$package = $body['package'];
// Check if the package is installed
$gpm = $this->getGpm();
$isPlugin = $gpm->isPluginInstalled($package);
$isTheme = $gpm->isThemeInstalled($package);
if (!$isPlugin && !$isTheme) {
throw new NotFoundException("Package '{$package}' is not installed.");
}
$type = $isPlugin ? 'plugin' : 'theme';
$this->fireEvent('onApiBeforePackageRemove', [
'package' => $package,
'type' => $type,
]);
try {
$result = GpmService::uninstall($package, []);
} catch (\Throwable $e) {
throw new ApiException(500, 'Removal Failed', $e->getMessage());
}
if ($result !== true) {
$message = is_string($result) ? $result : "Failed to remove {$type} '{$package}'.";
throw new ApiException(500, 'Removal Failed', $message);
}
$this->fireEvent('onApiPackageRemoved', [
'package' => $package,
'type' => $type,
]);
$tags = $type === 'plugin'
? ['plugins:delete:' . $package, 'plugins:list', 'gpm:update']
: ['themes:delete:' . $package, 'themes:list', 'gpm:update'];
return ApiResponse::noContent($this->invalidationHeaders($tags));
}
/**
* POST /gpm/update - Update a specific plugin or theme.
*/
public function update(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_WRITE);
$body = $this->getRequestBody($request);
$this->requireFields($body, ['package']);
$package = $body['package'];
$gpm = $this->getGpm();
if (!$gpm->isUpdatable($package)) {
throw new ValidationException("Package '{$package}' is not updatable or not installed.");
}
$isTheme = $gpm->isThemeInstalled($package);
$type = $isTheme ? 'theme' : 'plugin';
$this->fireEvent('onApiBeforePackageUpdate', [
'package' => $package,
'type' => $type,
]);
try {
$gpm->checkPackagesCanBeInstalled([$package]);
$dependencies = $gpm->getDependencies([$package]);
} catch (\Throwable $e) {
throw new ValidationException($this->stripGpmColorTags($e->getMessage()));
}
$depsToInstall = [];
foreach ($dependencies as $slug => $action) {
if ($action === 'install' || $action === 'update') {
$depsToInstall[] = (string) $slug;
}
}
// Install each dependency individually so partial success is reportable.
$installedDeps = [];
foreach ($depsToInstall as $depSlug) {
try {
$depResult = GpmService::install($depSlug, ['theme' => false]);
} catch (\Throwable $e) {
throw new ApiException(
500,
'Update Failed',
$this->partialFailureMessage(
sprintf(
"Failed to install dependency '%s' for '%s': %s",
$depSlug,
$package,
$this->stripGpmColorTags($e->getMessage())
),
$installedDeps
)
);
}
if ($depResult !== true && !is_string($depResult)) {
throw new ApiException(
500,
'Update Failed',
$this->partialFailureMessage(
"Failed to install dependency '{$depSlug}' for '{$package}'.",
$installedDeps
)
);
}
$installedDeps[] = $depSlug;
}
try {
$result = GpmService::update($package, [
'theme' => $isTheme,
'install_deps' => false,
]);
} catch (\Throwable $e) {
throw new ApiException(
500,
'Update Failed',
$this->partialFailureMessage(
$this->stripGpmColorTags($e->getMessage()),
$installedDeps
)
);
}
if ($result !== true && !is_string($result)) {
throw new ApiException(
500,
'Update Failed',
$this->partialFailureMessage("Failed to update '{$package}'.", $installedDeps)
);
}
$this->fireEvent('onApiPackageUpdated', [
'package' => $package,
'type' => $type,
'dependencies' => $installedDeps,
]);
$tags = [
$type === 'theme' ? 'themes:update:' . $package : 'plugins:update:' . $package,
$type === 'theme' ? 'themes:list' : 'plugins:list',
'gpm:update',
];
foreach ($installedDeps as $depSlug) {
$tags[] = 'plugins:create:' . $depSlug;
}
if (!empty($installedDeps)) {
$tags[] = 'plugins:list';
}
$message = "Package '{$package}' updated successfully.";
if (!empty($installedDeps)) {
$message .= ' Dependencies installed: ' . implode(', ', $installedDeps) . '.';
}
return ApiResponse::create(
[
'message' => $message,
'package' => $package,
'type' => $type,
'dependencies' => $installedDeps,
],
200,
$this->invalidationHeaders(array_values(array_unique($tags))),
);
}
/**
* POST /gpm/update-all - Update all updatable packages.
*
* Each package goes through the same dependency validation as the per-package
* `update` endpoint: GPM-registered Grav/PHP requirements must be satisfied,
* and any plugin-deps that themselves need an update are processed first.
* Packages whose requirements aren't met land in `failed[]` with a
* toast-friendly message; packages already brought current as a cascade dep
* of a prior iteration land in `skipped[]`.
*/
public function updateAll(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_WRITE);
$gpm = $this->getGpm(true);
$updatable = $gpm->getUpdatable();
$results = ['updated' => [], 'failed' => [], 'skipped' => []];
$cascadedDeps = [];
$packages = [];
foreach (array_keys($updatable['plugins'] ?? []) as $slug) {
$packages[] = ['slug' => (string) $slug, 'isTheme' => false];
}
foreach (array_keys($updatable['themes'] ?? []) as $slug) {
$packages[] = ['slug' => (string) $slug, 'isTheme' => true];
}
foreach ($packages as ['slug' => $slug, 'isTheme' => $isTheme]) {
// A prior iteration may have already cascaded this package as a dep.
// We can't reuse $gpm->isUpdatable() to detect this: the initial
// $gpm->getUpdatable() call above mutates the shared Remote\Package
// ::$version (Grav core's CachedCollection holds Remote\Packages
// statically, and getUpdatablePlugins() rewrites $version to the
// local version on hit). Subsequent isUpdatable() reads then see
// remote==local and report "not updatable" for everything.
if (isset($cascadedDeps[$slug])) {
$results['skipped'][] = ['package' => $slug, 'reason' => 'already up to date (installed as a dependency)'];
continue;
}
try {
$gpm->checkPackagesCanBeInstalled([$slug]);
$dependencies = $gpm->getDependencies([$slug]);
} catch (\Throwable $e) {
$results['failed'][] = [
'package' => $slug,
'error' => $this->stripGpmColorTags($e->getMessage()),
];
continue;
}
$depsToInstall = [];
foreach ($dependencies as $depSlug => $action) {
if ($action === 'install' || $action === 'update') {
$depsToInstall[] = (string) $depSlug;
}
}
$installedDeps = [];
$depFailed = false;
foreach ($depsToInstall as $depSlug) {
try {
$depResult = $this->installPackage($depSlug, ['theme' => false]);
} catch (\Throwable $e) {
$results['failed'][] = [
'package' => $slug,
'error' => $this->partialFailureMessage(
sprintf(
"Failed to install dependency '%s': %s",
$depSlug,
$this->stripGpmColorTags($e->getMessage())
),
$installedDeps
),
];
$depFailed = true;
break;
}
if ($depResult !== true && !is_string($depResult)) {
$results['failed'][] = [
'package' => $slug,
'error' => $this->partialFailureMessage("Failed to install dependency '{$depSlug}'.", $installedDeps),
];
$depFailed = true;
break;
}
$installedDeps[] = $depSlug;
$cascadedDeps[$depSlug] = true;
}
if ($depFailed) {
continue;
}
try {
$result = $this->updatePackage($slug, [
'theme' => $isTheme,
'install_deps' => false,
]);
} catch (\Throwable $e) {
$results['failed'][] = [
'package' => $slug,
'error' => $this->partialFailureMessage(
$this->stripGpmColorTags($e->getMessage()),
$installedDeps
),
];
continue;
}
if ($result !== true && !is_string($result)) {
$results['failed'][] = [
'package' => $slug,
'error' => $this->partialFailureMessage("Failed to update '{$slug}'.", $installedDeps),
];
continue;
}
$results['updated'][] = $slug;
}
// Surface cascaded deps as a separate field so callers can show "also updated as deps: x, y".
$results['cascaded_dependencies'] = array_values(array_keys($cascadedDeps));
return ApiResponse::create(
$results,
200,
$this->invalidationHeaders(['plugins:list', 'themes:list', 'gpm:update']),
);
}
/**
* POST /gpm/upgrade - Self-upgrade Grav core.
*/
public function upgrade(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_WRITE);
$gpm = $this->getGpm(true);
$gravInfo = $gpm->getGrav();
if (!$gravInfo || !$gravInfo->isUpdatable()) {
throw new ValidationException('Grav is already at the latest version.');
}
if ($gravInfo->isSymlink()) {
throw new ValidationException('Cannot upgrade Grav when installed via symlink.');
}
$body = $this->getRequestBody($request);
$override = !empty($body['override']);
$this->fireEvent('onApiBeforeGravUpgrade', [
'current_version' => GRAV_VERSION,
'available_version' => $gravInfo->getVersion(),
]);
try {
$result = GpmService::selfupgrade(['override' => $override]);
} catch (\Throwable $e) {
$this->grav['log']->error('[api] Grav self-upgrade failed: ' . $e->getMessage());
throw new ApiException(500, 'Upgrade Failed', $e->getMessage(), [], $e);
}
if (!$result) {
$report = GpmService::getLastPreflightReport();
$blocking = $report['blocking'] ?? [];
// Recoverable: preflight blocked the upgrade and the caller can act on it
// (disable the offending packages, or retry with {"override": true}).
if (!$override && !empty($blocking)) {
return ApiResponse::create([
'status' => 'preflight_failed',
'message' => 'Upgrade blocked by preflight checks.',
'blocking' => $blocking,
'warnings' => $report['warnings'] ?? [],
'incompatible_packages' => $report['incompatible_packages'] ?? [],
'can_override' => true,
], 409);
}
$detail = GpmService::getLastError() ?: 'Failed to upgrade Grav core.';
$this->grav['log']->error('[api] Grav self-upgrade failed: ' . $detail);
throw new ApiException(500, 'Upgrade Failed', $detail);
}
$this->fireEvent('onApiGravUpgraded', [
'previous_version' => GRAV_VERSION,
'new_version' => $gravInfo->getVersion(),
]);
return ApiResponse::create(
[
'message' => 'Grav upgraded successfully.',
'previous_version' => GRAV_VERSION,
'new_version' => $gravInfo->getVersion(),
],
200,
$this->invalidationHeaders(['grav:update', 'gpm:update']),
);
}
/**
* POST /gpm/direct-install - Install from URL or uploaded zip.
*/
public function directInstall(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_WRITE);
$body = $this->getRequestBody($request);
// Support URL-based install
if (isset($body['url'])) {
$packageFile = $body['url'];
} else {
// Check for uploaded file
$uploadedFiles = $request->getUploadedFiles();
$file = $uploadedFiles['file'] ?? null;
if (!$file || $file->getError() !== UPLOAD_ERR_OK) {
throw new ValidationException('Either a "url" field or an uploaded "file" is required.');
}
// Move uploaded file to tmp
$tmpDir = $this->grav['locator']->findResource('tmp://', true, true);
$tmpFile = $tmpDir . '/api-upload-' . uniqid() . '.zip';
$file->moveTo($tmpFile);
$packageFile = $tmpFile;
}
try {
$result = GpmService::directInstall($packageFile);
} catch (\Throwable $e) {
// Clean up tmp file on error
if (isset($tmpFile) && file_exists($tmpFile)) {
@unlink($tmpFile);
}
throw new ApiException(500, 'Installation Failed', $e->getMessage());
}
// Clean up tmp file if we created one
if (isset($tmpFile) && file_exists($tmpFile)) {
@unlink($tmpFile);
}
if ($result !== true) {
$message = is_string($result) ? $result : 'Direct install failed.';
throw new ApiException(500, 'Installation Failed', $message);
}
return ApiResponse::create(
['message' => 'Package installed successfully via direct install.'],
201,
$this->invalidationHeaders(['plugins:list', 'themes:list', 'gpm:update']),
);
}
/**
* GET /gpm/repository/plugins - List available plugins from GPM repository.
*/
public function repositoryPlugins(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
$pagination = $this->getPagination($request);
// Allow fetching all repository packages (the install modal needs the full list)
$query = $request->getQueryParams();
if (isset($query['per_page']) && (int) $query['per_page'] > $pagination['per_page']) {
$requested = min(2000, (int) $query['per_page']);
$pagination['per_page'] = $requested;
$pagination['limit'] = $requested;
}
$gpm = $this->getGpm();
$repoPlugins = $gpm->getRepositoryPlugins();
if ($repoPlugins === null) {
throw new ApiException(502, 'Bad Gateway', 'Unable to reach GPM repository.');
}
$query = $request->getQueryParams();
$search = $query['q'] ?? null;
$allPlugins = [];
foreach ($repoPlugins as $slug => $plugin) {
if ($search && !$this->matchesSearch($plugin, $slug, $search)) {
continue;
}
$data = $this->serializer->serialize($plugin, ['type' => 'plugin']);
$data['installed'] = $gpm->isPluginInstalled($slug);
$allPlugins[] = $data;
}
$total = count($allPlugins);
$slice = array_slice($allPlugins, $pagination['offset'], $pagination['limit']);
$baseUrl = $this->getApiBaseUrl() . '/gpm/repository/plugins';
return ApiResponse::paginated(
data: $slice,
total: $total,
page: $pagination['page'],
perPage: $pagination['per_page'],
baseUrl: $baseUrl,
);
}
/**
* GET /gpm/repository/themes - List available themes from GPM repository.
*/
public function repositoryThemes(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
$pagination = $this->getPagination($request);
$query = $request->getQueryParams();
if (isset($query['per_page']) && (int) $query['per_page'] > $pagination['per_page']) {
$requested = min(2000, (int) $query['per_page']);
$pagination['per_page'] = $requested;
$pagination['limit'] = $requested;
}
$gpm = $this->getGpm();
$repoThemes = $gpm->getRepositoryThemes();
if ($repoThemes === null) {
throw new ApiException(502, 'Bad Gateway', 'Unable to reach GPM repository.');
}
$query = $request->getQueryParams();
$search = $query['q'] ?? null;
$allThemes = [];
foreach ($repoThemes as $slug => $theme) {
if ($search && !$this->matchesSearch($theme, $slug, $search)) {
continue;
}
$data = $this->serializer->serialize($theme, ['type' => 'theme']);
$data['installed'] = $gpm->isThemeInstalled($slug);
$allThemes[] = $data;
}
$total = count($allThemes);
$slice = array_slice($allThemes, $pagination['offset'], $pagination['limit']);
$baseUrl = $this->getApiBaseUrl() . '/gpm/repository/themes';
return ApiResponse::paginated(
data: $slice,
total: $total,
page: $pagination['page'],
perPage: $pagination['per_page'],
baseUrl: $baseUrl,
);
}
/**
* GET /gpm/repository/{slug} - Get repository details for a package.
*/
public function repositoryPackage(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
$slug = $this->getRouteParam($request, 'slug');
$gpm = $this->getGpm();
$package = $gpm->findPackage($slug, true);
if (!$package) {
throw new NotFoundException("Package '{$slug}' not found in GPM repository.");
}
$isPlugin = $gpm->getRepositoryPlugin($slug) !== null;
$type = $isPlugin ? 'plugin' : 'theme';
$data = $this->serializer->serialize($package, ['type' => $type]);
$data['installed'] = $isPlugin
? $gpm->isPluginInstalled($slug)
: $gpm->isThemeInstalled($slug);
return ApiResponse::create($data);
}
/**
* GET /gpm/search - Search across all repository packages (plugins + themes).
*/
public function search(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
$query = $request->getQueryParams();
$search = $query['q'] ?? null;
if (!$search || trim($search) === '') {
throw new ValidationException("The 'q' query parameter is required for search.");
}
$pagination = $this->getPagination($request);
$gpm = $this->getGpm();
$results = [];
$repoPlugins = $gpm->getRepositoryPlugins();
if ($repoPlugins) {
foreach ($repoPlugins as $slug => $plugin) {
if ($this->matchesSearch($plugin, $slug, $search)) {
$data = $this->serializer->serialize($plugin, ['type' => 'plugin']);
$data['installed'] = $gpm->isPluginInstalled($slug);
$results[] = $data;
}
}
}
$repoThemes = $gpm->getRepositoryThemes();
if ($repoThemes) {
foreach ($repoThemes as $slug => $theme) {
if ($this->matchesSearch($theme, $slug, $search)) {
$data = $this->serializer->serialize($theme, ['type' => 'theme']);
$data['installed'] = $gpm->isThemeInstalled($slug);
$results[] = $data;
}
}
}
$total = count($results);
$slice = array_slice($results, $pagination['offset'], $pagination['limit']);
$baseUrl = $this->getApiBaseUrl() . '/gpm/search';
return ApiResponse::paginated(
data: $slice,
total: $total,
page: $pagination['page'],
perPage: $pagination['per_page'],
baseUrl: $baseUrl,
);
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
/**
* Get a GPM instance.
*
* Protected (not private) so test subclasses can return a mock GPM
* without instantiating the real one (which touches the filesystem
* and remote GPM repository on construction).
*/
protected function getGpm(bool $refresh = false): GPM
{
return new GPM($refresh);
}
/**
* Install a package via GpmService. Wrapper exists so test subclasses
* can stub the install side-effect without calling the static service
* (which performs network downloads and filesystem writes).
*
* @param array<string, mixed> $options
* @return string|bool
*/
protected function installPackage(string $slug, array $options)
{
return GpmService::install($slug, $options);
}
/**
* Update a package via GpmService. See installPackage() for rationale.
*
* @param array<string, mixed> $options
* @return string|bool
*/
protected function updatePackage(string $slug, array $options)
{
return GpmService::update($slug, $options);
}
/**
* Strip Grav CLI color markup (e.g. <red>..</red>, <cyan>..</cyan>) from
* exception messages so they read cleanly in API responses.
*/
private function stripGpmColorTags(string $message): string
{
return preg_replace(
'#</?(?:red|green|yellow|blue|magenta|cyan|white|black|light-red|light-green|light-yellow|light-blue|light-magenta|light-cyan|light-gray|dark-gray)>#i',
'',
$message
) ?? $message;
}
/**
* Append a note about dependencies that were already installed before the
* failure, so callers can see the partial state without a separate probe.
*
* @param string[] $installedDeps
*/
private function partialFailureMessage(string $message, array $installedDeps): string
{
if (empty($installedDeps)) {
return $message;
}
return $message . ' Dependencies already installed before failure: ' . implode(', ', $installedDeps) . '.';
}
/**
* Resolve thumbnail and screenshot URLs for an installed theme.
* Returns ['thumbnail' => url|null, 'screenshot' => url|null].
*/
private function getThemeImages(string $slug): array
{
$result = ['thumbnail' => null, 'screenshot' => null];
try {
$path = $this->resolvePackagePath($slug, 'themes');
} catch (NotFoundException) {
return $result;
}
// Thumbnail (small, capped at 500px for list views)
foreach (['thumbnail.jpg', 'thumbnail.png'] as $file) {
$source = $path . '/' . $file;
if (file_exists($source)) {
$filename = $this->thumbSmall->getThumbnailFilename($source);
if ($filename) {
$this->thumbSmall->getThumbnail($source);
$result['thumbnail'] = $this->getApiBaseUrl() . '/thumbnails/' . $filename;
break;
}
}
}
// Screenshot (large, capped at 2000px for detail/preview)
foreach (['screenshot.jpg', 'screenshot.png'] as $file) {
$source = $path . '/' . $file;
if (file_exists($source)) {
$filename = $this->thumbLarge->getThumbnailFilename($source);
if ($filename) {
$this->thumbLarge->getThumbnail($source);
$result['screenshot'] = $this->getApiBaseUrl() . '/thumbnails/' . $filename;
break;
}
}
}
// Fall back: if no thumbnail but screenshot exists, use screenshot for both
if (!$result['thumbnail'] && $result['screenshot']) {
$result['thumbnail'] = $result['screenshot'];
}
// Vice versa
if (!$result['screenshot'] && $result['thumbnail']) {
$result['screenshot'] = $result['thumbnail'];
}
return $result;
}
/**
* Check if a package matches a search query (slug, name, description, author, keywords).
*/
private function matchesSearch(object $package, string $slug, string $search): bool
{
$search = strtolower($search);
// Match against slug
if (str_contains(strtolower($slug), $search)) {
return true;
}
// Match against name
$name = $package->name ?? '';
if ($name && str_contains(strtolower($name), $search)) {
return true;
}
// Match against description
$description = $package->description ?? '';
if ($description && str_contains(strtolower($description), $search)) {
return true;
}
// Match against author name
$author = $package->author ?? null;
if ($author) {
$authorName = is_object($author) ? ($author->name ?? '') : ($author['name'] ?? '');
if ($authorName && str_contains(strtolower($authorName), $search)) {
return true;
}
}
// Match against keywords
$keywords = $package->keywords ?? [];
if (is_array($keywords)) {
foreach ($keywords as $keyword) {
if (str_contains(strtolower($keyword), $search)) {
return true;
}
}
}
return false;
}
/**
* GET /gpm/plugins/{slug}/readme - Get plugin README.md content.
* GET /gpm/themes/{slug}/readme
*/
public function readme(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
$slug = $this->getRouteParam($request, 'slug');
$type = str_contains($request->getUri()->getPath(), '/themes/') ? 'themes' : 'plugins';
$path = $this->resolvePackagePath($slug, $type);
$file = $path . '/README.md';
if (!file_exists($file)) {
throw new NotFoundException("No README found for '{$slug}'.");
}
return ApiResponse::create([
'content' => file_get_contents($file),
]);
}
/**
* GET /gpm/plugins/{slug}/changelog - Get plugin CHANGELOG.md content.
* GET /gpm/themes/{slug}/changelog
*/
public function changelog(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
$slug = $this->getRouteParam($request, 'slug');
$type = str_contains($request->getUri()->getPath(), '/themes/') ? 'themes' : 'plugins';
$path = $this->resolvePackagePath($slug, $type);
$file = $path . '/CHANGELOG.md';
if (!file_exists($file)) {
throw new NotFoundException("No changelog found for '{$slug}'.");
}
return ApiResponse::create([
'content' => file_get_contents($file),
]);
}
/**
* Resolve the filesystem path for an installed package.
*/
private function resolvePackagePath(string $slug, string $type): string
{
if (
$slug === ''
|| $slug === '.'
|| $slug === '..'
|| str_contains($slug, '/')
|| str_contains($slug, '\\')
|| str_contains($slug, "\0")
) {
throw new ValidationException("Invalid package slug '{$slug}'.");
}
$base = $type === 'themes' ? 'themes' : 'plugins';
$path = $this->grav['locator']->findResource("user://{$base}/{$slug}", true);
if (!$path || !is_dir($path)) {
throw new NotFoundException("Package '{$slug}' not found.");
}
return $path;
}
/**
* Discover custom admin-next field web components shipped by a package.
*
* Convention: plugins place field components at admin-next/fields/{type}.js
* Each JS file should define a Custom Element that admin-next will load
* on demand when encountering an unknown field type.
*
* @return array<string, string>|null Map of field type → relative script path, or null if none
*/
private function discoverCustomFields(string $slug, string $type): ?array
{
try {
$path = $this->resolvePackagePath($slug, $type);
} catch (NotFoundException) {
return null;
}
$fieldsDir = $path . '/admin-next/fields';
if (!is_dir($fieldsDir)) {
return null;
}
$fields = [];
foreach (new \DirectoryIterator($fieldsDir) as $file) {
if ($file->isDot() || !$file->isFile()) {
continue;
}
if ($file->getExtension() === 'js') {
$fieldType = $file->getBasename('.js');
$fields[$fieldType] = $fieldType;
}
}
return $fields ?: null;
}
/**
* GET /custom-fields — Return all custom field registrations from all enabled plugins and themes.
*
* Returns a map of field type → plugin/theme slug so admin-next can
* pre-populate the custom field registry at startup.
*/
public function allCustomFields(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
$gpm = $this->getGpm();
$allFields = [];
// Scan enabled plugins
foreach ($gpm->getInstalledPlugins() as $slug => $plugin) {
if (!$this->config->get("plugins.{$slug}.enabled", false)) {
continue;
}
$fields = $this->discoverCustomFields($slug, 'plugins');
if ($fields) {
foreach ($fields as $fieldType => $label) {
// Provider kind lets admin-next fetch the script from the
// correct /gpm/{kind}/{slug}/field/{type} route.
$allFields[$fieldType] = ['slug' => $slug, 'kind' => 'plugins'];
}
}
}
// Scan installed themes
foreach ($gpm->getInstalledThemes() as $slug => $theme) {
$fields = $this->discoverCustomFields($slug, 'themes');
if ($fields) {
foreach ($fields as $fieldType => $label) {
$allFields[$fieldType] = ['slug' => $slug, 'kind' => 'themes'];
}
}
}
return ApiResponse::create($allFields);
}
/**
* GET /gpm/{plugins|themes}/{slug}/field/{type} - Serve a custom field web component JS.
*
* Returns the JavaScript file for a custom admin-next field component.
* The response is cached aggressively (1 year) since the content only
* changes when the plugin is updated.
*/
public function customFieldScript(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
$slug = $this->getRouteParam($request, 'slug');
$fieldType = $this->getRouteParam($request, 'type');
$pkgType = str_contains($request->getUri()->getPath(), '/themes/') ? 'themes' : 'plugins';
$path = $this->resolvePackagePath($slug, $pkgType);
$file = $path . '/admin-next/fields/' . basename($fieldType) . '.js';
if (!file_exists($file)) {
throw new NotFoundException("Custom field '{$fieldType}' not found for '{$slug}'.");
}
$content = file_get_contents($file);
return new \Grav\Framework\Psr7\Response(
200,
[
'Content-Type' => 'application/javascript; charset=utf-8',
'Cache-Control' => 'no-cache',
],
$content,
);
}
/**
* GET /gpm/plugins/{slug}/page — Get plugin page definition.
*
* Resolution order:
* 1. Fire onApiPluginPageInfo event (plugin provides definition)
* 2. Filesystem: admin-next/pages/{slug}.yaml definition file
* 3. Filesystem: admin-next/pages/{slug}.js → infer component mode
* 4. 404
*/
public function pluginPage(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
$slug = $this->getRouteParam($request, 'slug');
// 1. Try event-based definition
$event = new Event([
'plugin' => $slug,
'definition' => null,
'user' => $this->getUser($request),
]);
$this->grav->fireEvent('onApiPluginPageInfo', $event);
if ($event['definition']) {
$definition = $event['definition'];
// Check if a page web component exists
$definition['has_custom_component'] = $this->hasPluginPageScript($slug);
return ApiResponse::create($definition);
}
// 2. Try filesystem discovery
$definition = $this->discoverPluginPage($slug);
if ($definition) {
return ApiResponse::create($definition);
}
throw new NotFoundException("No admin page found for plugin '{$slug}'.");
}
/**
* GET /gpm/plugins/{slug}/page-script — Serve a plugin page web component JS.
*/
public function customPageScript(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
$slug = $this->getRouteParam($request, 'slug');
$path = $this->resolvePackagePath($slug, 'plugins');
$file = $path . '/admin-next/pages/' . basename($slug) . '.js';
if (!file_exists($file)) {
throw new NotFoundException("Page component not found for plugin '{$slug}'.");
}
$content = file_get_contents($file);
return new \Grav\Framework\Psr7\Response(
200,
[
'Content-Type' => 'application/javascript; charset=utf-8',
'Cache-Control' => 'no-cache',
],
$content,
);
}
/**
* GET /gpm/plugins/{slug}/widget-script — Serve a floating widget web component JS.
*
* Convention: admin-next/widgets/{slug}.js
*/
public function widgetScript(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
$slug = $this->getRouteParam($request, 'slug');
$path = $this->resolvePackagePath($slug, 'plugins');
$file = $path . '/admin-next/widgets/' . basename($slug) . '.js';
if (!file_exists($file)) {
throw new NotFoundException("Widget component not found for plugin '{$slug}'.");
}
$content = file_get_contents($file);
return new \Grav\Framework\Psr7\Response(
200,
[
'Content-Type' => 'application/javascript; charset=utf-8',
'Cache-Control' => 'no-cache',
],
$content,
);
}
/**
* GET /gpm/plugins/{slug}/panel-script — Serve a context panel web component JS.
*
* Convention: admin-next/panels/{slug}.js
*/
public function panelScript(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
$slug = $this->getRouteParam($request, 'slug');
$path = $this->resolvePackagePath($slug, 'plugins');
$file = $path . '/admin-next/panels/' . basename($slug) . '.js';
if (!file_exists($file)) {
throw new NotFoundException("Panel component not found for plugin '{$slug}'.");
}
$content = file_get_contents($file);
return new \Grav\Framework\Psr7\Response(
200,
[
'Content-Type' => 'application/javascript; charset=utf-8',
'Cache-Control' => 'no-cache',
],
$content,
);
}
/**
* GET /gpm/plugins/{slug}/report-script/{reportId} - Serve a report web component JS.
*
* Convention: admin-next/reports/{reportId}.js
*/
public function reportScript(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, self::PERMISSION_READ);
$slug = $this->getRouteParam($request, 'slug');
$reportId = $this->getRouteParam($request, 'reportId');
$path = $this->resolvePackagePath($slug, 'plugins');
$file = $path . '/admin-next/reports/' . basename($reportId) . '.js';
if (!file_exists($file)) {
throw new NotFoundException("Report component '{$reportId}' not found for plugin '{$slug}'.");
}
$content = file_get_contents($file);
return new \Grav\Framework\Psr7\Response(
200,
[
'Content-Type' => 'application/javascript; charset=utf-8',
'Cache-Control' => 'no-cache',
],
$content,
);
}
/**
* Discover a plugin page definition from filesystem conventions.
*
* Checks for admin-next/pages/{slug}.yaml and admin-next/pages/{slug}.js
*/
private function discoverPluginPage(string $slug): ?array
{
try {
$path = $this->resolvePackagePath($slug, 'plugins');
} catch (NotFoundException) {
return null;
}
$pagesDir = $path . '/admin-next/pages';
$yamlFile = $pagesDir . '/' . $slug . '.yaml';
$jsFile = $pagesDir . '/' . $slug . '.js';
// Try YAML definition
if (file_exists($yamlFile)) {
$data = \Grav\Common\Yaml::parse(file_get_contents($yamlFile));
if (is_array($data)) {
$data['has_custom_component'] = file_exists($jsFile);
return $data;
}
}
// Try JS component only (infer component mode)
if (file_exists($jsFile)) {
return [
'id' => $slug,
'plugin' => $slug,
'title' => ucwords(str_replace('-', ' ', $slug)),
'page_type' => 'component',
'has_custom_component' => true,
];
}
return null;
}
/**
* Check if a plugin ships a page-level web component.
*/
private function hasPluginPageScript(string $slug): bool
{
try {
$path = $this->resolvePackagePath($slug, 'plugins');
return file_exists($path . '/admin-next/pages/' . basename($slug) . '.js');
} catch (NotFoundException) {
return false;
}
}
}