feat: support PIN on-demand with 72-hour TTL + controller cleanup
Generic: Project CI / Tests (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Generic: Project CI / Lint & Validate (pull_request) Successful in 10s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 13s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Successful in 15s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 41s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 23s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 51s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 1m1s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 1m2s

- 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
This commit is contained in:
Jonathan Miller
2026-06-23 17:09:32 -05:00
parent 8f3d3cea8b
commit 333966416b
6 changed files with 163 additions and 115 deletions
@@ -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()]);
}
}
@@ -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) {}
@@ -57,13 +57,20 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
<span class="fw-bold"><?php echo $this->escape($siteInfo->sitename); ?></span>
<span class="badge bg-primary">MokoSuite <?php echo $this->escape($siteInfo->mokosuiteclient_version); ?></span>
<?php if (!empty($this->supportPin)): ?>
<span class="badge bg-dark" style="font-family:monospace;letter-spacing:0.08em;cursor:help;" title="Daily verification PIN — rotates at midnight UTC."><span class="icon-key small me-1" aria-hidden="true"></span><?php echo $this->escape($this->supportPin); ?></span>
<span class="badge bg-dark" style="font-family:monospace;letter-spacing:0.08em;cursor:help;" title="Support PIN — valid for 72 hours"><span class="icon-key small me-1" aria-hidden="true"></span><?php echo $this->escape($this->supportPin); ?></span>
<button type="button" class="btn btn-sm btn-outline-primary py-0 px-1" id="mokosuiteclient-btn-heartbeat-pin"
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.sendHeartbeat&format=json'); ?>"
data-token="<?php echo $token; ?>"
title="Send heartbeat with PIN to MokoSuiteHQ">
<span class="icon-upload" aria-hidden="true"></span>
</button>
<?php elseif (!empty($this->supportPinAvailable)): ?>
<button type="button" class="btn btn-sm btn-outline-dark py-0 px-2" id="mokosuiteclient-request-pin"
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.requestPin&format=json'); ?>"
data-token="<?php echo $token; ?>"
style="font-size:0.75rem;" title="Request a support PIN (valid 72 hours)">
<span class="icon-key" aria-hidden="true"></span> Request PIN
</button>
<?php endif; ?>
<span class="badge bg-secondary">Joomla <?php echo $this->escape($siteInfo->joomla_version); ?></span>
<span class="badge bg-secondary">PHP <?php echo $this->escape($siteInfo->php_version); ?></span>
@@ -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);
@@ -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) {}
@@ -71,7 +71,14 @@ $diskColor = ($diskPct !== null && $diskPct > 90) ? 'bg-danger' : (($diskPct !==
<span class="fw-bold"><?php echo htmlspecialchars($siteInfo->sitename ?? ''); ?></span>
<span class="badge bg-primary">MokoSuite <?php echo htmlspecialchars($siteInfo->mokosuiteclient_version ?? ''); ?></span>
<?php if (!empty($supportPin)): ?>
<span class="badge bg-dark" style="font-family:monospace;letter-spacing:0.08em;cursor:help;" title="Daily verification PIN — rotates at midnight UTC."><span class="icon-key small me-1" aria-hidden="true"></span><?php echo htmlspecialchars($supportPin); ?></span>
<span class="badge bg-dark" style="font-family:monospace;letter-spacing:0.08em;cursor:help;" title="Support PIN — valid for 72 hours"><span class="icon-key small me-1" aria-hidden="true"></span><?php echo htmlspecialchars($supportPin); ?></span>
<?php elseif (!empty($supportPinAvailable)): ?>
<button type="button" class="btn btn-sm btn-outline-dark py-0 px-2" id="mokosuiteclient-request-pin"
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.requestPin&format=json'); ?>"
data-token="<?php echo Session::getFormToken(); ?>"
style="font-size:0.75rem;" title="Request a support PIN (valid 72 hours)">
<span class="icon-key" aria-hidden="true"></span> Request PIN
</button>
<?php endif; ?>
<span class="badge bg-secondary">Joomla <?php echo htmlspecialchars($siteInfo->joomla_version ?? ''); ?></span>
<span class="badge bg-secondary">PHP <?php echo htmlspecialchars($siteInfo->php_version ?? ''); ?></span>
@@ -88,3 +95,37 @@ $diskColor = ($diskPct !== null && $diskPct > 90) ? 'bg-danger' : (($diskPct !==
</span>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var btn = document.getElementById('mokosuiteclient-request-pin');
if (!btn) return;
btn.addEventListener('click', function() {
var el = this;
el.disabled = true;
el.textContent = '...';
var fd = new FormData();
fd.append(el.dataset.token, '1');
fetch(el.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 = 'font-family:monospace;letter-spacing:0.08em;cursor:help;';
badge.title = 'Support PIN — valid for 72 hours';
badge.innerHTML = '<span class="icon-key small me-1" aria-hidden="true"></span>' + d.pin;
el.replaceWith(badge);
} else {
Joomla.renderMessages({error:[d.message||'Failed to generate PIN']});
el.disabled = false;
el.innerHTML = '<span class="icon-key" aria-hidden="true"></span> Request PIN';
}
})
.catch(function(){
Joomla.renderMessages({error:['Network error']});
el.disabled = false;
el.innerHTML = '<span class="icon-key" aria-hidden="true"></span> Request PIN';
});
});
});
</script>