From 3975e8e205b5d1a22282aef104020a5bf82fe1cc Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 30 May 2026 14:06:46 -0500 Subject: [PATCH] feat(api): add extensions list endpoint with filters and update server info GET /?mokowaas=extensions and GET /api/v1/mokowaas/extensions returns all installed extensions with version, enabled/protected/locked status, and update server details. Supports ?type, ?search, and ?enabled filters. Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + .../src/Controller/ExtensionsController.php | 187 ++++++++++++++++++ .../Extension/MokoWaaS.php | 135 ++++++++++++- .../src/Extension/MokoWaaSApi.php | 6 + wiki/api-endpoints.md | 51 +++++ 5 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 src/packages/com_mokowaas/api/src/Controller/ExtensionsController.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 7deb1753..0c83e9c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ - Content Sync: configurable sync targets with URL + API token in plugin settings - Package installer: protect all MokoWaaS extensions (not just system plugin) and ensure update server stays enabled - Package installer: clean up legacy `mokowaasbrand` extension entries and files on install/update +- API endpoint `GET /?mokowaas=extensions` and `GET /api/v1/mokowaas/extensions` — list installed extensions with version, status, and update server info ## [02.20.00] --- 2026-05-28 diff --git a/src/packages/com_mokowaas/api/src/Controller/ExtensionsController.php b/src/packages/com_mokowaas/api/src/Controller/ExtensionsController.php new file mode 100644 index 00000000..81f8f529 --- /dev/null +++ b/src/packages/com_mokowaas/api/src/Controller/ExtensionsController.php @@ -0,0 +1,187 @@ +getIdentity(); + + if (!$user->authorise('core.manage', 'com_installer')) + { + $this->sendJson(403, ['error' => 'Not authorized — requires core.manage on com_installer']); + return; + } + + try + { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select([ + $db->quoteName('e.extension_id'), + $db->quoteName('e.name'), + $db->quoteName('e.type'), + $db->quoteName('e.element'), + $db->quoteName('e.folder'), + $db->quoteName('e.client_id'), + $db->quoteName('e.enabled'), + $db->quoteName('e.protected'), + $db->quoteName('e.locked'), + $db->quoteName('e.manifest_cache'), + ]) + ->from($db->quoteName('#__extensions', 'e')) + ->order($db->quoteName('e.type') . ' ASC, ' . $db->quoteName('e.name') . ' ASC'); + + // Filter by type + $typeFilter = $app->input->get('type', '', 'CMD'); + + if ($typeFilter !== '') + { + $query->where($db->quoteName('e.type') . ' = ' . $db->quote($typeFilter)); + } + + // Filter by enabled + $enabledFilter = $app->input->get('enabled', '', 'CMD'); + + if ($enabledFilter !== '') + { + $query->where($db->quoteName('e.enabled') . ' = ' . (int) $enabledFilter); + } + + // Search name or element + $search = $app->input->get('search', '', 'STRING'); + + if ($search !== '') + { + $searchQuoted = $db->quote('%' . $db->escape($search, true) . '%'); + $query->where( + '(' . $db->quoteName('e.name') . ' LIKE ' . $searchQuoted + . ' OR ' . $db->quoteName('e.element') . ' LIKE ' . $searchQuoted . ')' + ); + } + + $db->setQuery($query); + $rows = $db->loadAssocList(); + + // Get update sites for cross-reference + $usQuery = $db->getQuery(true) + ->select([ + $db->quoteName('us.update_site_id'), + $db->quoteName('us.name', 'site_name'), + $db->quoteName('us.location'), + $db->quoteName('us.enabled', 'site_enabled'), + $db->quoteName('usm.extension_id'), + ]) + ->from($db->quoteName('#__update_sites', 'us')) + ->innerJoin( + $db->quoteName('#__update_sites_extensions', 'usm') + . ' ON ' . $db->quoteName('us.update_site_id') + . ' = ' . $db->quoteName('usm.update_site_id') + ); + $db->setQuery($usQuery); + $updateSites = []; + + foreach ($db->loadAssocList() ?: [] as $us) + { + $updateSites[(int) $us['extension_id']] = [ + 'name' => $us['site_name'], + 'location' => $us['location'], + 'enabled' => (bool) $us['site_enabled'], + ]; + } + + // Build response + $extensions = []; + + foreach ($rows as $row) + { + $manifest = json_decode($row['manifest_cache'] ?: '{}', true); + $extId = (int) $row['extension_id']; + + $ext = [ + 'extension_id' => $extId, + 'name' => $row['name'], + 'type' => $row['type'], + 'element' => $row['element'], + 'folder' => $row['folder'] ?: null, + 'client_id' => (int) $row['client_id'], + 'enabled' => (bool) $row['enabled'], + 'protected' => (bool) $row['protected'], + 'locked' => (bool) $row['locked'], + 'version' => $manifest['version'] ?? null, + 'author' => $manifest['author'] ?? null, + 'description' => $manifest['description'] ?? null, + ]; + + if (isset($updateSites[$extId])) + { + $ext['update_server'] = $updateSites[$extId]; + } + + $extensions[] = $ext; + } + + $this->sendJson(200, [ + 'status' => 'ok', + 'count' => count($extensions), + 'extensions' => $extensions, + ]); + } + catch (\Throwable $e) + { + $this->sendJson(500, [ + 'error' => 'Failed to list extensions', + 'message' => $e->getMessage(), + ]); + } + } + + /** + * @param int $code HTTP status code + * @param array $payload Response data + * @return void + */ + private function sendJson(int $code, array $payload): void + { + $app = Factory::getApplication(); + $app->setHeader('Content-Type', 'application/json', true); + $app->setHeader('Status', (string) $code, true); + echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + $app->close(); + } +} diff --git a/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php b/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php index a8e14349..aa1dd9dd 100644 --- a/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php +++ b/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php @@ -1567,11 +1567,14 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface case 'sync-receive': $this->handleSyncReceiveAction(); break; + case 'extensions': + $this->handleExtensionsAction(); + break; default: $this->sendHealthResponse(400, [ 'error' => 'Unknown action', 'action' => $action, - 'available' => ['health', 'install', 'update', 'cache', 'backup', 'info', 'reset', 'snapshot', 'sync', 'sync-receive'], + 'available' => ['health', 'install', 'update', 'cache', 'backup', 'info', 'reset', 'snapshot', 'sync', 'sync-receive', 'extensions'], ]); break; } @@ -1773,6 +1776,136 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface } } + /** + * List installed extensions with version, status, and update server info. + * + * GET /?mokowaas=extensions + * Optional: ?type=plugin&search=moko&enabled=1 + * + * @return void + * @since 02.21.00 + */ + protected function handleExtensionsAction() + { + try + { + $db = Factory::getDbo(); + $input = $this->app->input; + + $query = $db->getQuery(true) + ->select([ + $db->quoteName('e.extension_id'), + $db->quoteName('e.name'), + $db->quoteName('e.type'), + $db->quoteName('e.element'), + $db->quoteName('e.folder'), + $db->quoteName('e.client_id'), + $db->quoteName('e.enabled'), + $db->quoteName('e.protected'), + $db->quoteName('e.locked'), + $db->quoteName('e.manifest_cache'), + ]) + ->from($db->quoteName('#__extensions', 'e')) + ->order($db->quoteName('e.type') . ' ASC, ' . $db->quoteName('e.name') . ' ASC'); + + $typeFilter = $input->get('type', '', 'CMD'); + + if ($typeFilter !== '') + { + $query->where($db->quoteName('e.type') . ' = ' . $db->quote($typeFilter)); + } + + $enabledFilter = $input->get('enabled', '', 'CMD'); + + if ($enabledFilter !== '') + { + $query->where($db->quoteName('e.enabled') . ' = ' . (int) $enabledFilter); + } + + $search = $input->get('search', '', 'STRING'); + + if ($search !== '') + { + $like = $db->quote('%' . $db->escape($search, true) . '%'); + $query->where( + '(' . $db->quoteName('e.name') . ' LIKE ' . $like + . ' OR ' . $db->quoteName('e.element') . ' LIKE ' . $like . ')' + ); + } + + $db->setQuery($query); + $rows = $db->loadAssocList(); + + // Get update sites + $usQuery = $db->getQuery(true) + ->select([ + $db->quoteName('us.name', 'site_name'), + $db->quoteName('us.location'), + $db->quoteName('us.enabled', 'site_enabled'), + $db->quoteName('usm.extension_id'), + ]) + ->from($db->quoteName('#__update_sites', 'us')) + ->innerJoin( + $db->quoteName('#__update_sites_extensions', 'usm') + . ' ON ' . $db->quoteName('us.update_site_id') + . ' = ' . $db->quoteName('usm.update_site_id') + ); + $db->setQuery($usQuery); + $updateSites = []; + + foreach ($db->loadAssocList() ?: [] as $us) + { + $updateSites[(int) $us['extension_id']] = [ + 'name' => $us['site_name'], + 'location' => $us['location'], + 'enabled' => (bool) $us['site_enabled'], + ]; + } + + $extensions = []; + + foreach ($rows as $row) + { + $manifest = json_decode($row['manifest_cache'] ?: '{}', true); + $extId = (int) $row['extension_id']; + + $ext = [ + 'extension_id' => $extId, + 'name' => $row['name'], + 'type' => $row['type'], + 'element' => $row['element'], + 'folder' => $row['folder'] ?: null, + 'client_id' => (int) $row['client_id'], + 'enabled' => (bool) $row['enabled'], + 'protected' => (bool) $row['protected'], + 'locked' => (bool) $row['locked'], + 'version' => $manifest['version'] ?? null, + 'author' => $manifest['author'] ?? null, + ]; + + if (isset($updateSites[$extId])) + { + $ext['update_server'] = $updateSites[$extId]; + } + + $extensions[] = $ext; + } + + $this->sendHealthResponse(200, [ + 'status' => 'ok', + 'count' => count($extensions), + 'extensions' => $extensions, + ]); + } + catch (\Throwable $e) + { + $this->sendHealthResponse(500, [ + 'error' => 'Failed to list extensions', + 'message' => $e->getMessage(), + ]); + } + } + /** * Trigger Joomla update finder check. * diff --git a/src/packages/plg_webservices_mokowaas/src/Extension/MokoWaaSApi.php b/src/packages/plg_webservices_mokowaas/src/Extension/MokoWaaSApi.php index 8acb7b60..778ba99a 100644 --- a/src/packages/plg_webservices_mokowaas/src/Extension/MokoWaaSApi.php +++ b/src/packages/plg_webservices_mokowaas/src/Extension/MokoWaaSApi.php @@ -94,5 +94,11 @@ final class MokoWaaSApi extends CMSPlugin implements SubscriberInterface 'syncreceive', ['component' => 'com_mokowaas'] ); + + $router->createCRUDRoutes( + 'v1/mokowaas/extensions', + 'extensions', + ['component' => 'com_mokowaas'] + ); } } diff --git a/wiki/api-endpoints.md b/wiki/api-endpoints.md index afa13f0f..8bc3cdc9 100644 --- a/wiki/api-endpoints.md +++ b/wiki/api-endpoints.md @@ -182,6 +182,7 @@ In addition to the query-string endpoints above, MokoWaaS registers standard Joo | `GET/POST /api/v1/mokowaas/snapshot` | SnapshotController | List or create snapshots | | `POST /api/v1/mokowaas/sync` | SyncController | Push content to all sync targets | | `POST /api/v1/mokowaas/sync-receive` | SyncReceiveController | Receive content from source site | +| `GET /api/v1/mokowaas/extensions` | ExtensionsController | List installed extensions | These routes use Joomla's standard API authentication (API token in `X-Joomla-Token` header) and are useful for integrations that already use the Joomla API framework. @@ -284,3 +285,53 @@ The `name` field is optional and defaults to the active baseline name. "has_media": true } ``` + +### Extensions Endpoint (REST API) + +``` +GET /api/index.php/v1/mokowaas/extensions +X-Joomla-Token: +``` + +Lists all installed Joomla extensions with version, enabled/protected/locked status, and update server info. + +**Query filters:** + +| Parameter | Description | Example | +|---|---|---| +| `type` | Filter by extension type | `?type=plugin` | +| `search` | Search name or element | `?search=moko` | +| `enabled` | Filter by enabled status | `?enabled=1` | + +**Query-string equivalent:** `GET /?mokowaas=extensions&search=moko&type=plugin` + +Requires `core.manage` on `com_installer`. + +**Success Response** (HTTP 200): + +```json +{ + "status": "ok", + "count": 3, + "extensions": [ + { + "extension_id": 456, + "name": "System - MokoWaaS", + "type": "plugin", + "element": "mokowaas", + "folder": "system", + "client_id": 0, + "enabled": true, + "protected": true, + "locked": false, + "version": "02.21.00", + "author": "Moko Consulting", + "update_server": { + "name": "MokoWaaS Update Server", + "location": "https://git.mokoconsulting.tech/.../updates.xml", + "enabled": true + } + } + ] +} +```