required permission. */ private const VIEW_ACL = [ 'dashboard' => 'mokosuiteclient.dashboard', 'extensions' => 'mokosuiteclient.extensions', 'htaccess' => 'mokosuiteclient.htaccess', 'privacy' => 'core.admin', 'waflog' => 'mokosuiteclient.security.waflog', 'automation' => 'core.admin', 'database' => 'core.admin', 'cleanup' => 'mokosuiteclient.cache', 'snippets' => 'mokosuiteclient.snippets.manage', 'templates' => 'mokosuiteclient.templates.manage', 'replacements' => 'mokosuiteclient.replacements.manage', 'conditions' => 'mokosuiteclient.conditions.manage', ]; public function display($cachable = false, $urlparams = []) { $view = $this->input->get('view', $this->default_view); $acl = self::VIEW_ACL[$view] ?? 'core.manage'; if (!$this->checkAcl($acl)) { Factory::getApplication()->enqueueMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error'); Factory::getApplication()->redirect(Route::_('index.php', false)); return; } return parent::display($cachable, $urlparams); } // ================================================================== // Plugin toggle // ================================================================== public function togglePlugin() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); if (!$this->checkAcl('mokosuiteclient.plugins.toggle')) { $this->jsonForbidden(); return; } $app = Factory::getApplication(); $model = $this->getModel('Dashboard'); $result = $model->togglePlugin( $app->getInput()->getInt('extension_id', 0), $app->getInput()->getInt('enabled', 0) ); $this->jsonResponse($result); } // ================================================================== // Heartbeat // ================================================================== public function sendHeartbeat() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); try { $corePlugin = \Joomla\CMS\Plugin\PluginHelper::getPlugin('system', 'mokosuiteclient'); if (!$corePlugin) { $this->jsonResponse(['success' => false, 'message' => 'Core plugin not enabled.']); return; } $params = new \Joomla\Registry\Registry($corePlugin->params); $baseUrl = rtrim($params->get('monitor_base_url', ''), '/'); // Fall back to manifest XML default if (empty($baseUrl)) { $manifestFile = JPATH_PLUGINS . '/system/mokosuiteclient/mokosuiteclient.xml'; if (is_file($manifestFile)) { $xml = simplexml_load_file($manifestFile); if ($xml) { foreach ($xml->xpath('//field[@name="monitor_base_url"]') as $field) { $baseUrl = rtrim((string) $field['default'], '/'); break; } } } } if (empty($baseUrl)) { $this->jsonResponse(['success' => false, 'message' => 'MokoSuiteClientHQ URL not configured.']); return; } $healthToken = $params->get('health_api_token', ''); if (empty($healthToken)) { $this->jsonResponse(['success' => false, 'message' => 'Health token not configured.']); return; } $siteUrl = rtrim(\Joomla\CMS\Uri\Uri::root(), '/'); $domain = parse_url($siteUrl, PHP_URL_HOST) ?: ''; $timestamp = time(); // Discover all MokoSuite ecosystem packages for HQ $mokoPackages = []; try { $pkgDb = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); $pkgQuery = $pkgDb->getQuery(true) ->select([$pkgDb->quoteName('element'), $pkgDb->quoteName('manifest_cache')]) ->from($pkgDb->quoteName('#__extensions')) ->where('(' . $pkgDb->quoteName('element') . ' LIKE ' . $pkgDb->quote('pkg_mokosuite%') . ' OR ' . $pkgDb->quoteName('element') . ' LIKE ' . $pkgDb->quote('pkg_mokojoom%') . ')'); $pkgDb->setQuery($pkgQuery); foreach ($pkgDb->loadObjectList() ?: [] as $pkg) { $m = json_decode($pkg->manifest_cache ?? '{}'); $mokoPackages[$pkg->element] = $m->version ?? ''; } } catch (\Throwable $e) {} $payload = json_encode([ 'token' => $healthToken, 'domain' => $domain, 'site_name' => Factory::getConfig()->get('sitename', 'Joomla'), 'site_url' => $siteUrl, 'joomla_version' => (new \Joomla\CMS\Version())->getShortVersion(), 'php_version' => PHP_VERSION, 'timestamp' => $timestamp, 'moko_packages' => $mokoPackages, ], JSON_UNESCAPED_SLASHES); // RSA sign the request $headers = ['Content-Type: application/json']; $signingKeyB64 = $params->get('monitor_signing_key', ''); // Fall back to manifest XML default if (empty($signingKeyB64)) { $manifestFile = JPATH_PLUGINS . '/system/mokosuiteclient/mokosuiteclient.xml'; if (is_file($manifestFile)) { $xml = simplexml_load_file($manifestFile); if ($xml) { foreach ($xml->xpath('//field[@name="monitor_signing_key"]') as $field) { $signingKeyB64 = (string) $field['default']; break; } } } } if (!empty($signingKeyB64)) { $privateKeyPem = base64_decode($signingKeyB64); $privateKey = openssl_pkey_get_private($privateKeyPem); if ($privateKey !== false) { $message = $domain . '|' . $timestamp . '|' . $healthToken; $signature = ''; if (openssl_sign($message, $signature, $privateKey, OPENSSL_ALGO_SHA256)) { $headers[] = 'X-MokoSuite-Signature: ' . base64_encode($signature); $headers[] = 'X-MokoSuite-Timestamp: ' . $timestamp; } } } $endpoint = $baseUrl . '/api/index.php/v1/mokosuitehq/heartbeat'; $ch = curl_init($endpoint); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_POSTFIELDS => $payload, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, CURLOPT_FOLLOWLOCATION => true, CURLOPT_SSL_VERIFYPEER => true, ]); $response = curl_exec($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); if ($error) { $this->jsonResponse(['success' => false, 'message' => 'Connection failed: ' . $error]); } elseif ($code >= 200 && $code < 300) { $body = json_decode($response, true); $this->jsonResponse(['success' => true, 'message' => 'Heartbeat sent: ' . ($body['status'] ?? 'ok')]); } else { $body = json_decode($response, true); $this->jsonResponse(['success' => false, 'message' => 'HTTP ' . $code . ': ' . ($body['error'] ?? $body['message'] ?? 'Unknown')]); } } catch (\Throwable $e) { $this->jsonResponse(['success' => false, 'message' => 'Error: ' . $e->getMessage()]); } } // Cache // ================================================================== public function clearCache() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); if (!$this->checkAcl('mokosuiteclient.cache')) { $this->jsonForbidden(); return; } $this->jsonResponse($this->getModel('Dashboard')->clearCache()); } public function clearTemp() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); if (!$this->checkAcl('mokosuiteclient.cache')) { $this->jsonForbidden(); return; } $this->jsonResponse($this->getModel('Dashboard')->clearTemp()); } // ================================================================== // Extensions // ================================================================== public function installExtension() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); if (!$this->checkAcl('mokosuiteclient.extensions')) { $this->jsonForbidden(); return; } $downloadUrl = Factory::getApplication()->getInput()->getString('download_url', ''); if (empty($downloadUrl)) { $this->jsonResponse(['success' => false, 'message' => 'Missing download URL.']); return; } $this->jsonResponse($this->getModel('Extensions')->installFromUrl($downloadUrl)); } // ================================================================== // .htaccess // ================================================================== public function saveHtaccess() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); if (!$this->checkAcl('mokosuiteclient.htaccess')) { $this->jsonForbidden(); return; } $app = Factory::getApplication(); $input = $app->getInput(); $model = $this->getModel('Htaccess'); $options = []; foreach ($input->getArray() as $key => $value) { if (str_starts_with($key, 'opt_')) { $options[substr($key, 4)] = $value; } } if (!empty($options)) { $model->saveOptions($options); } $this->jsonResponse($model->saveHtaccess($input->getRaw('content', ''))); } public function generateHtaccess() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); if (!$this->checkAcl('mokosuiteclient.htaccess')) { $this->jsonForbidden(); return; } $model = $this->getModel('Htaccess'); $options = Factory::getApplication()->getInput()->getArray(); $model->saveOptions($options); $app = Factory::getApplication(); $app->setHeader('Content-Type', 'application/json'); echo json_encode([ 'htaccess' => $model->generateHtaccess($options), 'nginx' => $model->generateNginx($options), ]); $app->close(); } // ================================================================== // Regular Labs Import // ================================================================== public function importRegularLabs() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; } try { $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); $prefix = $db->getPrefix(); $tables = $db->getTableList(); $results = []; // ── Conditions (4 tables) ────────────────────────────── if (in_array($prefix . 'conditions', $tables) && in_array($prefix . 'mokosuiteclient_conditions', $tables)) { // Check if already imported $existing = (int) $db->setQuery("SELECT COUNT(*) FROM " . $db->quoteName('#__mokosuiteclient_conditions'))->loadResult(); if ($existing === 0) { // conditions $db->setQuery("INSERT INTO " . $db->quoteName('#__mokosuiteclient_conditions') . " (id, alias, name, description, category, color, match_all, published, hash, checked_out, checked_out_time)" . " SELECT id, alias, name, description, category, color, match_all, published, hash, checked_out, checked_out_time" . " FROM " . $db->quoteName('#__conditions'))->execute(); $c1 = $db->getAffectedRows(); // conditions_groups if (in_array($prefix . 'conditions_groups', $tables)) { $db->setQuery("INSERT INTO " . $db->quoteName('#__mokosuiteclient_conditions_groups') . " (id, condition_id, match_all, ordering)" . " SELECT id, condition_id, match_all, ordering" . " FROM " . $db->quoteName('#__conditions_groups'))->execute(); } // conditions_rules if (in_array($prefix . 'conditions_rules', $tables)) { $db->setQuery("INSERT INTO " . $db->quoteName('#__mokosuiteclient_conditions_rules') . " (id, group_id, type, exclude, params, ordering)" . " SELECT id, group_id, type, exclude, params, ordering" . " FROM " . $db->quoteName('#__conditions_rules'))->execute(); } // conditions_map if (in_array($prefix . 'conditions_map', $tables)) { $db->setQuery("INSERT INTO " . $db->quoteName('#__mokosuiteclient_conditions_map') . " (condition_id, extension, item_id)" . " SELECT condition_id, extension, item_id" . " FROM " . $db->quoteName('#__conditions_map'))->execute(); } $results['conditions'] = $c1 . ' condition sets imported'; } else { $results['conditions'] = 'skipped (already has data)'; } } // ── Snippets ────────────────────────────────────────── if (in_array($prefix . 'snippets', $tables) && in_array($prefix . 'mokosuiteclient_snippets', $tables)) { $existing = (int) $db->setQuery("SELECT COUNT(*) FROM " . $db->quoteName('#__mokosuiteclient_snippets'))->loadResult(); if ($existing === 0) { $db->setQuery("INSERT INTO " . $db->quoteName('#__mokosuiteclient_snippets') . " (id, alias, name, description, category, color, content, params, published, ordering, checked_out, checked_out_time)" . " SELECT id, alias, name, description, category, color, content, params, published, ordering, checked_out, checked_out_time" . " FROM " . $db->quoteName('#__snippets'))->execute(); $results['snippets'] = $db->getAffectedRows() . ' snippets imported'; } else { $results['snippets'] = 'skipped (already has data)'; } } // ── ReReplacer ──────────────────────────────────────── if (in_array($prefix . 'rereplacer', $tables) && in_array($prefix . 'mokosuiteclient_replacements', $tables)) { $existing = (int) $db->setQuery("SELECT COUNT(*) FROM " . $db->quoteName('#__mokosuiteclient_replacements'))->loadResult(); if ($existing === 0) { // RL uses 'replace' column, we use 'replace_value'; RL 'area' is text (JSON), we use varchar $db->setQuery("INSERT INTO " . $db->quoteName('#__mokosuiteclient_replacements') . " (id, name, search, replace_value, area, published, description, ordering, checked_out, checked_out_time)" . " SELECT id, name, search, `replace`, 'both', published, description, ordering, checked_out, checked_out_time" . " FROM " . $db->quoteName('#__rereplacer'))->execute(); $results['replacements'] = $db->getAffectedRows() . ' replacement rules imported'; } else { $results['replacements'] = 'skipped (already has data)'; } } // ── Content Templater ───────────────────────────────── if (in_array($prefix . 'contenttemplater', $tables) && in_array($prefix . 'mokosuiteclient_content_templates', $tables)) { $existing = (int) $db->setQuery("SELECT COUNT(*) FROM " . $db->quoteName('#__mokosuiteclient_content_templates'))->loadResult(); if ($existing === 0) { $db->setQuery("INSERT INTO " . $db->quoteName('#__mokosuiteclient_content_templates') . " (id, name, description, category, color, template_data, published, ordering, checked_out, checked_out_time)" . " SELECT id, name, description, category, color, content, published, ordering, checked_out, checked_out_time" . " FROM " . $db->quoteName('#__contenttemplater'))->execute(); $results['templates'] = $db->getAffectedRows() . ' content templates imported'; } else { $results['templates'] = 'skipped (already has data)'; } } if (empty($results)) { $this->jsonResponse(['success' => false, 'message' => 'No Regular Labs data found to import.']); } else { $summary = implode('; ', array_map(fn($k, $v) => ucfirst($k) . ': ' . $v, array_keys($results), $results)); $this->jsonResponse(['success' => true, 'message' => 'Import complete. ' . $summary]); } } catch (\Throwable $e) { $this->jsonResponse(['success' => false, 'message' => 'Import error: ' . $e->getMessage()]); } } // ================================================================== // Support PIN // ================================================================== public function requestPin() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); if (!$this->checkAcl('mokosuiteclient.dashboard')) { $this->jsonForbidden(); return; } try { $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); $result = \Moko\Component\MokoSuiteClient\Administrator\Helper\SupportPinHelper::requestNew($db); $this->jsonResponse($result); } catch (\Throwable $e) { $this->jsonResponse(['success' => false, 'message' => 'Error: ' . $e->getMessage()]); } } // ================================================================== // Maintenance (#127, #128) // ================================================================== public function optimizeDb() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; } $model = new \Moko\Component\MokoSuiteClient\Administrator\Model\MaintenanceModel(); $this->jsonResponse($model->optimizeTables()); } public function repairDb() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; } $model = new \Moko\Component\MokoSuiteClient\Administrator\Model\MaintenanceModel(); $this->jsonResponse($model->repairTables()); } public function purgeSessions() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; } $model = new \Moko\Component\MokoSuiteClient\Administrator\Model\MaintenanceModel(); $this->jsonResponse($model->purgeSessions()); } public function cleanDirectory() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); if (!$this->checkAcl('mokosuiteclient.cache')) { $this->jsonForbidden(); return; } $dirKey = Factory::getApplication()->getInput()->getString('dir_key', ''); $model = new \Moko\Component\MokoSuiteClient\Administrator\Model\MaintenanceModel(); $this->jsonResponse($model->cleanDirectory($dirKey)); } // ================================================================== // Settings Import/Export (#132) // ================================================================== public function exportSettings() { Session::checkToken('get') or die(Text::_('JINVALID_TOKEN')); if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; } $db = Factory::getDbo(); $settings = []; // Export all MokoSuiteClient plugin params $plugins = ['mokosuiteclient', 'mokosuiteclient_firewall', 'mokosuiteclient_tenant', 'mokosuiteclient_devtools', 'mokosuiteclient_offline']; foreach ($plugins as $element) { $db->setQuery( $db->getQuery(true) ->select($db->quoteName('params')) ->from($db->quoteName('#__extensions')) ->where($db->quoteName('element') . ' = ' . $db->quote($element)) ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) ->where($db->quoteName('folder') . ' = ' . $db->quote('system')) ); $settings['plugins'][$element] = json_decode($db->loadResult() ?? '{}', true); } // Export component params $db->setQuery( $db->getQuery(true) ->select($db->quoteName('params')) ->from($db->quoteName('#__extensions')) ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokosuiteclient')) ->where($db->quoteName('type') . ' = ' . $db->quote('component')) ); $settings['component'] = json_decode($db->loadResult() ?? '{}', true); $settings['exported'] = gmdate('Y-m-d\TH:i:s\Z'); $settings['site'] = Factory::getConfig()->get('sitename', ''); $this->jsonResponse(['success' => true, 'settings' => $settings]); } public function importSettings() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; } $json = Factory::getApplication()->getInput()->getRaw('settings_json', ''); $data = json_decode($json, true); if (empty($data) || empty($data['plugins'])) { $this->jsonResponse(['success' => false, 'message' => 'Invalid settings JSON.']); return; } $db = Factory::getDbo(); $count = 0; foreach ($data['plugins'] ?? [] as $element => $params) { if (!is_array($params)) { continue; } $db->setQuery( $db->getQuery(true) ->update($db->quoteName('#__extensions')) ->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($params))) ->where($db->quoteName('element') . ' = ' . $db->quote($element)) ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) ->where($db->quoteName('folder') . ' = ' . $db->quote('system')) )->execute(); $count++; } if (!empty($data['component']) && is_array($data['component'])) { $db->setQuery( $db->getQuery(true) ->update($db->quoteName('#__extensions')) ->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($data['component']))) ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokosuiteclient')) ->where($db->quoteName('type') . ' = ' . $db->quote('component')) )->execute(); $count++; } $this->jsonResponse(['success' => true, 'message' => "Imported settings for {$count} extensions."]); } // ================================================================== // WAF Log // ================================================================== public function purgeWafLog() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); if (!$this->checkAcl('mokosuiteclient.security.waflog')) { $this->jsonForbidden(); return; } $days = Factory::getApplication()->getInput()->getInt('days', 30); $model = new \Moko\Component\MokoSuiteClient\Administrator\Model\WaflogModel(); $this->jsonResponse($model->purgeLogs($days)); } public function banIpFromLog() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); if (!$this->checkAcl('mokosuiteclient.security.waflog')) { $this->jsonForbidden(); return; } $ip = Factory::getApplication()->getInput()->getString('ip', ''); $model = new \Moko\Component\MokoSuiteClient\Administrator\Model\WaflogModel(); $this->jsonResponse($model->banIp($ip)); } // ================================================================== // Privacy Guard // ================================================================== public function processDataRequest() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; } $input = Factory::getApplication()->getInput(); $model = new \Moko\Component\MokoSuiteClient\Administrator\Model\PrivacyModel(); $action = $input->getString('action', 'deny'); if ($action === 'create') { $result = $model->createRequest( $input->getInt('user_id', 0), $input->getString('type', 'export') ); $this->jsonResponse($result); return; } if ($action === 'approve' && !$input->getInt('request_id', 0) && $input->getInt('user_id', 0)) { // Auto-process: create then immediately approve $result = $model->createRequest( $input->getInt('user_id', 0), $input->getString('type', 'export') ); if ($result['success'] && !empty($result['id'])) { $result = $model->processRequest((int) $result['id'], 'approve'); } $this->jsonResponse($result); return; } $this->jsonResponse($model->processRequest( $input->getInt('request_id', 0), $action )); } public function exportUserData() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; } $model = new \Moko\Component\MokoSuiteClient\Administrator\Model\PrivacyModel(); $this->jsonResponse($model->exportUserData( Factory::getApplication()->getInput()->getInt('user_id', 0) )); } // ================================================================== // Importers // ================================================================== public function importAdminTools() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; } $this->jsonResponse($this->getModel('Import')->importAdminTools()); } // ================================================================== // Helpers // ================================================================== /** * Check a MokoSuiteClient ACL permission for the current user. */ private function checkAcl(string $action): bool { $user = Factory::getApplication()->getIdentity(); // Super admins always pass if ($user->authorise('core.admin', 'com_mokosuiteclient')) { return true; } return $user->authorise($action, 'com_mokosuiteclient'); } /** * Send a JSON response and close. */ private function jsonResponse(array $data): void { $app = Factory::getApplication(); $app->setHeader('Content-Type', 'application/json'); echo json_encode($data); $app->close(); } /** * Send a 403 JSON response and close. */ private function jsonForbidden(): void { $this->jsonResponse(['success' => false, 'message' => Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN')]); return; } }