From a51f04c841c15d18fd7f7a61fd8be1ea77bf55f6 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 6 Jun 2026 11:10:02 -0500 Subject: [PATCH] feat: provision reset API for new client setup from Base ProvisionController: POST /api/v1/mokowaas/provision-reset - Resets article hits to zero - Deletes content version history - Regenerates heartbeat token (optional, for breach response) - Revokes all user API tokens with email notification (optional) - Sets setup-required flag for new client info collection Core plugin: checkSetupRequired() shows persistent admin banner until plugin settings are saved. Clears flag on save. Route registered in webservices plugin. Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/Controller/ProvisionController.php | 236 ++++++++++++++++++ .../Extension/MokoWaaS.php | 78 +++++- .../src/Extension/MokoWaaSApi.php | 6 + 3 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 source/packages/com_mokowaas/api/src/Controller/ProvisionController.php diff --git a/source/packages/com_mokowaas/api/src/Controller/ProvisionController.php b/source/packages/com_mokowaas/api/src/Controller/ProvisionController.php new file mode 100644 index 00000000..8f66a1c5 --- /dev/null +++ b/source/packages/com_mokowaas/api/src/Controller/ProvisionController.php @@ -0,0 +1,236 @@ +getIdentity(); + + if (!$user->authorise('core.manage', 'com_mokowaas')) + { + $this->sendJson(403, ['error' => 'Not authorized']); + + return; + } + + if ($app->input->getMethod() !== 'POST') + { + $this->sendJson(405, ['error' => 'POST required']); + + return; + } + + $db = Factory::getDbo(); + $results = []; + + // 1. Reset article hit counters + try + { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__content')) + ->set($db->quoteName('hits') . ' = 0') + )->execute(); + $results['hits_reset'] = $db->getAffectedRows(); + } + catch (\Throwable $e) + { + $results['hits_reset'] = 'error: ' . $e->getMessage(); + } + + // 2. Delete content version history + try + { + $db->setQuery( + $db->getQuery(true)->delete($db->quoteName('#__history')) + )->execute(); + $results['versions_deleted'] = $db->getAffectedRows(); + } + catch (\Throwable $e) + { + $results['versions_deleted'] = 'error: ' . $e->getMessage(); + } + + // 3. Regenerate heartbeat token if requested + $input = $app->getInput()->json; + $resetToken = (bool) ($input->get('reset_token', false, 'BOOLEAN')); + + if ($resetToken) + { + try + { + $newToken = bin2hex(random_bytes(32)); + + $plugin = \Joomla\CMS\Plugin\PluginHelper::getPlugin('system', 'mokowaas'); + + if ($plugin) + { + $pluginParams = new \Joomla\Registry\Registry($plugin->params); + $pluginParams->set('health_api_token', $newToken); + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote($pluginParams->toString())) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('system')) + )->execute(); + + $results['token_regenerated'] = true; + $results['new_token'] = $newToken; + } + } + catch (\Throwable $e) + { + $results['token_regenerated'] = 'error: ' . $e->getMessage(); + } + } + + // 4. Reset all user API tokens if requested + $resetApiTokens = (bool) ($input->get('reset_api_tokens', false, 'BOOLEAN')); + + if ($resetApiTokens) + { + try + { + // Get users who have API tokens before deleting + $db->setQuery( + $db->getQuery(true) + ->select('DISTINCT ' . $db->quoteName('user_id')) + ->from($db->quoteName('#__user_keys')) + ->where($db->quoteName('series') . ' LIKE ' . $db->quote('api-%')) + ); + $affectedUserIds = $db->loadColumn() ?: []; + + $db->setQuery( + $db->getQuery(true)->delete($db->quoteName('#__user_keys')) + ->where($db->quoteName('series') . ' LIKE ' . $db->quote('api-%')) + )->execute(); + $results['api_tokens_revoked'] = $db->getAffectedRows(); + + // Notify affected users + if (!empty($affectedUserIds)) + { + $this->notifyTokenReset($db, $affectedUserIds); + $results['users_notified'] = \count($affectedUserIds); + } + } + catch (\Throwable $e) + { + $results['api_tokens_revoked'] = 'error: ' . $e->getMessage(); + } + } + + // 5. Flag site for fresh client info setup + try + { + // Write a flag file that the core plugin checks on next admin load + $flagFile = JPATH_ADMINISTRATOR . '/cache/mokowaas_setup_required.flag'; + file_put_contents($flagFile, json_encode([ + 'created' => gmdate('Y-m-d\TH:i:s\Z'), + 'reason' => 'provision-reset', + 'remote_ip' => $_SERVER['REMOTE_ADDR'] ?? '', + ])); + $results['setup_flag'] = true; + } + catch (\Throwable $e) + { + $results['setup_flag'] = 'error: ' . $e->getMessage(); + } + + $this->sendJson(200, [ + 'status' => 'ok', + 'message' => 'Site provisioned for new client.', + 'results' => $results, + ]); + } + + /** + * Notify users that their API tokens have been revoked. + */ + private function notifyTokenReset($db, array $userIds): void + { + try + { + $db->setQuery( + $db->getQuery(true) + ->select([$db->quoteName('name'), $db->quoteName('email')]) + ->from($db->quoteName('#__users')) + ->whereIn($db->quoteName('id'), $userIds) + ->where($db->quoteName('block') . ' = 0') + ); + $users = $db->loadObjectList() ?: []; + + $config = Factory::getConfig(); + $siteName = $config->get('sitename', 'Joomla'); + $siteUrl = rtrim(\Joomla\CMS\Uri\Uri::root(), '/'); + + $mailer = Factory::getMailer(); + + foreach ($users as $u) + { + try + { + $mailer->clearAllRecipients(); + $mailer->addRecipient($u->email, $u->name); + $mailer->setSubject($siteName . ' — API tokens have been reset'); + $mailer->setBody( + "Hello {$u->name},\n\n" + . "Your API access tokens on {$siteName} have been revoked by an administrator.\n\n" + . "If you use API integrations, please log in and generate a new token:\n" + . "{$siteUrl}/administrator/\n\n" + . "— {$siteName}" + ); + $mailer->send(); + } + catch (\Throwable $e) + { + // Non-critical + } + } + } + catch (\Throwable $e) + { + // Non-critical + } + } + + private function sendJson(int $code, array $data): void + { + http_response_code($code); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($data, JSON_UNESCAPED_SLASHES); + Factory::getApplication()->close(); + } +} diff --git a/source/packages/plg_system_mokowaas/Extension/MokoWaaS.php b/source/packages/plg_system_mokowaas/Extension/MokoWaaS.php index c61e0b48..55918988 100644 --- a/source/packages/plg_system_mokowaas/Extension/MokoWaaS.php +++ b/source/packages/plg_system_mokowaas/Extension/MokoWaaS.php @@ -167,10 +167,11 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface $this->handleMokoApi($mokoAction); } - // One-time remote login (admin only) + // Admin-only features if ($this->app->isClient('administrator')) { $this->handleOneTimeLogin(); + $this->checkSetupRequired(); $this->preserveDownloadKeys(); } } @@ -242,7 +243,14 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface // Grafana auto-provisioning $this->handleGrafanaProvisioning($params, $app); - // NOTE: reset_hits and delete_versions now handled by devtools plugin + // Clear setup-required flag on save (new client setup complete) + $flagFile = JPATH_ADMINISTRATOR . '/cache/mokowaas_setup_required.flag'; + + if (file_exists($flagFile)) + { + @unlink($flagFile); + $app->enqueueMessage('Client setup complete — setup flag cleared.', 'message'); + } if ($changed) { @@ -2053,6 +2061,72 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface return $this->masterNames; } + // ------------------------------------------------------------------ + // Setup Required Check + // ------------------------------------------------------------------ + + /** + * Check if the site has been provisioned for a new client and needs + * fresh setup information (company name, contact details). + * + * Shows a persistent admin banner until the setup flag is cleared + * by saving the core plugin settings. + * + * @return void + * + * @since 02.35.00 + */ + protected function checkSetupRequired(): void + { + $flagFile = JPATH_ADMINISTRATOR . '/cache/mokowaas_setup_required.flag'; + + if (!file_exists($flagFile)) + { + return; + } + + $this->app->enqueueMessage( + 'New client setup required. This site has been provisioned for a new client. ' + . 'Please update the site name, contact details, and save the MokoWaaS plugin settings to complete setup. ' + . 'Open Settings', + 'warning' + ); + } + + /** + * Get this plugin's extension_id. + */ + private function getPluginExtensionId(): int + { + static $id = null; + + if ($id !== null) + { + return $id; + } + + try + { + $db = Factory::getDbo(); + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('extension_id')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('system')) + ); + $id = (int) $db->loadResult(); + } + catch (\Throwable $e) + { + $id = 0; + } + + return $id; + } + // ------------------------------------------------------------------ // One-Time Remote Login // ------------------------------------------------------------------ diff --git a/source/packages/plg_webservices_mokowaas/src/Extension/MokoWaaSApi.php b/source/packages/plg_webservices_mokowaas/src/Extension/MokoWaaSApi.php index d929dd97..37235506 100644 --- a/source/packages/plg_webservices_mokowaas/src/Extension/MokoWaaSApi.php +++ b/source/packages/plg_webservices_mokowaas/src/Extension/MokoWaaSApi.php @@ -118,5 +118,11 @@ final class MokoWaaSApi extends CMSPlugin implements SubscriberInterface 'remotelogin', ['component' => 'com_mokowaas'] ); + + $router->createCRUDRoutes( + 'v1/mokowaas/provision-reset', + 'provision', + ['component' => 'com_mokowaas'] + ); } }