From 333966416bd6e49f5d6a199288558a93ebad005a Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 23 Jun 2026 17:09:32 -0500 Subject: [PATCH] feat: support PIN on-demand with 72-hour TTL + controller cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PIN hidden by default — "Request PIN" button generates on click - PIN valid for 72 hours (stored as support_pin_requested_at in params) - HMAC uses 72h window instead of daily date for stability - requestPin() AJAX endpoint in controller stores timestamp + returns PIN - Applied to both dashboard info bar and cpanel module - Dashboard JS handles PIN request with badge replacement - Cpanel JS handles same with inline script - Fixed orphaned ticket code fragments in controller (syntax error) - Removed duplicate maintenance section --- .../src/Controller/DisplayController.php | 154 ++++++------------ .../admin/src/View/Dashboard/HtmlView.php | 18 +- .../admin/tmpl/dashboard/default.php | 9 +- .../com_mokosuiteclient/media/js/dashboard.js | 37 +++++ .../src/Dispatcher/Dispatcher.php | 17 +- .../tmpl/default.php | 43 ++++- 6 files changed, 163 insertions(+), 115 deletions(-) diff --git a/source/packages/com_mokosuiteclient/admin/src/Controller/DisplayController.php b/source/packages/com_mokosuiteclient/admin/src/Controller/DisplayController.php index a6e288f6..edb7d03f 100644 --- a/source/packages/com_mokosuiteclient/admin/src/Controller/DisplayController.php +++ b/source/packages/com_mokosuiteclient/admin/src/Controller/DisplayController.php @@ -363,124 +363,68 @@ class DisplayController extends BaseController $app->close(); } - $input = Factory::getApplication()->getInput(); + // ================================================================== + // Support PIN + // ================================================================== - $this->jsonResponse($this->getModel('Tickets')->updateStatus( - $input->getInt('ticket_id', 0), - $input->getInt('status', 0) - )); - } + public function requestPin() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); - $id = Factory::getApplication()->getInput()->getInt('id', 0); - $this->jsonResponse($this->getModel('Tickets')->deletePriority($id)); - } + if (!$this->checkAcl('mokosuiteclient.dashboard')) + { + $this->jsonForbidden(); + return; + } try { - $db = Factory::getDbo(); - $escaped = $db->quote('%' . $db->escape($query, true) . '%'); - - $results = $db->setQuery( + $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); + $db->setQuery( $db->getQuery(true) - ->select([$db->quoteName('l.title'), $db->quoteName('l.url'), $db->quoteName('l.description')]) - ->from($db->quoteName('#__finder_links', 'l')) - ->where($db->quoteName('l.published') . ' = 1') - ->where('(' . $db->quoteName('l.title') . ' LIKE ' . $escaped - . ' OR ' . $db->quoteName('l.description') . ' LIKE ' . $escaped . ')') - ->order($db->quoteName('l.title') . ' ASC') - ->setLimit(8) - )->loadObjectList() ?: []; + ->select([$db->quoteName('extension_id'), $db->quoteName('params')]) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('system')) + ); + $ext = $db->loadObject(); - foreach ($results as $r) + if (!$ext) { - $r->description = mb_substr(strip_tags($r->description ?? ''), 0, 150); + $this->jsonResponse(['success' => false, 'message' => 'Core plugin not found.']); + return; } - $this->jsonResponse(['results' => $results]); + $params = json_decode($ext->params, true) ?: []; + $token = $params['health_api_token'] ?? ''; + + if (empty($token)) + { + $this->jsonResponse(['success' => false, 'message' => 'Health token not configured.']); + return; + } + + $now = time(); + $params['support_pin_requested_at'] = $now; + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($params))) + ->where($db->quoteName('extension_id') . ' = ' . (int) $ext->extension_id) + )->execute(); + + $pinTtl = 72 * 3600; + $window = floor($now / $pinTtl); + $hash = hash_hmac('sha256', (string) $window, $token); + $pin = 'MOKO-' . strtoupper(substr($hash, 0, 4)) . '-' . strtoupper(substr($hash, 4, 4)); + + $this->jsonResponse(['success' => true, 'pin' => $pin, 'message' => 'PIN generated — valid for 72 hours.']); } catch (\Throwable $e) { - Log::add('KB search failed: ' . $e->getMessage(), Log::ERROR, 'mokosuiteclient'); - $this->jsonResponse(['results' => [], 'error' => 'Search unavailable']); - } - } - - // ================================================================== - // 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)); - } - - $input = Factory::getApplication()->getInput(); - - $this->jsonResponse($this->getModel('Tickets')->updateStatus( - $input->getInt('ticket_id', 0), - $input->getInt('status', 0) - )); - } - - $id = Factory::getApplication()->getInput()->getInt('id', 0); - $this->jsonResponse($this->getModel('Tickets')->deletePriority($id)); - } - - try - { - $db = Factory::getDbo(); - $escaped = $db->quote('%' . $db->escape($query, true) . '%'); - - $results = $db->setQuery( - $db->getQuery(true) - ->select([$db->quoteName('l.title'), $db->quoteName('l.url'), $db->quoteName('l.description')]) - ->from($db->quoteName('#__finder_links', 'l')) - ->where($db->quoteName('l.published') . ' = 1') - ->where('(' . $db->quoteName('l.title') . ' LIKE ' . $escaped - . ' OR ' . $db->quoteName('l.description') . ' LIKE ' . $escaped . ')') - ->order($db->quoteName('l.title') . ' ASC') - ->setLimit(8) - )->loadObjectList() ?: []; - - foreach ($results as $r) - { - $r->description = mb_substr(strip_tags($r->description ?? ''), 0, 150); - } - - $this->jsonResponse(['results' => $results]); - } - catch (\Throwable $e) - { - Log::add('KB search failed: ' . $e->getMessage(), Log::ERROR, 'mokosuiteclient'); - $this->jsonResponse(['results' => [], 'error' => 'Search unavailable']); + $this->jsonResponse(['success' => false, 'message' => 'Error: ' . $e->getMessage()]); } } diff --git a/source/packages/com_mokosuiteclient/admin/src/View/Dashboard/HtmlView.php b/source/packages/com_mokosuiteclient/admin/src/View/Dashboard/HtmlView.php index 322ba7b1..8eef3af2 100644 --- a/source/packages/com_mokosuiteclient/admin/src/View/Dashboard/HtmlView.php +++ b/source/packages/com_mokosuiteclient/admin/src/View/Dashboard/HtmlView.php @@ -27,6 +27,7 @@ class HtmlView extends BaseHtmlView protected $loginChartData = []; protected $mokoExtensions = []; public $supportPin = ''; + public $supportPinAvailable = false; public function display($tpl = null) { @@ -47,12 +48,21 @@ class HtmlView extends BaseHtmlView ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) ->where($db->quoteName('folder') . ' = ' . $db->quote('system')) ); - $token = (json_decode((string) $db->loadResult()))->health_api_token ?? ''; + $coreParams = json_decode((string) $db->loadResult()); + $healthToken = $coreParams->health_api_token ?? ''; + $this->supportPinAvailable = !empty($healthToken); - if (!empty($token)) + if (!empty($healthToken)) { - $hash = hash_hmac('sha256', gmdate('Y-m-d'), $token); - $this->supportPin = 'MOKO-' . strtoupper(substr($hash, 0, 4)) . '-' . strtoupper(substr($hash, 4, 4)); + $pinRequestedAt = $coreParams->support_pin_requested_at ?? ''; + $pinTtl = 72 * 3600; + + if (!empty($pinRequestedAt) && (time() - (int) $pinRequestedAt) < $pinTtl) + { + $window = floor((int) $pinRequestedAt / $pinTtl); + $hash = hash_hmac('sha256', (string) $window, $healthToken); + $this->supportPin = 'MOKO-' . strtoupper(substr($hash, 0, 4)) . '-' . strtoupper(substr($hash, 4, 4)); + } } } catch (\Throwable $e) {} diff --git a/source/packages/com_mokosuiteclient/admin/tmpl/dashboard/default.php b/source/packages/com_mokosuiteclient/admin/tmpl/dashboard/default.php index b878b2c5..739f72dd 100644 --- a/source/packages/com_mokosuiteclient/admin/tmpl/dashboard/default.php +++ b/source/packages/com_mokosuiteclient/admin/tmpl/dashboard/default.php @@ -57,13 +57,20 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action escape($siteInfo->sitename); ?> MokoSuite escape($siteInfo->mokosuiteclient_version); ?> supportPin)): ?> - escape($this->supportPin); ?> + escape($this->supportPin); ?> + supportPinAvailable)): ?> + Joomla escape($siteInfo->joomla_version); ?> PHP escape($siteInfo->php_version); ?> diff --git a/source/packages/com_mokosuiteclient/media/js/dashboard.js b/source/packages/com_mokosuiteclient/media/js/dashboard.js index 065fde22..625c218d 100644 --- a/source/packages/com_mokosuiteclient/media/js/dashboard.js +++ b/source/packages/com_mokosuiteclient/media/js/dashboard.js @@ -144,6 +144,43 @@ document.addEventListener('DOMContentLoaded', function () { }); } + // Request PIN button + var pinBtn = document.getElementById('mokosuiteclient-request-pin'); + if (pinBtn) { + pinBtn.addEventListener('click', function () { + var btn = this; + btn.disabled = true; + btn.textContent = '...'; + var fd = new FormData(); + fd.append(btn.dataset.token, '1'); + fetch(btn.dataset.url, {method: 'POST', body: fd, headers: {'X-Requested-With': 'XMLHttpRequest'}}) + .then(function (r) { return r.json(); }) + .then(function (d) { + if (d.success && d.pin) { + var badge = document.createElement('span'); + badge.className = 'badge bg-dark'; + badge.style.cssText = 'font-family:monospace;letter-spacing:0.08em;cursor:help;'; + badge.title = 'Support PIN — valid for 72 hours'; + badge.textContent = d.pin; + var icon = document.createElement('span'); + icon.className = 'icon-key small me-1'; + icon.setAttribute('aria-hidden', 'true'); + badge.prepend(icon); + btn.replaceWith(badge); + } else { + Joomla.renderMessages({error: [d.message || 'Failed to generate PIN']}); + btn.disabled = false; + btn.textContent = 'Request PIN'; + } + }) + .catch(function () { + Joomla.renderMessages({error: ['Network error']}); + btn.disabled = false; + btn.textContent = 'Request PIN'; + }); + }); + } + // Akeeba import buttons ['btn-import-admintools', 'btn-import-ats-dash'].forEach(function(id) { var btn = document.getElementById(id); diff --git a/source/packages/mod_mokosuiteclient_cpanel/src/Dispatcher/Dispatcher.php b/source/packages/mod_mokosuiteclient_cpanel/src/Dispatcher/Dispatcher.php index a3e5ffcf..d652da6d 100644 --- a/source/packages/mod_mokosuiteclient_cpanel/src/Dispatcher/Dispatcher.php +++ b/source/packages/mod_mokosuiteclient_cpanel/src/Dispatcher/Dispatcher.php @@ -47,8 +47,9 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI $data['currentIp'] = $helper->getCurrentIp(); $data['ssl'] = $helper->getSslStatus(); - // Daily support PIN derived from health token + today's date (UTC) + // Support PIN — only shown if requested within last 72 hours $data['supportPin'] = ''; + $data['supportPinAvailable'] = false; try { @@ -65,9 +66,17 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI if (!empty($token)) { - $date = gmdate('Y-m-d'); - $hash = hash_hmac('sha256', $date, $token); - $data['supportPin'] = 'MOKO-' . strtoupper(substr($hash, 0, 4)) . '-' . strtoupper(substr($hash, 4, 4)); + $data['supportPinAvailable'] = true; + $pinRequestedAt = $coreParams->support_pin_requested_at ?? ''; + $pinTtl = 72 * 3600; // 72 hours + + if (!empty($pinRequestedAt) && (time() - (int) $pinRequestedAt) < $pinTtl) + { + // PIN is active — generate from the request timestamp (stable for 72h window) + $window = floor((int) $pinRequestedAt / $pinTtl); + $hash = hash_hmac('sha256', (string) $window, $token); + $data['supportPin'] = 'MOKO-' . strtoupper(substr($hash, 0, 4)) . '-' . strtoupper(substr($hash, 4, 4)); + } } } catch (\Throwable $e) {} diff --git a/source/packages/mod_mokosuiteclient_cpanel/tmpl/default.php b/source/packages/mod_mokosuiteclient_cpanel/tmpl/default.php index 29412f9e..4263c6ea 100644 --- a/source/packages/mod_mokosuiteclient_cpanel/tmpl/default.php +++ b/source/packages/mod_mokosuiteclient_cpanel/tmpl/default.php @@ -71,7 +71,14 @@ $diskColor = ($diskPct !== null && $diskPct > 90) ? 'bg-danger' : (($diskPct !== sitename ?? ''); ?> MokoSuite mokosuiteclient_version ?? ''); ?> - + + + Joomla joomla_version ?? ''); ?> PHP php_version ?? ''); ?> @@ -88,3 +95,37 @@ $diskColor = ($diskPct !== null && $diskPct > 90) ? 'bg-danger' : (($diskPct !== +