@@ -122,6 +188,10 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
protected): ?>
+ configure_only): ?>
+
+ enabled ? Text::_('COM_MOKOWAAS_ENABLED') : Text::_('COM_MOKOWAAS_DISABLED'); ?>
+
tableData;
+$tables = $data['tables'] ?? [];
+$token = Session::getFormToken();
+$optimizeUrl = Route::_('index.php?option=com_mokowaas&task=display.optimizeDb&format=json');
+$repairUrl = Route::_('index.php?option=com_mokowaas&task=display.repairDb&format=json');
+$purgeUrl = Route::_('index.php?option=com_mokowaas&task=display.purgeSessions&format=json');
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Table | Engine | Rows | Size | Overhead |
+
+
+
+ | name); ?> |
+ engine); ?> |
+ rows); ?> |
+ size_mb; ?> MB |
+ overhead_kb > 0 ? $t->overhead_kb . ' KB' : '—'; ?> |
+
+
+
+
+
+
+
+
+
diff --git a/src/packages/com_mokowaas/admin/tmpl/htaccess/default.php b/src/packages/com_mokowaas/admin/tmpl/htaccess/default.php
new file mode 100644
index 00000000..79155233
--- /dev/null
+++ b/src/packages/com_mokowaas/admin/tmpl/htaccess/default.php
@@ -0,0 +1,306 @@
+options;
+$preview = $this->preview;
+$nginx = $this->nginxPreview;
+$current = $this->currentHtaccess;
+$token = Session::getFormToken();
+$saveUrl = Route::_('index.php?option=com_mokowaas&task=display.saveHtaccess&format=json');
+$genUrl = Route::_('index.php?option=com_mokowaas&task=display.generateHtaccess&format=json');
+
+// Helper for toggle switch
+$sw = function($name, $label, $desc = '') use ($opts) {
+ $checked = !empty($opts[$name]) ? 'checked' : '';
+ echo '
';
+ echo '
' . htmlspecialchars($label) . '';
+ if ($desc) echo '
' . htmlspecialchars($desc) . '';
+ echo '
';
+ echo '
';
+ echo '';
+ echo '
';
+};
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/packages/com_mokowaas/admin/tmpl/privacy/default.php b/src/packages/com_mokowaas/admin/tmpl/privacy/default.php
new file mode 100644
index 00000000..0b6f6f72
--- /dev/null
+++ b/src/packages/com_mokowaas/admin/tmpl/privacy/default.php
@@ -0,0 +1,184 @@
+requests;
+$policies = $this->policies;
+$summary = $this->summary;
+$token = Session::getFormToken();
+
+$statusBadge = [
+ 'pending' => 'bg-warning text-dark',
+ 'processing' => 'bg-info',
+ 'completed' => 'bg-success',
+ 'denied' => 'bg-secondary',
+];
+$typeBadge = [
+ 'export' => 'bg-primary',
+ 'delete' => 'bg-danger',
+ 'anonymize' => 'bg-warning text-dark',
+];
+?>
+
+
+
+
+
+
+ pending_requests; ?>
+ Pending Requests
+
+
+
+
+ total_requests; ?>
+ Total Requests
+
+
+
+
+ consent_entries; ?>
+ Consent Entries
+
+
+
+
+ policies_active; ?>
+ Active Policies
+
+
+
+
+
+
+
+
+
+
+
No data requests found.
+
+
+
+ | # | User | Type | Status | Created | Processed | Actions |
+
+
+
+ | id; ?> |
+ escape($r->user_name ?? ''); ?> escape($r->user_email ?? ''); ?> |
+ type); ?> |
+ status); ?> |
+ created, 'M d, Y H:i'); ?> |
+ processed ? HTMLHelper::_('date', $r->processed, 'M d, Y H:i') : '—'; ?> |
+
+ status === 'pending'): ?>
+
+
+
+
+ status === 'completed' && $r->type === 'export'): ?>
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Type | Days | Action | Active |
+
+
+
+ | escape($p->content_type); ?> |
+ retention_days; ?> |
+ action; ?> |
+ enabled ? 'Yes' : 'No'; ?> |
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/packages/com_mokowaas/admin/tmpl/ticket/default.php b/src/packages/com_mokowaas/admin/tmpl/ticket/default.php
new file mode 100644
index 00000000..e7ceb8ee
--- /dev/null
+++ b/src/packages/com_mokowaas/admin/tmpl/ticket/default.php
@@ -0,0 +1,198 @@
+ticket;
+$canned = $this->cannedResponses;
+$token = Session::getFormToken();
+
+$statusBadge = [
+ 'open' => 'bg-primary', 'in_progress' => 'bg-info',
+ 'waiting' => 'bg-warning text-dark', 'resolved' => 'bg-success', 'closed' => 'bg-secondary',
+];
+$priorityBadge = [
+ 'low' => 'bg-secondary', 'normal' => 'bg-primary', 'high' => 'bg-warning text-dark', 'urgent' => 'bg-danger',
+];
+?>
+
+
+
+
+
+
+
+
escape($t->body)); ?>
+
+
+
+ replies as $reply): ?>
+
+
+
escape($reply->body)); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Status | status)); ?> |
+ | Priority | priority); ?> |
+ | Category | escape($t->category_title ?? '—'); ?> |
+ | Created By | escape($t->created_by_name); ?> escape($t->created_by_email ?? ''); ?> |
+ | Assigned To | escape($t->assigned_to_name ?? 'Unassigned'); ?> |
+ | Created | created, 'M d, Y H:i'); ?> |
+ resolved): ?>| Resolved | resolved, 'M d, Y H:i'); ?> |
+ closed): ?>| Closed | closed, 'M d, Y H:i'); ?> |
+ | Replies | reply_count; ?> |
+
+
+
+
+
+ sla_response_due || $t->sla_resolution_due): ?>
+
+
+
+ sla_response_due): ?>
+
+ Response Due
+ sla_responded && strtotime($t->sla_response_due) < time();
+ ?>
+
+ sla_responded ? 'Responded' : HTMLHelper::_('date', $t->sla_response_due, 'M d H:i'); ?>
+
+
+
+
+ sla_resolution_due): ?>
+
+ Resolution Due
+ status, ['resolved','closed']) && strtotime($t->sla_resolution_due) < time();
+ ?>
+
+ status, ['resolved','closed']) ? 'Met' : HTMLHelper::_('date', $t->sla_resolution_due, 'M d H:i'); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+ 'Reopen', 'in_progress' => 'In Progress', 'waiting' => 'Waiting', 'resolved' => 'Resolve', 'closed' => 'Close'] as $s => $label): ?>
+ status): ?>
+
+
+
+
+
+
+
+
+
diff --git a/src/packages/com_mokowaas/admin/tmpl/tickets/default.php b/src/packages/com_mokowaas/admin/tmpl/tickets/default.php
new file mode 100644
index 00000000..33c7f502
--- /dev/null
+++ b/src/packages/com_mokowaas/admin/tmpl/tickets/default.php
@@ -0,0 +1,291 @@
+tickets;
+$categories = $this->categories;
+$counts = $this->statusCounts;
+$overdue = $this->overdue;
+$atsAvailable = $this->atsAvailable;
+$token = Session::getFormToken();
+
+$statusBadge = [
+ 'open' => 'bg-primary',
+ 'in_progress' => 'bg-info',
+ 'waiting' => 'bg-warning text-dark',
+ 'resolved' => 'bg-success',
+ 'closed' => 'bg-secondary',
+];
+
+$priorityBadge = [
+ 'low' => 'bg-secondary',
+ 'normal' => 'bg-primary',
+ 'high' => 'bg-warning text-dark',
+ 'urgent' => 'bg-danger',
+];
+?>
+
+
+
+
+
+
in_progress; ?>In Progress
+
+
+
+ 0): ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | # |
+ Subject |
+ Status |
+ Priority |
+ Category |
+ Created By |
+ Assigned To |
+ Created |
+ SLA |
+
+
+
+
+ | No tickets found. |
+
+
+ sla_response_due && !$t->sla_responded && strtotime($t->sla_response_due) < $now) $slaClass = 'table-danger';
+ elseif ($t->sla_resolution_due && strtotime($t->sla_resolution_due) < $now && !\in_array($t->status, ['resolved','closed'])) $slaClass = 'table-danger';
+ elseif ($t->sla_response_due && !$t->sla_responded && strtotime($t->sla_response_due) < $now + 3600) $slaClass = 'table-warning';
+ ?>
+
+ | id; ?> |
+ escape(mb_substr($t->subject, 0, 60)); ?> |
+ status)); ?> |
+ priority); ?> |
+ escape($t->category_title ?? '—'); ?> |
+ escape($t->created_by_name ?? ''); ?> |
+ assigned_to_name ? $this->escape($t->assigned_to_name) : 'Unassigned'; ?> |
+ created, 'M d H:i'); ?> |
+
+ sla_response_due && !$t->sla_responded): ?>
+ sla_response_due, 'M d H:i'); ?>
+ sla_resolution_due): ?>
+ sla_resolution_due, 'M d H:i'); ?>
+ —
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/packages/com_mokowaas/admin/tmpl/waflog/default.php b/src/packages/com_mokowaas/admin/tmpl/waflog/default.php
new file mode 100644
index 00000000..4fab7ab2
--- /dev/null
+++ b/src/packages/com_mokowaas/admin/tmpl/waflog/default.php
@@ -0,0 +1,212 @@
+logs;
+$ruleCounts = $this->ruleCounts;
+$topIps = $this->topIps;
+$ruleNames = $this->ruleNames;
+$total = $this->total;
+$filters = $this->filters;
+$token = Session::getFormToken();
+$input = Factory::getApplication()->getInput();
+$page = max(1, $input->getInt('page', 1));
+$totalPages = max(1, ceil($total / 50));
+
+$ruleBadge = [
+ 'sqli' => 'bg-danger', 'xss' => 'bg-danger', 'mua' => 'bg-warning text-dark',
+ 'rfi' => 'bg-danger', 'dfi' => 'bg-danger', 'blocked_file' => 'bg-info',
+ 'blocked_php' => 'bg-info', 'tmpl_switch' => 'bg-secondary',
+ 'ip_blocklist' => 'bg-dark', 'admin_secret' => 'bg-dark',
+];
+?>
+
+
+
+
+
+
+ rule); ?>
+ cnt); ?>
+
+
+
+ Total
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Time | IP | Rule | URI | Detail | User Agent | |
+
+
+
+ | No blocked requests found. |
+
+
+
+ | created, 'M d H:i:s'); ?> |
+ ip); ?> |
+ rule); ?> |
+ uri, 0, 60)); ?> |
+ detail, 0, 50)); ?> |
+ user_agent, 0, 40)); ?> |
+
+
+ |
+
+
+
+
+
+
+
+ 1): ?>
+
+
+
+
+
+
+
+
+
+
+
+ | IP | Blocks | Last | |
+
+
+
+ ip); ?> |
+ cnt; ?> |
+ last_seen, 'M d'); ?> |
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/packages/com_mokowaas/media/js/dashboard.js b/src/packages/com_mokowaas/media/js/dashboard.js
index df8433ed..e6aa671c 100644
--- a/src/packages/com_mokowaas/media/js/dashboard.js
+++ b/src/packages/com_mokowaas/media/js/dashboard.js
@@ -109,4 +109,26 @@ document.addEventListener('DOMContentLoaded', function () {
});
});
}
+
+ // Akeeba import buttons
+ ['btn-import-admintools', 'btn-import-ats-dash'].forEach(function(id) {
+ var btn = document.getElementById(id);
+ if (!btn) return;
+ btn.addEventListener('click', function() {
+ var el = this;
+ if (!confirm('Import Akeeba data into MokoWaaS? Akeeba extensions will be disabled after import.')) return;
+ el.disabled = true;
+ var origText = el.textContent;
+ el.textContent = ' Importing...';
+ 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) { Joomla.renderMessages({message:[d.message]}); setTimeout(function(){location.reload()}, 2000); }
+ else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; el.textContent = origText; }
+ })
+ .catch(function(){ Joomla.renderMessages({error:['Network error']}); el.disabled = false; el.textContent = origText; });
+ });
+ });
});
diff --git a/src/packages/com_mokowaas/mokowaas.xml b/src/packages/com_mokowaas/mokowaas.xml
index 803387a9..8313f8c0 100644
--- a/src/packages/com_mokowaas/mokowaas.xml
+++ b/src/packages/com_mokowaas/mokowaas.xml
@@ -20,21 +20,52 @@
GPL-3.0-or-later
hello@mokoconsulting.tech
https://mokoconsulting.tech
-
02.33.00
+
02.32.52
MokoWaaS admin dashboard and REST API. Provides a control panel for managing MokoWaaS feature plugins, site health monitoring, and remote management endpoints.
Moko\Component\MokoWaaS
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ access.xml
+ config.xml
language
services
+ sql
src
tmpl
+
+ en-GB/com_mokowaas.sys.ini
+
+
+ language
+ services
+ src
+ tmpl
+
+
+
+ admin/sql/install.mysql.sql
+
+
src
diff --git a/src/packages/com_mokowaas/site/language/en-GB/com_mokowaas.ini b/src/packages/com_mokowaas/site/language/en-GB/com_mokowaas.ini
new file mode 100644
index 00000000..3047c85e
--- /dev/null
+++ b/src/packages/com_mokowaas/site/language/en-GB/com_mokowaas.ini
@@ -0,0 +1,11 @@
+; MokoWaaS Customer Portal - Language Strings
+; Copyright (C) 2026 Moko Consulting. All rights reserved.
+; License: GPL-3.0-or-later
+
+COM_MOKOWAAS_PORTAL_TITLE="Support Portal"
+COM_MOKOWAAS_PORTAL_MY_TICKETS="My Support Tickets"
+COM_MOKOWAAS_PORTAL_NEW_TICKET="New Ticket"
+COM_MOKOWAAS_PORTAL_SUBMIT="Submit Ticket"
+COM_MOKOWAAS_PORTAL_REPLY="Send Reply"
+COM_MOKOWAAS_PORTAL_NO_TICKETS="You haven't submitted any support tickets yet."
+COM_MOKOWAAS_PORTAL_LOGIN_REQUIRED="Please log in to access the support portal."
diff --git a/src/packages/com_mokowaas/site/services/provider.php b/src/packages/com_mokowaas/site/services/provider.php
new file mode 100644
index 00000000..cb74ca34
--- /dev/null
+++ b/src/packages/com_mokowaas/site/services/provider.php
@@ -0,0 +1,38 @@
+registerServiceProvider(new MVCFactory('\\Moko\\Component\\MokoWaaS'));
+ $container->registerServiceProvider(new ComponentDispatcherFactory('\\Moko\\Component\\MokoWaaS'));
+
+ $container->set(
+ ComponentInterface::class,
+ function (Container $container) {
+ $component = new \Joomla\CMS\Extension\MVCComponent(
+ $container->get(ComponentDispatcherFactoryInterface::class)
+ );
+ $component->setMVCFactory($container->get(MVCFactoryInterface::class));
+
+ return $component;
+ }
+ );
+ }
+};
diff --git a/src/packages/com_mokowaas/site/src/Controller/DisplayController.php b/src/packages/com_mokowaas/site/src/Controller/DisplayController.php
new file mode 100644
index 00000000..1018e9eb
--- /dev/null
+++ b/src/packages/com_mokowaas/site/src/Controller/DisplayController.php
@@ -0,0 +1,267 @@
+getIdentity();
+
+ if ($user->guest)
+ {
+ Factory::getApplication()->enqueueMessage('Please log in to access the support portal.', 'warning');
+ Factory::getApplication()->redirect(Route::_(
+ 'index.php?option=com_users&view=login&return=' . base64_encode('index.php?option=com_mokowaas&view=tickets'),
+ false
+ ));
+
+ return;
+ }
+
+ return parent::display($cachable, $urlparams);
+ }
+
+ /**
+ * Submit a new ticket.
+ */
+ public function submitTicket()
+ {
+ Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
+
+ $user = Factory::getApplication()->getIdentity();
+
+ if ($user->guest)
+ {
+ $this->jsonResponse(['success' => false, 'message' => 'Please log in.']);
+ return;
+ }
+
+ $input = Factory::getApplication()->getInput();
+
+ // Use admin TicketsModel
+ $model = $this->getModel('Tickets', 'Administrator');
+
+ $this->jsonResponse($model->createTicket([
+ 'subject' => $input->getString('subject', ''),
+ 'body' => $input->getRaw('body', ''),
+ 'priority' => $input->getString('priority', 'normal'),
+ 'category_id' => $input->getInt('category_id', 0),
+ ]));
+ }
+
+ /**
+ * Submit a reply.
+ */
+ public function submitReply()
+ {
+ Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
+
+ $user = Factory::getApplication()->getIdentity();
+ $input = Factory::getApplication()->getInput();
+
+ if ($user->guest)
+ {
+ $this->jsonResponse(['success' => false, 'message' => 'Please log in.']);
+ return;
+ }
+
+ $ticketId = $input->getInt('ticket_id', 0);
+ $model = $this->getModel('Tickets', 'Administrator');
+ $ticket = $model->getTicket($ticketId);
+
+ if (!$ticket)
+ {
+ $this->jsonResponse(['success' => false, 'message' => 'Ticket not found.']);
+ return;
+ }
+
+ // Customers can only reply to their own tickets; staff can reply to any
+ if ((int) $ticket->created_by !== $user->id && !$this->isStaff($user))
+ {
+ $this->jsonResponse(['success' => false, 'message' => 'Access denied.']);
+ return;
+ }
+
+ // Staff replies from frontend are not internal notes
+ $this->jsonResponse($model->addReply(
+ $ticketId,
+ $input->getRaw('body', ''),
+ false
+ ));
+ }
+
+ /**
+ * Update ticket status (staff/manager only from frontend).
+ */
+ public function updateStatus()
+ {
+ Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
+
+ $user = Factory::getApplication()->getIdentity();
+
+ if (!$this->isStaff($user))
+ {
+ $this->jsonResponse(['success' => false, 'message' => 'Access denied.']);
+ return;
+ }
+
+ $input = Factory::getApplication()->getInput();
+ $model = $this->getModel('Tickets', 'Administrator');
+
+ $this->jsonResponse($model->updateStatus(
+ $input->getInt('ticket_id', 0),
+ $input->getString('status', '')
+ ));
+ }
+
+ /**
+ * Assign a ticket (manager only from frontend).
+ */
+ public function assignTicket()
+ {
+ Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
+
+ $user = Factory::getApplication()->getIdentity();
+
+ if (!$user->authorise('mokowaas.tickets.assign', 'com_mokowaas'))
+ {
+ $this->jsonResponse(['success' => false, 'message' => 'Access denied.']);
+ return;
+ }
+
+ $input = Factory::getApplication()->getInput();
+ $ticketId = $input->getInt('ticket_id', 0);
+ $assignTo = $input->getInt('assigned_to', 0);
+
+ try
+ {
+ $db = Factory::getDbo();
+ $db->setQuery(
+ $db->getQuery(true)
+ ->update($db->quoteName('#__mokowaas_tickets'))
+ ->set($db->quoteName('assigned_to') . ' = ' . ($assignTo ?: 'NULL'))
+ ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql()))
+ ->where($db->quoteName('id') . ' = ' . $ticketId)
+ )->execute();
+
+ $this->jsonResponse(['success' => true, 'message' => 'Ticket assigned.']);
+ }
+ catch (\Throwable $e)
+ {
+ $this->jsonResponse(['success' => false, 'message' => $e->getMessage()]);
+ return;
+ }
+ }
+
+ /**
+ * Submit a data privacy request from frontend.
+ */
+ public function submitDataRequest()
+ {
+ Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
+
+ $user = Factory::getApplication()->getIdentity();
+
+ if ($user->guest)
+ {
+ $this->jsonResponse(['success' => false, 'message' => 'Please log in.']);
+ return;
+ }
+
+ $type = Factory::getApplication()->getInput()->getString('type', '');
+ $model = new \Moko\Component\MokoWaaS\Administrator\Model\PrivacyModel();
+
+ $this->jsonResponse($model->createRequest($user->id, $type, 'Submitted via self-service portal'));
+ }
+
+ /**
+ * Check if user is support staff (can manage tickets beyond their own).
+ */
+ private function isStaff($user): bool
+ {
+ if ($user->guest)
+ {
+ return false;
+ }
+
+ // Super admins always staff
+ if ($user->authorise('core.admin'))
+ {
+ return true;
+ }
+
+ // Anyone with mokowaas.tickets ACL on the component is staff
+ return $user->authorise('mokowaas.tickets', 'com_mokowaas');
+ }
+
+ /**
+ * Search KB articles via Smart Search (com_finder).
+ */
+ public function searchKb()
+ {
+ $query = Factory::getApplication()->getInput()->getString('q', '');
+
+ if (strlen($query) < 3)
+ {
+ $this->jsonResponse(['results' => []]);
+ }
+
+ try
+ {
+ $db = Factory::getDbo();
+ $escaped = $db->quote('%' . $db->escape($query, true) . '%');
+
+ $results = $db->setQuery(
+ $db->getQuery(true)
+ ->select([
+ $db->quoteName('l.link_id'),
+ $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)
+ {
+ $this->jsonResponse(['results' => []]);
+ }
+ }
+
+ private function jsonResponse(array $data): void
+ {
+ $app = Factory::getApplication();
+ $app->setHeader('Content-Type', 'application/json');
+ echo json_encode($data);
+ $app->close();
+ }
+}
diff --git a/src/packages/com_mokowaas/site/src/View/Privacy/HtmlView.php b/src/packages/com_mokowaas/site/src/View/Privacy/HtmlView.php
new file mode 100644
index 00000000..a6b70082
--- /dev/null
+++ b/src/packages/com_mokowaas/site/src/View/Privacy/HtmlView.php
@@ -0,0 +1,68 @@
+getIdentity();
+
+ if ($user->guest)
+ {
+ Factory::getApplication()->redirect(Route::_(
+ 'index.php?option=com_users&view=login&return=' . base64_encode('index.php?option=com_mokowaas&view=privacy'),
+ false
+ ));
+
+ return;
+ }
+
+ $db = Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
+
+ // Get user's data requests
+ $query = $db->getQuery(true)
+ ->select('*')
+ ->from($db->quoteName('#__mokowaas_data_requests'))
+ ->where($db->quoteName('user_id') . ' = ' . (int) $user->id)
+ ->order($db->quoteName('created') . ' DESC');
+
+ try
+ {
+ $db->setQuery($query);
+ $this->requests = $db->loadObjectList() ?: [];
+ }
+ catch (\Throwable $e)
+ {
+ $this->requests = [];
+ }
+
+ // Get consent history
+ try
+ {
+ $db->setQuery(
+ $db->getQuery(true)
+ ->select('*')
+ ->from($db->quoteName('#__mokowaas_consent_log'))
+ ->where($db->quoteName('user_id') . ' = ' . (int) $user->id)
+ ->order($db->quoteName('created') . ' DESC')
+ ->setLimit(20)
+ );
+ $this->consent = $db->loadObjectList() ?: [];
+ }
+ catch (\Throwable $e)
+ {
+ $this->consent = [];
+ }
+
+ parent::display($tpl);
+ }
+}
diff --git a/src/packages/com_mokowaas/site/src/View/Ticket/HtmlView.php b/src/packages/com_mokowaas/site/src/View/Ticket/HtmlView.php
new file mode 100644
index 00000000..4a4289e6
--- /dev/null
+++ b/src/packages/com_mokowaas/site/src/View/Ticket/HtmlView.php
@@ -0,0 +1,84 @@
+get('Joomla\Database\DatabaseInterface');
+ $user = Factory::getApplication()->getIdentity();
+ $id = Factory::getApplication()->getInput()->getInt('id', 0);
+
+ $this->isStaff = $user->authorise('core.admin') || $user->authorise('mokowaas.tickets', 'com_mokowaas');
+ $this->canAssign = $user->authorise('core.admin') || $user->authorise('mokowaas.tickets.assign', 'com_mokowaas');
+
+ // Get ticket — staff see any, customers see only their own
+ $query = $db->getQuery(true)
+ ->select([
+ $db->quoteName('t') . '.*',
+ $db->quoteName('c.title', 'category_title'),
+ $db->quoteName('u.name', 'created_by_name'),
+ $db->quoteName('u.email', 'created_by_email'),
+ $db->quoteName('a.name', 'assigned_to_name'),
+ ])
+ ->from($db->quoteName('#__mokowaas_tickets', 't'))
+ ->leftJoin($db->quoteName('#__mokowaas_ticket_categories', 'c') . ' ON c.id = t.category_id')
+ ->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
+ ->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to')
+ ->where($db->quoteName('t.id') . ' = ' . $id);
+
+ if (!$this->isStaff)
+ {
+ $query->where($db->quoteName('t.created_by') . ' = ' . (int) $user->id);
+ }
+
+ $db->setQuery($query);
+ $this->ticket = $db->loadObject();
+
+ if (!$this->ticket)
+ {
+ Factory::getApplication()->enqueueMessage('Ticket not found.', 'error');
+ Factory::getApplication()->redirect(Route::_('index.php?option=com_mokowaas&view=tickets', false));
+
+ return;
+ }
+
+ // Load replies — staff see internal notes, customers don't
+ $query = $db->getQuery(true)
+ ->select([
+ $db->quoteName('r') . '.*',
+ $db->quoteName('u.name', 'user_name'),
+ ])
+ ->from($db->quoteName('#__mokowaas_ticket_replies', 'r'))
+ ->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id')
+ ->where($db->quoteName('r.ticket_id') . ' = ' . $id);
+
+ if (!$this->isStaff)
+ {
+ $query->where($db->quoteName('r.is_internal') . ' = 0');
+ }
+
+ $query->order($db->quoteName('r.created') . ' ASC');
+ $db->setQuery($query);
+ $this->ticket->replies = $db->loadObjectList() ?: [];
+
+ parent::display($tpl);
+ }
+}
diff --git a/src/packages/com_mokowaas/site/src/View/Tickets/HtmlView.php b/src/packages/com_mokowaas/site/src/View/Tickets/HtmlView.php
new file mode 100644
index 00000000..5988fba9
--- /dev/null
+++ b/src/packages/com_mokowaas/site/src/View/Tickets/HtmlView.php
@@ -0,0 +1,75 @@
+get('Joomla\Database\DatabaseInterface');
+ $user = Factory::getApplication()->getIdentity();
+
+ $this->isStaff = $user->authorise('core.admin')
+ || $user->authorise('mokowaas.tickets', 'com_mokowaas');
+
+ // Staff see all tickets, customers see their own
+ $query = $db->getQuery(true)
+ ->select([
+ $db->quoteName('t.id'),
+ $db->quoteName('t.subject'),
+ $db->quoteName('t.status'),
+ $db->quoteName('t.priority'),
+ $db->quoteName('t.created'),
+ $db->quoteName('t.assigned_to'),
+ $db->quoteName('c.title', 'category_title'),
+ $db->quoteName('u.name', 'created_by_name'),
+ $db->quoteName('a.name', 'assigned_to_name'),
+ ])
+ ->from($db->quoteName('#__mokowaas_tickets', 't'))
+ ->leftJoin($db->quoteName('#__mokowaas_ticket_categories', 'c') . ' ON c.id = t.category_id')
+ ->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
+ ->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to');
+
+ if (!$this->isStaff)
+ {
+ $query->where($db->quoteName('t.created_by') . ' = ' . (int) $user->id);
+ }
+
+ $filterStatus = Factory::getApplication()->getInput()->getString('filter_status', '');
+
+ if ($filterStatus)
+ {
+ $query->where($db->quoteName('t.status') . ' = ' . $db->quote($filterStatus));
+ }
+
+ $query->order($db->quoteName('t.created') . ' DESC')->setLimit(50);
+ $db->setQuery($query);
+ $this->tickets = $db->loadObjectList() ?: [];
+
+ // Categories for new ticket form
+ $query = $db->getQuery(true)
+ ->select([$db->quoteName('id'), $db->quoteName('title')])
+ ->from($db->quoteName('#__mokowaas_ticket_categories'))
+ ->where($db->quoteName('published') . ' = 1')
+ ->order($db->quoteName('ordering') . ' ASC');
+ $db->setQuery($query);
+ $this->categories = $db->loadObjectList() ?: [];
+
+ parent::display($tpl);
+ }
+}
diff --git a/src/packages/com_mokowaas/site/tmpl/privacy/default.php b/src/packages/com_mokowaas/site/tmpl/privacy/default.php
new file mode 100644
index 00000000..f26b4e6a
--- /dev/null
+++ b/src/packages/com_mokowaas/site/tmpl/privacy/default.php
@@ -0,0 +1,114 @@
+getIdentity();
+$requests = $this->requests;
+$consent = $this->consent;
+$token = Session::getFormToken();
+
+$statusLabel = ['pending' => 'Pending', 'processing' => 'Processing', 'completed' => 'Completed', 'denied' => 'Denied'];
+$statusClass = ['pending' => 'warning', 'processing' => 'info', 'completed' => 'success', 'denied' => 'secondary'];
+?>
+
+
+
My Privacy & Data
+
Manage your personal data, download your information, or request account deletion.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Type | Status | Submitted | Processed |
+
+
+
+ | type); ?> |
+ status] ?? $r->status; ?> |
+ created, 'M d, Y H:i'); ?> |
+ processed ? HTMLHelper::_('date', $r->processed, 'M d, Y H:i') : '—'; ?> |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Category | Action | Date |
+
+
+
+ | category))); ?> |
+ action); ?> |
+ created, 'M d, Y H:i'); ?> |
+
+
+
+
+
+
+
+
+
+
diff --git a/src/packages/com_mokowaas/site/tmpl/ticket/default.php b/src/packages/com_mokowaas/site/tmpl/ticket/default.php
new file mode 100644
index 00000000..7f84e579
--- /dev/null
+++ b/src/packages/com_mokowaas/site/tmpl/ticket/default.php
@@ -0,0 +1,241 @@
+ticket;
+$isStaff = $this->isStaff;
+$canAssign = $this->canAssign;
+$token = Session::getFormToken();
+$userId = Factory::getApplication()->getIdentity()->id;
+
+$statusLabel = [
+ 'open' => 'Open', 'in_progress' => 'In Progress', 'waiting' => 'Awaiting Response',
+ 'resolved' => 'Resolved', 'closed' => 'Closed',
+];
+$statusClass = [
+ 'open' => 'primary', 'in_progress' => 'info', 'waiting' => 'warning',
+ 'resolved' => 'success', 'closed' => 'secondary',
+];
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
#id; ?> — subject); ?>
+
+ category_title ?? 'General'); ?>
+ · created, 'M d, Y H:i'); ?>
+ · priority); ?>
+
+ · By: created_by_name); ?>
+
+
+
+
+ status] ?? $t->status; ?>
+
+
+
+
+
+
+
+
+
+ replies as $reply): ?>
+ user_id !== (int) $t->created_by);
+ $isInternal = (int) $reply->is_internal;
+ ?>
+
+
+
+
+ status, ['closed'])): ?>
+
+
+
Reply
+
+
+
+ status === 'closed'): ?>
+
+
+
+
+
+
+
+
+
+
+
+
+ - Status
+ - status] ?? $t->status; ?>
+ - Priority
+ - priority); ?>
+ - Category
+ - category_title ?? '—'); ?>
+ - Submitted By
+ - created_by_name); ?>
created_by_email ?? ''); ?>
+ - Assigned To
+ - assigned_to_name ?? 'Unassigned'); ?>
+ - Created
+ - created, 'M d H:i'); ?>
+ - Replies
+ - replies); ?>
+
+
+
+
+
+
+
+
+ 'Reopen', 'in_progress' => 'In Progress', 'waiting' => 'Waiting on Customer', 'resolved' => 'Resolve', 'closed' => 'Close'] as $s => $label): ?>
+ status): ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/packages/com_mokowaas/site/tmpl/tickets/default.php b/src/packages/com_mokowaas/site/tmpl/tickets/default.php
new file mode 100644
index 00000000..8ed9e1a3
--- /dev/null
+++ b/src/packages/com_mokowaas/site/tmpl/tickets/default.php
@@ -0,0 +1,83 @@
+tickets;
+$categories = $this->categories;
+$isStaff = $this->isStaff;
+$token = Session::getFormToken();
+
+$statusLabel = [
+ 'open' => 'Open', 'in_progress' => 'In Progress', 'waiting' => 'Awaiting Response',
+ 'resolved' => 'Resolved', 'closed' => 'Closed',
+];
+$statusClass = [
+ 'open' => 'primary', 'in_progress' => 'info', 'waiting' => 'warning',
+ 'resolved' => 'success', 'closed' => 'secondary',
+];
+?>
+
+
+
+
+
+
+ New Ticket
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | # |
+ Subject |
+ Status |
+ Priority |
+ Category |
+ Submitted By | Assigned To |
+ Date |
+
+
+
+
+
+ | id; ?> |
+ subject, 0, 60)); ?> |
+ status] ?? $t->status; ?> |
+ priority); ?> |
+ category_title ?? '—'); ?> |
+
+ created_by_name ?? ''); ?> |
+ assigned_to_name ?? 'Unassigned'); ?> |
+
+ created, 'M d, Y'); ?> |
+
+
+
+
+
+
+
diff --git a/src/packages/com_mokowaas/site/tmpl/tickets/submit.php b/src/packages/com_mokowaas/site/tmpl/tickets/submit.php
new file mode 100644
index 00000000..cc5da1b8
--- /dev/null
+++ b/src/packages/com_mokowaas/site/tmpl/tickets/submit.php
@@ -0,0 +1,204 @@
+categories;
+$token = Session::getFormToken();
+$searchUrl = Route::_('index.php?option=com_mokowaas&task=display.searchKb&format=json');
+$submitUrl = Route::_('index.php?option=com_mokowaas&task=display.submitTicket&format=json');
+$ticketUrl = Route::_('index.php?option=com_mokowaas&view=ticket&id=');
+$ticketsUrl = Route::_('index.php?option=com_mokowaas&view=tickets');
+
+// Check if Smart Search has indexed content
+$finderEnabled = false;
+try {
+ $db = \Joomla\CMS\Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
+ $db->setQuery('SELECT COUNT(*) FROM #__finder_links WHERE published = 1');
+ $finderEnabled = (int) $db->loadResult() > 0;
+} catch (\Throwable $e) {}
+?>
+
+
+
Submit a Support Request
+
+
+
+
+
Before submitting, let's see if we already have an answer for you.
+
+
+
+
+
+
+
+
+
+
+
+
+
Related Articles
+
+
Didn't find what you need?
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/packages/mod_mokowaas_cache/language/en-GB/mod_mokowaas_cache.ini b/src/packages/mod_mokowaas_cache/language/en-GB/mod_mokowaas_cache.ini
new file mode 100644
index 00000000..dd3acea8
--- /dev/null
+++ b/src/packages/mod_mokowaas_cache/language/en-GB/mod_mokowaas_cache.ini
@@ -0,0 +1,3 @@
+MOD_MOKOWAAS_CACHE="MokoWaaS Cache Cleaner"
+MOD_MOKOWAAS_CACHE_DESC="One-click cache cleaner in the admin status bar. Clears all Joomla cache (site, admin, and expired)."
+MOD_MOKOWAAS_CACHE_CLEAR_ALL="Clear All Cache"
diff --git a/src/packages/mod_mokowaas_cache/language/en-GB/mod_mokowaas_cache.sys.ini b/src/packages/mod_mokowaas_cache/language/en-GB/mod_mokowaas_cache.sys.ini
new file mode 100644
index 00000000..25f62d28
--- /dev/null
+++ b/src/packages/mod_mokowaas_cache/language/en-GB/mod_mokowaas_cache.sys.ini
@@ -0,0 +1,2 @@
+MOD_MOKOWAAS_CACHE="MokoWaaS Cache Cleaner"
+MOD_MOKOWAAS_CACHE_DESC="One-click cache cleaner in the admin status bar. Clears all Joomla cache (site, admin, and expired)."
diff --git a/src/packages/mod_mokowaas_cache/mod_mokowaas_cache.xml b/src/packages/mod_mokowaas_cache/mod_mokowaas_cache.xml
new file mode 100644
index 00000000..6364ea8c
--- /dev/null
+++ b/src/packages/mod_mokowaas_cache/mod_mokowaas_cache.xml
@@ -0,0 +1,24 @@
+
+
+ mod_mokowaas_cache
+ Moko Consulting
+ 2026-06-04
+ Copyright (C) 2026 Moko Consulting. All rights reserved.
+ GPL-3.0-or-later
+ hello@mokoconsulting.tech
+ https://mokoconsulting.tech
+ 02.32.52
+ MOD_MOKOWAAS_CACHE_DESC
+ Moko\Module\MokoWaaSCache
+
+
+ services
+ src
+ tmpl
+
+
+
+ en-GB/mod_mokowaas_cache.ini
+ en-GB/mod_mokowaas_cache.sys.ini
+
+
diff --git a/src/packages/mod_mokowaas_cache/services/provider.php b/src/packages/mod_mokowaas_cache/services/provider.php
new file mode 100644
index 00000000..cf5c25c4
--- /dev/null
+++ b/src/packages/mod_mokowaas_cache/services/provider.php
@@ -0,0 +1,23 @@
+registerServiceProvider(new ModuleDispatcherFactory('\\Moko\\Module\\MokoWaaSCache'));
+ $container->registerServiceProvider(new Module());
+ }
+};
diff --git a/src/packages/mod_mokowaas_cache/src/Dispatcher/Dispatcher.php b/src/packages/mod_mokowaas_cache/src/Dispatcher/Dispatcher.php
new file mode 100644
index 00000000..b67aad8d
--- /dev/null
+++ b/src/packages/mod_mokowaas_cache/src/Dispatcher/Dispatcher.php
@@ -0,0 +1,14 @@
+
+
+
+
+
diff --git a/src/packages/mod_mokowaas_cpanel/mod_mokowaas_cpanel.xml b/src/packages/mod_mokowaas_cpanel/mod_mokowaas_cpanel.xml
index 4f12f37c..21f389b9 100644
--- a/src/packages/mod_mokowaas_cpanel/mod_mokowaas_cpanel.xml
+++ b/src/packages/mod_mokowaas_cpanel/mod_mokowaas_cpanel.xml
@@ -7,7 +7,7 @@
GPL-3.0-or-later
hello@mokoconsulting.tech
https://mokoconsulting.tech
- 02.33.00
+ 02.32.52
MOD_MOKOWAAS_CPANEL_DESC
Moko\Module\MokoWaaSCpanel
diff --git a/src/packages/mod_mokowaas_cpanel/src/Dispatcher/Dispatcher.php b/src/packages/mod_mokowaas_cpanel/src/Dispatcher/Dispatcher.php
index d3b1c191..d5b14142 100644
--- a/src/packages/mod_mokowaas_cpanel/src/Dispatcher/Dispatcher.php
+++ b/src/packages/mod_mokowaas_cpanel/src/Dispatcher/Dispatcher.php
@@ -33,6 +33,7 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI
$data['counts'] = $helper->getCounts($db);
$data['disk'] = $helper->getDiskInfo();
$data['currentIp'] = $helper->getCurrentIp();
+ $data['ssl'] = $helper->getSslStatus();
return $data;
}
diff --git a/src/packages/mod_mokowaas_cpanel/src/Helper/CpanelHelper.php b/src/packages/mod_mokowaas_cpanel/src/Helper/CpanelHelper.php
index 7160329e..87fff882 100644
--- a/src/packages/mod_mokowaas_cpanel/src/Helper/CpanelHelper.php
+++ b/src/packages/mod_mokowaas_cpanel/src/Helper/CpanelHelper.php
@@ -87,10 +87,11 @@ class CpanelHelper
public function getCounts(DatabaseInterface $db): object
{
$counts = (object) [
- 'articles' => 0,
- 'users' => 0,
- 'extensions' => 0,
- 'updates' => 0,
+ 'articles' => 0,
+ 'users' => 0,
+ 'extensions' => 0,
+ 'updates' => 0,
+ 'moko_updates' => 0,
];
try
@@ -106,6 +107,20 @@ class CpanelHelper
$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from($db->quoteName('#__updates'))->where($db->quoteName('extension_id') . ' != 0'));
$counts->updates = (int) $db->loadResult();
+
+ // MokoWaaS-specific updates
+ $db->setQuery(
+ $db->getQuery(true)
+ ->select('COUNT(*)')
+ ->from($db->quoteName('#__updates', 'u'))
+ ->join('INNER', $db->quoteName('#__extensions', 'e') . ' ON e.extension_id = u.extension_id')
+ ->where('(' . $db->quoteName('e.element') . ' LIKE ' . $db->quote('mokowaas%')
+ . ' OR ' . $db->quoteName('e.element') . ' LIKE ' . $db->quote('pkg_mokowaas%')
+ . ' OR ' . $db->quoteName('e.element') . ' LIKE ' . $db->quote('com_mokowaas%')
+ . ' OR ' . $db->quoteName('e.element') . ' LIKE ' . $db->quote('mod_mokowaas%')
+ . ' OR ' . $db->quoteName('e.element') . ' = ' . $db->quote('mokoonyx') . ')')
+ );
+ $counts->moko_updates = (int) $db->loadResult();
}
catch (\Throwable $e)
{
@@ -136,4 +151,54 @@ class CpanelHelper
{
return $_SERVER['REMOTE_ADDR'] ?? '';
}
+
+ /**
+ * Check SSL certificate expiry (#148).
+ *
+ * @return object|null {expires, days_remaining, warning} or null if check fails
+ */
+ public function getSslStatus(): ?object
+ {
+ try
+ {
+ $host = parse_url(\Joomla\CMS\Uri\Uri::root(), PHP_URL_HOST);
+
+ if (empty($host))
+ {
+ return null;
+ }
+
+ $context = stream_context_create(['ssl' => ['capture_peer_cert' => true, 'verify_peer' => false]]);
+ $client = @stream_socket_client('ssl://' . $host . ':443', $errno, $errstr, 5, STREAM_CLIENT_CONNECT, $context);
+
+ if (!$client)
+ {
+ return null;
+ }
+
+ $params = stream_context_get_params($client);
+ fclose($client);
+
+ $cert = openssl_x509_parse($params['options']['ssl']['peer_certificate'] ?? '');
+
+ if (empty($cert['validTo_time_t']))
+ {
+ return null;
+ }
+
+ $expires = $cert['validTo_time_t'];
+ $days = (int) floor(($expires - time()) / 86400);
+
+ return (object) [
+ 'expires' => date('Y-m-d', $expires),
+ 'days_remaining' => $days,
+ 'warning' => $days <= 30,
+ 'critical' => $days <= 7,
+ ];
+ }
+ catch (\Throwable $e)
+ {
+ return null;
+ }
+ }
}
diff --git a/src/packages/mod_mokowaas_cpanel/tmpl/default.php b/src/packages/mod_mokowaas_cpanel/tmpl/default.php
index b3ed62f2..66eea9f4 100644
--- a/src/packages/mod_mokowaas_cpanel/tmpl/default.php
+++ b/src/packages/mod_mokowaas_cpanel/tmpl/default.php
@@ -67,6 +67,16 @@ $diskColor = ($diskPct !== null && $diskPct > 90) ? 'bg-danger' : (($diskPct !==
offline)): ?>
Offline
+ moko_updates ?? 0) > 0): ?>
+
+ moko_updates; ?> MokoWaaS updatemoko_updates > 1 ? 's' : ''; ?>
+
+
+ updates > 0 && $counts->updates !== ($counts->moko_updates ?? 0)): ?>
+
+ updates - ($counts->moko_updates ?? 0); ?> updateupdates - ($counts->moko_updates ?? 0)) > 1 ? 's' : ''; ?>
+
+
@@ -130,6 +140,12 @@ $diskColor = ($diskPct !== null && $diskPct > 90) ? 'bg-danger' : (($diskPct !==
+
+
+
+ SSL days_remaining; ?>d
+
+
Jjoomla_version ?? ''); ?> / PHP php_version ?? ''); ?>
diff --git a/src/packages/mod_mokowaas_menu/language/en-GB/mod_mokowaas_menu.ini b/src/packages/mod_mokowaas_menu/language/en-GB/mod_mokowaas_menu.ini
new file mode 100644
index 00000000..dff9f13a
--- /dev/null
+++ b/src/packages/mod_mokowaas_menu/language/en-GB/mod_mokowaas_menu.ini
@@ -0,0 +1 @@
+MOD_MOKOWAAS_MENU="MokoWaaS Admin Menu"
diff --git a/src/packages/mod_mokowaas_menu/language/en-GB/mod_mokowaas_menu.sys.ini b/src/packages/mod_mokowaas_menu/language/en-GB/mod_mokowaas_menu.sys.ini
new file mode 100644
index 00000000..898a3832
--- /dev/null
+++ b/src/packages/mod_mokowaas_menu/language/en-GB/mod_mokowaas_menu.sys.ini
@@ -0,0 +1,2 @@
+MOD_MOKOWAAS_MENU="MokoWaaS Admin Menu"
+MOD_MOKOWAAS_MENU_DESC="Dedicated MokoWaaS section in the admin sidebar menu."
diff --git a/src/packages/mod_mokowaas_menu/mod_mokowaas_menu.xml b/src/packages/mod_mokowaas_menu/mod_mokowaas_menu.xml
new file mode 100644
index 00000000..feaa5e4d
--- /dev/null
+++ b/src/packages/mod_mokowaas_menu/mod_mokowaas_menu.xml
@@ -0,0 +1,24 @@
+
+
+ mod_mokowaas_menu
+ Moko Consulting
+ 2026-06-04
+ Copyright (C) 2026 Moko Consulting. All rights reserved.
+ GPL-3.0-or-later
+ hello@mokoconsulting.tech
+ https://mokoconsulting.tech
+ 02.32.52
+ MokoWaaS admin sidebar menu — renders a dedicated MokoWaaS section in the admin menu before Joomla's default menu.
+ Moko\Module\MokoWaaSMenu
+
+
+ services
+ src
+ tmpl
+
+
+
+ en-GB/mod_mokowaas_menu.ini
+ en-GB/mod_mokowaas_menu.sys.ini
+
+
diff --git a/src/packages/mod_mokowaas_menu/services/provider.php b/src/packages/mod_mokowaas_menu/services/provider.php
new file mode 100644
index 00000000..67feaece
--- /dev/null
+++ b/src/packages/mod_mokowaas_menu/services/provider.php
@@ -0,0 +1,18 @@
+registerServiceProvider(new ModuleDispatcherFactory('\\Moko\\Module\\MokoWaaSMenu'));
+ $container->registerServiceProvider(new HelperFactory('\\Moko\\Module\\MokoWaaSMenu\\Administrator\\Helper'));
+ $container->registerServiceProvider(new Module());
+ }
+};
diff --git a/src/packages/mod_mokowaas_menu/src/Dispatcher/Dispatcher.php b/src/packages/mod_mokowaas_menu/src/Dispatcher/Dispatcher.php
new file mode 100644
index 00000000..b5d4dcc2
--- /dev/null
+++ b/src/packages/mod_mokowaas_menu/src/Dispatcher/Dispatcher.php
@@ -0,0 +1,14 @@
+ 'icon-cogs', 'title' => 'Dashboard', 'link' => 'index.php?option=com_mokowaas'],
+ ['icon' => 'fa-solid fa-handshake-angle', 'title' => 'Helpdesk', 'link' => 'index.php?option=com_mokowaas&view=tickets'],
+ ['icon' => 'icon-puzzle-piece', 'title' => 'Extensions', 'link' => 'index.php?option=com_mokowaas&view=extensions'],
+ ['icon' => 'fa-solid fa-file-code', 'title' => '.htaccess Maker', 'link' => 'index.php?option=com_mokowaas&view=htaccess'],
+ ['icon' => 'icon-lock', 'title' => 'Privacy Guard', 'link' => 'index.php?option=com_mokowaas&view=privacy'],
+ ['icon' => 'icon-shield-alt', 'title' => 'WAF Log', 'link' => 'index.php?option=com_mokowaas&view=waflog'],
+ ['icon' => 'icon-database', 'title' => 'Database Tools', 'link' => 'index.php?option=com_mokowaas&view=database'],
+ ['icon' => 'icon-trash', 'title' => 'Cache Cleanup', 'link' => 'index.php?option=com_mokowaas&view=cleanup'],
+ ['icon' => 'icon-power-off', 'title' => 'Feature Plugins', 'link' => 'index.php?option=com_plugins&filter[folder]=system&filter[search]=mokowaas'],
+];
+
+$app = \Joomla\CMS\Factory::getApplication();
+$currentOption = $app->getInput()->get('option', '');
+$currentView = $app->getInput()->get('view', '');
+
+// Determine if any child is active (auto-expand)
+$anyActive = ($currentOption === 'com_mokowaas');
+$parentClass = 'item parent item-level-1' . ($anyActive ? ' mm-active' : '');
+$collapseClass = 'collapse-level-1 mm-collapse' . ($anyActive ? ' mm-show' : '');
+?>
+
+
diff --git a/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php b/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php
index 4fb3e50b..c65cafe0 100644
--- a/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php
+++ b/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php
@@ -22,7 +22,7 @@
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* REPO: https://github.com/mokoconsulting-tech/mokowaas
- * VERSION: 02.33.00
+ * VERSION: 02.32.52
* PATH: /src/Extension/MokoWaaS.php
* NOTE: Handles Joomla system events for rebranding functionality
*/
@@ -161,20 +161,7 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
*/
public function boot(ContainerInterface $container): void
{
- $timeout = (int) $this->params->get('admin_session_timeout', 0);
-
- if ($timeout <= 0)
- {
- return;
- }
-
- if ($this->ipIsTrusted())
- {
- // Set both PHP and Joomla session lifetimes before the
- // session handler runs its expiry check.
- ini_set('session.gc_maxlifetime', 315360000);
- Factory::getConfig()->set('lifetime', 525600);
- }
+ // Session lifetime for trusted IPs is now handled by the firewall plugin
}
/**
@@ -189,12 +176,7 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
*/
public function onAfterInitialise()
{
- // Security: HTTPS redirect (runs for all clients)
- $this->enforceHttps();
-
- // Site alias handling: offline page and backend redirect.
- // Must run in onAfterInitialise (not onAfterRoute) so that
- // Joomla's offline check in doExecute() sees the updated config.
+ // Site alias handling
$this->handleSiteAlias();
// MokoWaaS API endpoints (run before routing)
@@ -205,18 +187,15 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
$this->handleMokoApi($mokoAction);
}
- // Dev mode: disable caching
- $this->enforceDevMode();
-
- // Admin-only WaaS controls
+ // Admin-only core controls (branding, emergency access, master user)
+ // NOTE: enforceHttps, enforceDevMode, enforceAdminSessionTimeout,
+ // enforceUploadRestrictions are now in feature plugins
if ($this->app->isClient('administrator'))
{
$this->handleEmergencyAccess();
$this->enforceMasterUser();
$this->enforceLoginSupportUrls();
$this->enforceAtumBranding();
- $this->enforceAdminSessionTimeout();
- $this->enforceUploadRestrictions();
}
$this->loadLanguageOverrides();
@@ -815,7 +794,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
return $strings;
}
-
/**
* Event triggered after an extension's config is saved.
*
@@ -883,41 +861,7 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
// Grafana auto-provisioning
$this->handleGrafanaProvisioning($params, $app);
- if ((int) $params->get('reset_hits', 0) === 1)
- {
- $count = $this->resetAllHits();
- $params->set('reset_hits', '0');
- $changed = true;
-
- $app->enqueueMessage(
- sprintf('Reset hit counters on %d articles.', $count),
- 'message'
- );
-
- Log::add(
- sprintf('All article hits reset (%d rows) by MokoWaaS', $count),
- Log::WARNING,
- 'mokowaas'
- );
- }
-
- if ((int) $params->get('delete_versions', 0) === 1)
- {
- $count = $this->deleteAllVersions();
- $params->set('delete_versions', '0');
- $changed = true;
-
- $app->enqueueMessage(
- sprintf('Deleted %d version history records.', $count),
- 'message'
- );
-
- Log::add(
- sprintf('All content versions purged (%d rows) by MokoWaaS', $count),
- Log::WARNING,
- 'mokowaas'
- );
- }
+ // NOTE: reset_hits and delete_versions now handled by devtools plugin
// Content Sync: Push Now
if ((int) $params->get('sync_push_now', 0) === 1)
@@ -977,48 +921,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
}
}
- /**
- * Reset all article hit counters to zero.
- *
- * @return int Number of rows affected
- *
- * @since 02.01.08
- */
- protected function resetAllHits()
- {
- $db = Factory::getDbo();
-
- $db->setQuery(
- $db->getQuery(true)
- ->update($db->quoteName('#__content'))
- ->set($db->quoteName('hits') . ' = 0')
- ->where($db->quoteName('hits') . ' > 0')
- );
- $db->execute();
-
- return $db->getAffectedRows();
- }
-
- /**
- * Delete all content version history records.
- *
- * @return int Number of rows deleted
- *
- * @since 02.01.08
- */
- protected function deleteAllVersions()
- {
- $db = Factory::getDbo();
-
- $db->setQuery(
- $db->getQuery(true)
- ->delete($db->quoteName('#__history'))
- );
- $db->execute();
-
- return $db->getAffectedRows();
- }
-
/**
* Event triggered after the route has been determined.
*
@@ -1036,11 +938,71 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
return;
}
- $this->warnMissingLicenseKey();
- $this->enforceAdminRestrictions();
$this->protectPlugin();
}
+ // ------------------------------------------------------------------
+ // Automation event hooks (#151) — delegate to ticket automation engine
+ // ------------------------------------------------------------------
+
+ public function onUserLogin($user, $options = [])
+ {
+ // Security alert for admin logins (#131)
+ if ($this->app->isClient('administrator'))
+ {
+ try
+ {
+ \Moko\Component\MokoWaaS\Administrator\Service\NotificationService::securityAlert(
+ 'admin_login',
+ 'Admin Login: ' . ($user['username'] ?? ''),
+ 'User: ' . ($user['username'] ?? '') . "\nIP: " . ($_SERVER['REMOTE_ADDR'] ?? '') . "\nTime: " . gmdate('Y-m-d H:i:s') . ' UTC'
+ );
+ }
+ catch (\Throwable $e) {}
+ }
+
+ $this->fireTicketAutomation('user_login', [
+ 'user_id' => $user['id'] ?? 0,
+ 'username' => $user['username'] ?? '',
+ 'subject' => 'User login: ' . ($user['username'] ?? ''),
+ 'body' => 'User ' . ($user['username'] ?? '') . ' logged in from ' . ($_SERVER['REMOTE_ADDR'] ?? ''),
+ ]);
+ }
+
+ public function onUserAfterSave($user, $isNew, $success, $msg)
+ {
+ if ($isNew && $success)
+ {
+ $this->fireTicketAutomation('user_register', [
+ 'user_id' => $user['id'] ?? 0,
+ 'username' => $user['username'] ?? '',
+ 'subject' => 'New user registered: ' . ($user['username'] ?? ''),
+ 'body' => 'New user: ' . ($user['name'] ?? '') . ' (' . ($user['email'] ?? '') . ')',
+ ]);
+ }
+ }
+
+ public function onUserLoginFailure($response)
+ {
+ $this->fireTicketAutomation('user_login_failed', [
+ 'subject' => 'Failed login attempt',
+ 'body' => 'Failed login from ' . ($_SERVER['REMOTE_ADDR'] ?? '') . ': ' . ($response['username'] ?? ''),
+ ]);
+ }
+
+ private function fireTicketAutomation(string $event, array $data): void
+ {
+ try
+ {
+ $model = new \Moko\Component\MokoWaaS\Administrator\Model\TicketsModel();
+ $model->runSystemEventAutomation($event, $data);
+ }
+ catch (\Throwable $e)
+ {
+ // Silent — automation should never break the main flow
+ }
+ }
+
/**
* Inject visual branding into the document head.
*
@@ -1509,7 +1471,7 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
$db->quote('mokowaas_firewall'),
$db->quote('mokowaas_tenant'),
$db->quote('mokowaas_devtools'),
- $db->quote('mokowaas_monitor'),
+ $db->quote('mokowaas_offline'),
$db->quote('mod_mokowaas_cpanel'),
];
@@ -1553,107 +1515,8 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
}
}
- /**
- * Filter admin menu items for non-master users.
- *
- * @param string $context Menu context
- * @param array &$items Menu items (by reference)
- * @param mixed $params Module params
- * @param mixed $enabled Whether module is enabled
- *
- * @return void
- *
- * @since 02.01.08
- */
- public function onPreprocessMenuItems($context, &$items, $params, $enabled)
- {
- if (!$this->app->isClient('administrator'))
- {
- return;
- }
-
- if ($this->isMasterUser())
- {
- return;
- }
-
- $hidden = $this->getHiddenMenuComponents();
-
- if (empty($hidden))
- {
- return;
- }
-
- foreach ($items as $key => $item)
- {
- foreach ($hidden as $component)
- {
- if (isset($item->link)
- && strpos($item->link, 'option=' . $component) !== false)
- {
- unset($items[$key]);
- break;
- }
- }
- }
- }
-
- /**
- * Enforce password policy before user save.
- *
- * @param array $oldUser Existing user data
- * @param boolean $isNew Whether this is a new user
- * @param array $newUser New user data being saved
- *
- * @return boolean True to allow save
- *
- * @since 02.01.08
- */
- public function onUserBeforeSave($oldUser, $isNew, $newUser)
- {
- if (empty($newUser['password_clear']))
- {
- return true;
- }
-
- $password = $newUser['password_clear'];
- $errors = [];
-
- $minLen = (int) $this->params->get('password_min_length', 12);
-
- if (strlen($password) < $minLen)
- {
- $errors[] = sprintf(
- 'Password must be at least %d characters.', $minLen
- );
- }
-
- if ($this->params->get('password_require_uppercase', 1)
- && !preg_match('/[A-Z]/', $password))
- {
- $errors[] = 'Password must contain an uppercase letter.';
- }
-
- if ($this->params->get('password_require_number', 1)
- && !preg_match('/\d/', $password))
- {
- $errors[] = 'Password must contain a number.';
- }
-
- if ($this->params->get('password_require_special', 1)
- && !preg_match('/[^A-Za-z0-9]/', $password))
- {
- $errors[] = 'Password must contain a special character.';
- }
-
- if (!empty($errors))
- {
- throw new \RuntimeException(implode(' ', $errors));
- }
-
- return true;
- }
-
+ // onPreprocessMenuItems — REMOVED, now in plg_system_mokowaas_tenant
+ // onUserBeforeSave — REMOVED, now in plg_system_mokowaas_firewall
// ------------------------------------------------------------------
// Diagnostics / Health Endpoint (called from onAfterInitialise)
@@ -4420,130 +4283,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
// License key check (called from onAfterRoute)
// ------------------------------------------------------------------
- /**
- * Show a persistent admin warning if no license key is set on the
- * MokoWaaS update site.
- *
- * Checks the extra_query column in #__update_sites for a dlid value.
- * Also validates the key against MokoGitea on a heartbeat interval
- * (once per day) and warns if the key is invalid or expired.
- *
- * @return void
- *
- * @since 02.31.00
- */
- protected function warnMissingLicenseKey(): void
- {
- // Only show to master users
- if (!$this->isMasterUser())
- {
- return;
- }
-
- // Only warn once per session
- $session = Factory::getSession();
-
- if ($session->get('mokowaas.license_warned', false))
- {
- return;
- }
-
- $session->set('mokowaas.license_warned', true);
-
- try
- {
- $db = Factory::getDbo();
-
- $query = $db->getQuery(true)
- ->select($db->quoteName('extra_query'))
- ->from($db->quoteName('#__update_sites'))
- ->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoWaaS%')
- . ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoWaaS%') . ')')
- ->setLimit(1);
- $db->setQuery($query);
- $extraQuery = (string) $db->loadResult();
-
- if (empty($extraQuery) || strpos($extraQuery, 'dlid=') === false)
- {
- $this->app->enqueueMessage(
- 'Moko Consulting License Key Required — '
- . 'No download key is configured. Updates will not be available until a valid license key is entered. '
- . 'Go to System → Update Sites '
- . 'and enter your license key in the Download Key field for the MokoWaaS update site.',
- 'warning'
- );
-
- return;
- }
-
- // Extract the key value from extra_query
- parse_str($extraQuery, $parsed);
- $licenseKey = $parsed['dlid'] ?? '';
-
- if (empty($licenseKey))
- {
- return;
- }
-
- // Heartbeat validation — check once per day
- $session = Factory::getSession();
- $lastCheck = (int) $session->get('mokowaas.license_check', 0);
- $now = time();
-
- if (($now - $lastCheck) < 86400)
- {
- // Show cached warning if key was invalid last check
- if ($session->get('mokowaas.license_invalid', false))
- {
- $this->app->enqueueMessage(
- 'Moko Consulting License Key Invalid — '
- . 'Your license key could not be validated. Please verify your key in '
- . 'System → Update Sites.',
- 'error'
- );
- }
-
- return;
- }
-
- // Validate against MokoGitea
- $session->set('mokowaas.license_check', $now);
-
- $validateUrl = 'https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/updates.xml'
- . '?dlid=' . urlencode($licenseKey)
- . '&domain=' . urlencode(Uri::root());
-
- $ch = curl_init($validateUrl);
- curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
- curl_setopt($ch, CURLOPT_TIMEOUT, 10);
- curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
- curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
- $response = curl_exec($ch);
- $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
- curl_close($ch);
-
- // Empty or non-200 means invalid key
- $isValid = ($httpCode === 200 && $response && strpos($response, '') !== false);
-
- $session->set('mokowaas.license_invalid', !$isValid);
-
- if (!$isValid)
- {
- $this->app->enqueueMessage(
- 'Moko Consulting License Key Invalid — '
- . 'Your license key could not be validated. Updates will not be available. '
- . 'Please verify your key in '
- . 'System → Update Sites.',
- 'error'
- );
- }
- }
- catch (\Throwable $e)
- {
- // Silent — license check is non-critical
- }
- }
-
// ------------------------------------------------------------------
/**
@@ -4679,110 +4418,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
*
* @since 02.01.08
*/
- /**
- * Enforce development mode settings.
- *
- * When dev mode is ON:
- * - Disable Joomla caching
- * - Enable Joomla debug mode (Global Config)
- * - Enable MokoOnyx template debug
- * - Disable article hit recording
- *
- * When dev mode is OFF (and was previously on):
- * - Reset all content version history
- * - Reset article published dates to now
- *
- * @return void
- *
- * @since 02.01.15
- */
- protected function enforceDevMode()
- {
- if (!$this->params->get('dev_mode', 0))
- {
- return;
- }
-
- // Disable caching
- $config = Factory::getConfig();
- $config->set('caching', 0);
-
- // Enable Joomla debug
- $config->set('debug', 1);
-
- // Enable MokoOnyx template debug
- $this->setTemplateParam('mokoonyx', 'debug', 1);
-
- // Show offline page on primary domain only — site aliases
- // and dev.* subdomains bypass offline mode for development
- $currentHost = $_SERVER['HTTP_HOST'] ?? '';
- $primaryDomain = $this->params->get('primary_domain', '');
-
- if (!empty($primaryDomain) && $currentHost === $primaryDomain)
- {
- $config->set('offline', 1);
- }
-
- // Suppress hit recording
- try
- {
- $db = Factory::getDbo();
- $db->setQuery(
- $db->getQuery(true)
- ->update($db->quoteName('#__content'))
- ->set($db->quoteName('hits') . ' = 0')
- ->where($db->quoteName('hits') . ' > 0')
- )->execute();
- }
- catch (\Throwable $e)
- {
- // Silent
- }
- }
-
- /**
- * Actions to run when dev mode is turned off.
- *
- * Resets content versions and hits, disables debug.
- *
- * @return void
- *
- * @since 02.31.00
- */
- protected function onDevModeDisabled(): void
- {
- try
- {
- $db = Factory::getDbo();
-
- // Delete all content version history
- $db->setQuery(
- $db->getQuery(true)->delete($db->quoteName('#__history'))
- )->execute();
-
- // Reset hits
- $db->setQuery(
- $db->getQuery(true)
- ->update($db->quoteName('#__content'))
- ->set($db->quoteName('hits') . ' = 0')
- )->execute();
-
- // Disable debug
- $this->setTemplateParam('mokoonyx', 'debug', 0);
-
- // Take site back online
- Factory::getConfig()->set('offline', 0);
-
- $this->app->enqueueMessage(
- 'Development mode disabled — versions cleared, hits reset, debug off, site online.',
- 'message'
- );
- }
- catch (\Throwable $e)
- {
- Log::add('Dev mode cleanup failed: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
- }
- }
/**
* Set a parameter on a template style.
@@ -4830,194 +4465,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
}
}
- protected function enforceHttps()
- {
- if (!$this->params->get('force_https', 0))
- {
- return;
- }
-
- if ($this->app->isClient('cli'))
- {
- return;
- }
-
- $isHttps = (!empty($_SERVER['HTTPS'])
- && $_SERVER['HTTPS'] !== 'off')
- || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
-
- if (!$isHttps)
- {
- $this->app->redirect(
- 'https://' . $_SERVER['HTTP_HOST']
- . $_SERVER['REQUEST_URI'], 301
- );
- }
- }
-
- /**
- * Enforce admin session idle timeout.
- *
- * @return void
- *
- * @since 02.01.08
- */
- protected function enforceAdminSessionTimeout()
- {
- $timeout = (int) $this->params->get('admin_session_timeout', 0);
-
- if ($timeout <= 0)
- {
- return;
- }
-
- // Don't timeout the master user
- if ($this->isMasterUser())
- {
- return;
- }
-
- // Trusted IPs — session lifetime already extended in boot()
- if ($this->ipIsTrusted())
- {
- return;
- }
-
- $session = Factory::getSession();
- $lastHit = $session->get('mokowaas.last_activity', 0);
- $now = time();
-
- if ($lastHit > 0 && ($now - $lastHit) > ($timeout * 60))
- {
- $this->app->logout();
- $this->app->redirect(
- Route::_('index.php', false)
- );
-
- return;
- }
-
- $session->set('mokowaas.last_activity', $now);
- }
-
- /**
- * Check whether the current request IP matches any trusted IP entry.
- *
- * Supports exact IPs, CIDR notation (e.g. 10.0.0.0/8), and
- * wildcard patterns (e.g. 192.168.1.*).
- *
- * @return bool True if the current IP is in the trusted list.
- *
- * @since 02.11.00
- */
- protected function ipIsTrusted(): bool
- {
- $entries = $this->params->get('trusted_ips', '');
-
- if (empty($entries))
- {
- return false;
- }
-
- // Subform stores as JSON string or array
- if (\is_string($entries))
- {
- $entries = json_decode($entries, true);
- }
-
- if (!\is_array($entries))
- {
- return false;
- }
-
- $ip = $this->app
- ? $this->app->input->server->getString('REMOTE_ADDR', '')
- : ($_SERVER['REMOTE_ADDR'] ?? '');
- $ipLong = ip2long($ip);
-
- if ($ipLong === false)
- {
- return false;
- }
-
- foreach ($entries as $entry)
- {
- if (empty($entry['enabled']) || empty($entry['ip']))
- {
- continue;
- }
-
- $range = trim($entry['ip']);
-
- // Wildcard: 192.168.1.*
- if (str_contains($range, '*'))
- {
- $pattern = '/^' . str_replace(['.', '*'], ['\\.', '\\d+'], $range) . '$/';
-
- if (preg_match($pattern, $ip))
- {
- return true;
- }
-
- continue;
- }
-
- // CIDR: 10.0.0.0/8
- if (str_contains($range, '/'))
- {
- [$subnet, $bits] = explode('/', $range, 2);
- $subnetLong = ip2long($subnet);
- $mask = -1 << (32 - (int) $bits);
-
- if ($subnetLong !== false && ($ipLong & $mask) === ($subnetLong & $mask))
- {
- return true;
- }
-
- continue;
- }
-
- // Exact match
- if ($ip === $range)
- {
- return true;
- }
- }
-
- return false;
- }
-
-
- /**
- * Override Joomla upload restrictions at runtime.
- *
- * @return void
- *
- * @since 02.01.08
- */
- protected function enforceUploadRestrictions()
- {
- $types = $this->params->get('upload_allowed_types', '');
- $maxMb = (int) $this->params->get('upload_max_size_mb', 0);
-
- if (empty($types) && $maxMb <= 0)
- {
- return;
- }
-
- $config = $this->app->getConfig();
-
- if (!empty($types))
- {
- $config->set('upload_extensions', $types);
- }
-
- if ($maxMb > 0)
- {
- $config->set('upload_maxsize', $maxMb);
- }
- }
-
/**
* Enforce login support module URLs on admin requests.
*
@@ -5086,121 +4533,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
// Tenant Restrictions (called from onAfterRoute)
// ------------------------------------------------------------------
- /**
- * Check admin routes against restriction rules and redirect if blocked.
- *
- * @return void
- *
- * @since 02.01.08
- */
- protected function enforceAdminRestrictions()
- {
- // Master user bypasses ALL restrictions
- if ($this->isMasterUser())
- {
- return;
- }
-
- $input = $this->app->input;
- $option = $input->get('option', '');
- $view = $input->get('view', '');
- $task = $input->get('task', '');
-
- // Disable install-from-URL for non-master users
- if ($this->params->get('disable_install_url', 1)
- && $option === 'com_installer'
- && stripos($task, 'install') !== false
- && $input->get('installtype') === 'url')
- {
- $this->blockAccess('Install from URL is disabled.');
-
- return;
- }
-
- $blocked = [];
-
- if ($this->params->get('restrict_installer', 1))
- {
- // Allow the update view by default so tenants can update extensions
- $allowUpdates = (int) $this->params->get('allow_extension_updates', 1);
-
- if ($allowUpdates && $option === 'com_installer'
- && \in_array($view, ['update', 'updatesites'], true))
- {
- // Do not block — update views are permitted
- }
- elseif ($option === 'com_installer')
- {
- $this->blockAccess('Access restricted.');
-
- return;
- }
- }
-
- if ($this->params->get('hide_sysinfo', 1))
- {
- $blocked[] = [
- 'option' => 'com_admin',
- 'view' => 'sysinfo',
- ];
- }
-
- if ($this->params->get('restrict_global_config', 1))
- {
- $blocked[] = [
- 'option' => 'com_config',
- 'view' => 'application',
- ];
- // Also block empty view (default landing = global config)
- if ($option === 'com_config' && $view === '')
- {
- $this->blockAccess('Access restricted.');
-
- return;
- }
- }
-
- if ($this->params->get('restrict_template_editing', 1))
- {
- $blocked[] = [
- 'option' => 'com_templates',
- 'view' => 'template',
- ];
- }
-
- foreach ($blocked as $rule)
- {
- if ($option !== $rule['option'])
- {
- continue;
- }
-
- if (isset($rule['view']) && $view !== $rule['view'])
- {
- continue;
- }
-
- $this->blockAccess('Access restricted.');
-
- return;
- }
- }
-
- /**
- * Redirect to admin dashboard with an error message.
- *
- * @param string $message Error message to display
- *
- * @return void
- *
- * @since 02.01.08
- */
- protected function blockAccess($message)
- {
- $this->app->enqueueMessage($message, 'error');
- $this->app->redirect(Route::_('index.php', false));
- }
-
/**
* Check whether the current user is the master WaaS user.
*
@@ -5252,38 +4584,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
return $this->masterNames;
}
- /**
- * Build the list of components to hide from admin menu.
- *
- * Combines explicit hidden_menu_items config with components that
- * are implicitly blocked by other restriction toggles.
- *
- * @return array Component option strings
- *
- * @since 02.01.08
- */
- protected function getHiddenMenuComponents()
- {
- $hidden = array_filter(array_map(
- 'trim',
- explode("\n", $this->params->get('hidden_menu_items', ''))
- ));
-
- // Auto-hide components that are restricted (keep visible when updates are allowed)
- if ($this->params->get('restrict_installer', 1)
- && !$this->params->get('allow_extension_updates', 1))
- {
- $hidden[] = 'com_installer';
- }
-
- if ($this->params->get('hide_sysinfo', 1))
- {
- $hidden[] = 'com_admin';
- }
-
- return array_unique($hidden);
- }
-
// ------------------------------------------------------------------
// Atum Template Branding (called from onAfterInitialise)
// ------------------------------------------------------------------
diff --git a/src/packages/plg_system_mokowaas/Field/AllowedIpsField.php b/src/packages/plg_system_mokowaas/Field/AllowedIpsField.php
index 432a8a74..be2468ca 100644
--- a/src/packages/plg_system_mokowaas/Field/AllowedIpsField.php
+++ b/src/packages/plg_system_mokowaas/Field/AllowedIpsField.php
@@ -7,7 +7,7 @@
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
- * VERSION: 02.33.00
+ * VERSION: 02.32.52
* PATH: /src/Field/AllowedIpsField.php
* BRIEF: Custom form field that displays the current IP whitelist
*/
diff --git a/src/packages/plg_system_mokowaas/Field/CopyableTokenField.php b/src/packages/plg_system_mokowaas/Field/CopyableTokenField.php
index 99d551bb..1cd755cc 100644
--- a/src/packages/plg_system_mokowaas/Field/CopyableTokenField.php
+++ b/src/packages/plg_system_mokowaas/Field/CopyableTokenField.php
@@ -8,7 +8,7 @@
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
- * VERSION: 02.33.00
+ * VERSION: 02.32.52
* PATH: /src/Field/CopyableTokenField.php
* BRIEF: Read-only token field with a copy-to-clipboard button
*/
diff --git a/src/packages/plg_system_mokowaas/Field/CurrentIpField.php b/src/packages/plg_system_mokowaas/Field/CurrentIpField.php
index 3d4bf6f6..96a8ac90 100644
--- a/src/packages/plg_system_mokowaas/Field/CurrentIpField.php
+++ b/src/packages/plg_system_mokowaas/Field/CurrentIpField.php
@@ -7,7 +7,7 @@
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
- * VERSION: 02.33.00
+ * VERSION: 02.32.52
* PATH: /src/Field/CurrentIpField.php
* BRIEF: Read-only field that displays the current user's IP address
*/
diff --git a/src/packages/plg_system_mokowaas/Field/DemoTaskInfoField.php b/src/packages/plg_system_mokowaas/Field/DemoTaskInfoField.php
index 97316c0a..83b822c4 100644
--- a/src/packages/plg_system_mokowaas/Field/DemoTaskInfoField.php
+++ b/src/packages/plg_system_mokowaas/Field/DemoTaskInfoField.php
@@ -8,7 +8,7 @@
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
- * VERSION: 02.33.00
+ * VERSION: 02.32.52
* PATH: /src/Field/DemoTaskInfoField.php
* BRIEF: Read-only field showing scheduled task info with link to manage it
*/
diff --git a/src/packages/plg_system_mokowaas/Field/NextResetField.php b/src/packages/plg_system_mokowaas/Field/NextResetField.php
index ca4d64ef..6a8810a9 100644
--- a/src/packages/plg_system_mokowaas/Field/NextResetField.php
+++ b/src/packages/plg_system_mokowaas/Field/NextResetField.php
@@ -8,7 +8,7 @@
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
- * VERSION: 02.33.00
+ * VERSION: 02.32.52
* PATH: /src/Field/NextResetField.php
* BRIEF: Read-only field showing next reset time from Joomla scheduled task
*/
diff --git a/src/packages/plg_system_mokowaas/Field/SnapshotTablesField.php b/src/packages/plg_system_mokowaas/Field/SnapshotTablesField.php
index 5618bbf6..1d10115d 100644
--- a/src/packages/plg_system_mokowaas/Field/SnapshotTablesField.php
+++ b/src/packages/plg_system_mokowaas/Field/SnapshotTablesField.php
@@ -8,7 +8,7 @@
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
- * VERSION: 02.33.00
+ * VERSION: 02.32.52
* PATH: /src/Field/SnapshotTablesField.php
* BRIEF: Multi-select list field that loads DB tables with sensible defaults
*/
diff --git a/src/packages/plg_system_mokowaas/Helper/MokoWaaSHelper.php b/src/packages/plg_system_mokowaas/Helper/MokoWaaSHelper.php
index 0cd42177..3e533f84 100644
--- a/src/packages/plg_system_mokowaas/Helper/MokoWaaSHelper.php
+++ b/src/packages/plg_system_mokowaas/Helper/MokoWaaSHelper.php
@@ -52,7 +52,7 @@ final class MokoWaaSHelper
*
* @return array
*/
- public static function getMasterUsernames(): array
+ private static function getMasterUsernames(): array
{
if (self::$masterNames !== null)
{
diff --git a/src/packages/plg_system_mokowaas/Service/ContentSyncReceiver.php b/src/packages/plg_system_mokowaas/Service/ContentSyncReceiver.php
index e6c69177..515b1e3f 100644
--- a/src/packages/plg_system_mokowaas/Service/ContentSyncReceiver.php
+++ b/src/packages/plg_system_mokowaas/Service/ContentSyncReceiver.php
@@ -10,7 +10,7 @@
* INGROUP: MokoWaaS
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
* PATH: /src/packages/plg_system_mokowaas/Service/ContentSyncReceiver.php
- * VERSION: 02.33.00
+ * VERSION: 02.32.52
* BRIEF: Receiver-side content sync — applies incoming payload to local DB
*/
diff --git a/src/packages/plg_system_mokowaas/Service/ContentSyncService.php b/src/packages/plg_system_mokowaas/Service/ContentSyncService.php
index d27bf9a8..6b2a9c39 100644
--- a/src/packages/plg_system_mokowaas/Service/ContentSyncService.php
+++ b/src/packages/plg_system_mokowaas/Service/ContentSyncService.php
@@ -10,7 +10,7 @@
* INGROUP: MokoWaaS
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
* PATH: /src/packages/plg_system_mokowaas/Service/ContentSyncService.php
- * VERSION: 02.33.00
+ * VERSION: 02.32.52
* BRIEF: Sender-side content sync — builds payload and pushes to remote sites
*/
diff --git a/src/packages/plg_system_mokowaas/Service/DemoResetService.php b/src/packages/plg_system_mokowaas/Service/DemoResetService.php
index ce458abd..1012b58f 100644
--- a/src/packages/plg_system_mokowaas/Service/DemoResetService.php
+++ b/src/packages/plg_system_mokowaas/Service/DemoResetService.php
@@ -10,7 +10,7 @@
* INGROUP: MokoWaaS
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
* PATH: /src/packages/plg_system_mokowaas/Service/DemoResetService.php
- * VERSION: 02.33.00
+ * VERSION: 02.32.52
* BRIEF: Content-only snapshot/restore for demo site reset
*/
diff --git a/src/packages/plg_system_mokowaas/mokowaas.xml b/src/packages/plg_system_mokowaas/mokowaas.xml
index b7ec28ca..86ce69a6 100644
--- a/src/packages/plg_system_mokowaas/mokowaas.xml
+++ b/src/packages/plg_system_mokowaas/mokowaas.xml
@@ -30,7 +30,7 @@
GNU General Public License version 3 or later; see LICENSE.md
hello@mokoconsulting.tech
https://mokoconsulting.tech
- 02.33.00
+ 02.32.52
This plugin rebrands the Joomla system interface with MokoWaaS identity. It applies language overrides and ensures consistent branding across the platform.
Moko\Plugin\System\MokoWaaS
script.php
@@ -76,7 +76,9 @@
-
diff --git a/src/packages/plg_system_mokowaas/script.php b/src/packages/plg_system_mokowaas/script.php
index dffd284d..82d35a6b 100644
--- a/src/packages/plg_system_mokowaas/script.php
+++ b/src/packages/plg_system_mokowaas/script.php
@@ -22,7 +22,7 @@
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* REPO: https://github.com/mokoconsulting-tech/mokowaas
- * VERSION: 02.33.00
+ * VERSION: 02.32.52
* PATH: /src/script.php
* BRIEF: Installation script for MokoWaaS plugin
* NOTE: Handles installation, update, and uninstallation tasks including language override deployment
diff --git a/src/packages/plg_system_mokowaas/services/provider.php b/src/packages/plg_system_mokowaas/services/provider.php
index 8770e8aa..05ba826c 100644
--- a/src/packages/plg_system_mokowaas/services/provider.php
+++ b/src/packages/plg_system_mokowaas/services/provider.php
@@ -22,7 +22,7 @@
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* REPO: https://github.com/mokoconsulting-tech/mokowaas
- * VERSION: 02.33.00
+ * VERSION: 02.32.52
* PATH: /src/services/provider.php
* BRIEF: Service provider for dependency injection in Joomla 5.x
* NOTE: Registers the plugin with Joomla's DI container
diff --git a/src/packages/plg_system_mokowaas_devtools/mokowaas_devtools.xml b/src/packages/plg_system_mokowaas_devtools/mokowaas_devtools.xml
index 1f432dd3..efd36b1c 100644
--- a/src/packages/plg_system_mokowaas_devtools/mokowaas_devtools.xml
+++ b/src/packages/plg_system_mokowaas_devtools/mokowaas_devtools.xml
@@ -8,7 +8,7 @@
GPL-3.0-or-later
hello@mokoconsulting.tech
https://mokoconsulting.tech
- 02.33.00
+ 02.32.52
PLG_SYSTEM_MOKOWAAS_DEVTOOLS_DESC
Moko\Plugin\System\MokoWaaSDevTools
diff --git a/src/packages/plg_system_mokowaas_firewall/mokowaas_firewall.xml b/src/packages/plg_system_mokowaas_firewall/mokowaas_firewall.xml
index e01bdbea..d345023d 100644
--- a/src/packages/plg_system_mokowaas_firewall/mokowaas_firewall.xml
+++ b/src/packages/plg_system_mokowaas_firewall/mokowaas_firewall.xml
@@ -8,7 +8,7 @@
GPL-3.0-or-later
hello@mokoconsulting.tech
https://mokoconsulting.tech
- 02.33.00
+ 02.32.52
PLG_SYSTEM_MOKOWAAS_FIREWALL_DESC
Moko\Plugin\System\MokoWaaSFirewall
@@ -127,6 +127,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
params->get('admin_session_timeout', 0);
+
+ if ($timeout <= 0)
+ {
+ return;
+ }
+
+ if ($this->ipIsTrusted())
+ {
+ ini_set('session.gc_maxlifetime', 315360000);
+ Factory::getConfig()->set('lifetime', 525600);
+ }
+ }
+
private const BLOCKED_FILES = [
'htaccess.txt', 'web.config.txt', 'configuration.php-dist',
'README.txt', 'LICENSE.txt', 'joomla.xml', 'robots.txt.dist',
@@ -90,7 +111,8 @@ class Firewall extends CMSPlugin implements SubscriberInterface
$this->checkDirectPhpAccess();
}
- // Existing features
+ // Security headers + existing features
+ $this->injectSecurityHeaders();
$this->enforceHttps();
$this->enforceUploadRestrictions();
@@ -379,6 +401,46 @@ class Firewall extends CMSPlugin implements SubscriberInterface
'created' => gmdate('Y-m-d H:i:s'),
];
$db->insertObject('#__mokowaas_waf_log', $row);
+
+ // Security alert email (#131) — rate limited to 1 per IP per 5 minutes
+ try
+ {
+ $alertKey = 'mokowaas_waf_alert_' . md5($ip);
+ $session = \Joomla\CMS\Factory::getSession();
+
+ if (!$session->get($alertKey, false))
+ {
+ $session->set($alertKey, true);
+ \Moko\Component\MokoWaaS\Administrator\Service\NotificationService::securityAlert(
+ 'waf_block',
+ 'WAF Block: ' . $rule . ' from ' . $ip,
+ "Rule: {$rule}\nIP: {$ip}\nURI: {$uri}\nDetail: " . substr($detail, 0, 200)
+ );
+ }
+ }
+ catch (\Throwable $e) {}
+
+ // Auto-ban: if IP has N+ blocks in last M minutes, add to blocklist (#143)
+ $threshold = (int) $this->params->get('autoban_threshold', 10);
+ $window = (int) $this->params->get('autoban_window', 5);
+
+ if ($threshold > 0 && $window > 0)
+ {
+ $cutoff = gmdate('Y-m-d H:i:s', time() - ($window * 60));
+ $db->setQuery(
+ $db->getQuery(true)
+ ->select('COUNT(*)')
+ ->from($db->quoteName('#__mokowaas_waf_log'))
+ ->where($db->quoteName('ip') . ' = ' . $db->quote($ip))
+ ->where($db->quoteName('created') . ' >= ' . $db->quote($cutoff))
+ );
+ $recentBlocks = (int) $db->loadResult();
+
+ if ($recentBlocks >= $threshold)
+ {
+ $this->autoBanIp($ip, $db);
+ }
+ }
}
catch (\Throwable $e)
{
@@ -397,6 +459,51 @@ class Firewall extends CMSPlugin implements SubscriberInterface
// Input Scanning
// ==================================================================
+ /**
+ * Auto-ban an IP by adding it to the blocklist params (#143).
+ */
+ private function autoBanIp(string $ip, $db): void
+ {
+ try
+ {
+ $query = $db->getQuery(true)
+ ->select($db->quoteName('params'))
+ ->from($db->quoteName('#__extensions'))
+ ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall'))
+ ->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
+ ->where($db->quoteName('folder') . ' = ' . $db->quote('system'));
+ $db->setQuery($query);
+ $params = new \Joomla\Registry\Registry($db->loadResult() ?? '{}');
+ $blocklist = json_decode($params->get('ip_blocklist', '[]'), true) ?: [];
+
+ foreach ($blocklist as $entry)
+ {
+ if (($entry['ip'] ?? '') === $ip)
+ {
+ return;
+ }
+ }
+
+ $blocklist[] = ['ip' => $ip, 'enabled' => '1', 'label' => 'Auto-banned by WAF (' . gmdate('Y-m-d H:i') . ')'];
+ $params->set('ip_blocklist', json_encode($blocklist));
+
+ $db->setQuery(
+ $db->getQuery(true)
+ ->update($db->quoteName('#__extensions'))
+ ->set($db->quoteName('params') . ' = ' . $db->quote($params->toString()))
+ ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall'))
+ ->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
+ ->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
+ )->execute();
+
+ Log::add('WAF auto-banned IP: ' . $ip, Log::WARNING, 'mokowaas');
+ }
+ catch (\Throwable $e)
+ {
+ // Silent
+ }
+ }
+
private function scanInput(array $input, string $pattern): ?string
{
foreach ($input as $key => $value)
@@ -414,7 +521,8 @@ class Firewall extends CMSPlugin implements SubscriberInterface
}
$value = (string) $value;
- $decoded = urldecode($value);
+ // Double-decode to catch %25xx encoding tricks
+ $decoded = urldecode(urldecode($value));
if (preg_match($pattern, $value) || preg_match($pattern, $decoded))
{
@@ -526,6 +634,68 @@ class Firewall extends CMSPlugin implements SubscriberInterface
}
}
+ /**
+ * Inject HTTP security headers at runtime (#124).
+ */
+ private function injectSecurityHeaders(): void
+ {
+ $app = $this->getApplication();
+
+ if ($app->isClient('cli'))
+ {
+ return;
+ }
+
+ if ($this->params->get('header_xframe', 1))
+ {
+ $app->setHeader('X-Frame-Options', 'SAMEORIGIN', true);
+ }
+
+ if ($this->params->get('header_xcontent', 1))
+ {
+ $app->setHeader('X-Content-Type-Options', 'nosniff', true);
+ }
+
+ if ($this->params->get('header_xxss', 1))
+ {
+ $app->setHeader('X-XSS-Protection', '1; mode=block', true);
+ }
+
+ $referrer = $this->params->get('header_referrer', '');
+
+ if (!empty($referrer) && $referrer !== 'off')
+ {
+ $app->setHeader('Referrer-Policy', $referrer, true);
+ }
+
+ if ($this->params->get('header_hsts', 0))
+ {
+ $maxAge = (int) $this->params->get('header_hsts_maxage', 31536000);
+ $hsts = 'max-age=' . $maxAge;
+
+ if ($this->params->get('header_hsts_subdomains', 0))
+ {
+ $hsts .= '; includeSubDomains';
+ }
+
+ $app->setHeader('Strict-Transport-Security', $hsts, true);
+ }
+
+ $csp = $this->params->get('header_csp', '');
+
+ if (!empty($csp))
+ {
+ $app->setHeader('Content-Security-Policy', $csp, true);
+ }
+
+ $perms = $this->params->get('header_permissions', '');
+
+ if (!empty($perms))
+ {
+ $app->setHeader('Permissions-Policy', $perms, true);
+ }
+ }
+
private function enforceHttps(): void
{
if (!$this->params->get('force_https', 0))
diff --git a/src/packages/plg_system_mokowaas_monitor/mokowaas_monitor.xml b/src/packages/plg_system_mokowaas_monitor/mokowaas_monitor.xml
index b074eb8c..2f32cec7 100644
--- a/src/packages/plg_system_mokowaas_monitor/mokowaas_monitor.xml
+++ b/src/packages/plg_system_mokowaas_monitor/mokowaas_monitor.xml
@@ -8,7 +8,7 @@
GPL-3.0-or-later
hello@mokoconsulting.tech
https://mokoconsulting.tech
- 02.33.00
+ 02.32.52
PLG_SYSTEM_MOKOWAAS_MONITOR_DESC
Moko\Plugin\System\MokoWaaSMonitor
diff --git a/src/packages/plg_system_mokowaas_offline/language/en-GB/plg_system_mokowaas_offline.ini b/src/packages/plg_system_mokowaas_offline/language/en-GB/plg_system_mokowaas_offline.ini
new file mode 100644
index 00000000..65517993
--- /dev/null
+++ b/src/packages/plg_system_mokowaas_offline/language/en-GB/plg_system_mokowaas_offline.ini
@@ -0,0 +1,13 @@
+; MokoWaaS Terms of Service Plugin
+; Copyright (C) 2026 Moko Consulting. All rights reserved.
+; License: GPL-3.0-or-later
+
+PLG_SYSTEM_MOKOWAAS_OFFLINE="System - MokoWaaS Offline Bypass"
+PLG_SYSTEM_MOKOWAAS_OFFLINE_DESC="Keep selected pages (Terms of Service, Privacy Policy, etc.) accessible when the site is in offline mode."
+
+PLG_SYSTEM_MOKOWAAS_OFFLINE_FIELDSET_BASIC="Offline-Accessible Pages"
+PLG_SYSTEM_MOKOWAAS_OFFLINE_SLUG_LABEL="Menu Items to Keep Online"
+PLG_SYSTEM_MOKOWAAS_OFFLINE_SLUG_DESC="Select menu items that remain accessible during offline mode. Hold Ctrl/Cmd for multiple."
+PLG_SYSTEM_MOKOWAAS_OFFLINE_CHILDREN_LABEL="Include Child Menu Items"
+PLG_SYSTEM_MOKOWAAS_OFFLINE_CHILDREN_DESC="Also allow access to child pages under the selected items."
+PLG_SYSTEM_MOKOWAAS_OFFLINE_SEF_WARNING="SEF URLs are disabled - path matching requires SEF. Itemid fallback is active."
diff --git a/src/packages/plg_system_mokowaas_offline/language/en-GB/plg_system_mokowaas_offline.sys.ini b/src/packages/plg_system_mokowaas_offline/language/en-GB/plg_system_mokowaas_offline.sys.ini
new file mode 100644
index 00000000..7b6f1ef3
--- /dev/null
+++ b/src/packages/plg_system_mokowaas_offline/language/en-GB/plg_system_mokowaas_offline.sys.ini
@@ -0,0 +1,3 @@
+; MokoWaaS Terms of Service Plugin - System strings
+PLG_SYSTEM_MOKOWAAS_OFFLINE="System - MokoWaaS Offline Bypass"
+PLG_SYSTEM_MOKOWAAS_OFFLINE_DESC="Keep selected pages (Terms of Service, Privacy Policy, etc.) accessible when the site is in offline mode."
diff --git a/src/packages/plg_system_mokowaas_offline/mokowaas_offline.xml b/src/packages/plg_system_mokowaas_offline/mokowaas_offline.xml
new file mode 100644
index 00000000..c603a52c
--- /dev/null
+++ b/src/packages/plg_system_mokowaas_offline/mokowaas_offline.xml
@@ -0,0 +1,44 @@
+
+
+ System - MokoWaaS Offline Bypass
+ mokowaas_offline
+ Moko Consulting
+ 2026-06-02
+ Copyright (C) 2026 Moko Consulting. All rights reserved.
+ GPL-3.0-or-later
+ hello@mokoconsulting.tech
+ https://mokoconsulting.tech
+ 02.32.52
+ PLG_SYSTEM_MOKOWAAS_OFFLINE_DESC
+ Moko\Plugin\System\MokoWaaSOffline
+
+
+ src
+ services
+ language
+
+
+
+ en-GB/plg_system_mokowaas_offline.ini
+ en-GB/plg_system_mokowaas_offline.sys.ini
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/packages/plg_system_mokowaas_offline/services/provider.php b/src/packages/plg_system_mokowaas_offline/services/provider.php
new file mode 100644
index 00000000..c45e733d
--- /dev/null
+++ b/src/packages/plg_system_mokowaas_offline/services/provider.php
@@ -0,0 +1,34 @@
+set(
+ PluginInterface::class,
+ function (Container $container) {
+ $dispatcher = $container->get(DispatcherInterface::class);
+ $plugin = new Tos($dispatcher, (array) PluginHelper::getPlugin('system', 'mokowaas_offline'));
+ $plugin->setApplication(Factory::getApplication());
+
+ return $plugin;
+ }
+ );
+ }
+};
diff --git a/src/packages/plg_system_mokowaas_offline/src/Extension/Tos.php b/src/packages/plg_system_mokowaas_offline/src/Extension/Tos.php
new file mode 100644
index 00000000..1ab84cf6
--- /dev/null
+++ b/src/packages/plg_system_mokowaas_offline/src/Extension/Tos.php
@@ -0,0 +1,172 @@
+ 'onAfterRoute',
+ ];
+ }
+
+ public function onAfterRoute(): void
+ {
+ $app = $this->getApplication();
+
+ if (!$app->isClient('site'))
+ {
+ return;
+ }
+
+ $config = $app->getConfig();
+
+ if (!$config->get('offline'))
+ {
+ return;
+ }
+
+ $slugs = $this->params->get('tos_slug', []);
+
+ if (\is_string($slugs))
+ {
+ $slugs = array_filter([trim($slugs)]);
+ }
+ else
+ {
+ $slugs = (array) $slugs;
+ }
+
+ if (empty($slugs))
+ {
+ return;
+ }
+
+ $includeChildren = (int) $this->params->get('include_children', 1);
+
+ if ($this->matchByPath($slugs, $config, $app, $includeChildren))
+ {
+ return;
+ }
+
+ $this->matchByItemId($slugs, $config, $app, $includeChildren);
+ }
+
+ private function matchByPath(array $slugs, $config, $app, int $includeChildren = 1): bool
+ {
+ $uri = Uri::getInstance();
+ $path = urldecode(trim($uri->getPath(), '/'));
+
+ $base = trim(Uri::base(true), '/');
+
+ if (!empty($base) && strpos($path, $base) === 0)
+ {
+ $path = trim(substr($path, \strlen($base)), '/');
+ }
+
+ if (empty($path) || $path === 'index.php')
+ {
+ return false;
+ }
+
+ foreach ($slugs as $slug)
+ {
+ $slug = trim((string) $slug);
+
+ if (empty($slug))
+ {
+ continue;
+ }
+
+ if ($path === $slug || ($includeChildren && strpos($path, $slug . '/') === 0))
+ {
+ $this->bypassOffline($config, $app);
+
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private function matchByItemId(array $slugs, $config, $app, int $includeChildren = 1): bool
+ {
+ $itemId = (int) $app->getInput()->getInt('Itemid', 0);
+
+ if (!$itemId)
+ {
+ return false;
+ }
+
+ try
+ {
+ $db = Factory::getDbo();
+ $query = $db->getQuery(true)
+ ->select($db->quoteName('path'))
+ ->from($db->quoteName('#__menu'))
+ ->where($db->quoteName('id') . ' = ' . $itemId)
+ ->where($db->quoteName('published') . ' = 1')
+ ->where($db->quoteName('client_id') . ' = 0');
+ $db->setQuery($query);
+ $menuPath = trim((string) $db->loadResult(), '/');
+
+ if (empty($menuPath))
+ {
+ return false;
+ }
+
+ foreach ($slugs as $slug)
+ {
+ $slug = trim((string) $slug);
+
+ if (empty($slug))
+ {
+ continue;
+ }
+
+ if ($menuPath === $slug || ($includeChildren && strpos($menuPath, $slug . '/') === 0))
+ {
+ $this->bypassOffline($config, $app);
+
+ return true;
+ }
+ }
+ }
+ catch (\Throwable $e)
+ {
+ // Silent
+ }
+
+ return false;
+ }
+
+ private function bypassOffline($config, $app): void
+ {
+ $config->set('offline', 0);
+ $app->getInput()->set('tmpl', 'component');
+ }
+}
diff --git a/src/packages/plg_system_mokowaas_offline/src/Field/MenuslugField.php b/src/packages/plg_system_mokowaas_offline/src/Field/MenuslugField.php
new file mode 100644
index 00000000..d9a822f2
--- /dev/null
+++ b/src/packages/plg_system_mokowaas_offline/src/Field/MenuslugField.php
@@ -0,0 +1,81 @@
+get('sef', true);
+
+ if (!$sef)
+ {
+ $options[] = (object) [
+ 'value' => '',
+ 'text' => Text::_('PLG_SYSTEM_MOKOWAAS_OFFLINE_SEF_WARNING'),
+ 'disabled' => true,
+ ];
+ }
+ }
+ catch (\Throwable $e)
+ {
+ // Ignore
+ }
+
+ try
+ {
+ $db = Factory::getDbo();
+ $query = $db->getQuery(true)
+ ->select($db->quoteName(['path', 'alias', 'title', 'menutype']))
+ ->from($db->quoteName('#__menu'))
+ ->where($db->quoteName('published') . ' = 1')
+ ->where($db->quoteName('client_id') . ' = 0')
+ ->where($db->quoteName('alias') . ' != ' . $db->quote(''))
+ ->order($db->quoteName('menutype') . ', ' . $db->quoteName('title'));
+ $db->setQuery($query);
+ $menuItems = $db->loadObjectList();
+
+ $lastMenuType = '';
+
+ foreach ($menuItems ?: [] as $item)
+ {
+ if ($item->menutype !== $lastMenuType)
+ {
+ if ($lastMenuType !== '')
+ {
+ $options[] = (object) ['value' => '', 'text' => '──────────────', 'disabled' => true];
+ }
+
+ $lastMenuType = $item->menutype;
+ }
+
+ $label = $item->title !== '' ? $item->title : ucwords(str_replace(['-', '_'], ' ', $item->alias));
+ $options[] = (object) ['value' => $item->path, 'text' => $label . ' (/' . $item->path . ')'];
+ }
+ }
+ catch (\Throwable $e)
+ {
+ // Silent
+ }
+
+ return $options;
+ }
+}
diff --git a/src/packages/plg_system_mokowaas_tenant/mokowaas_tenant.xml b/src/packages/plg_system_mokowaas_tenant/mokowaas_tenant.xml
index 9609d33c..b493399d 100644
--- a/src/packages/plg_system_mokowaas_tenant/mokowaas_tenant.xml
+++ b/src/packages/plg_system_mokowaas_tenant/mokowaas_tenant.xml
@@ -8,7 +8,7 @@
GPL-3.0-or-later
hello@mokoconsulting.tech
https://mokoconsulting.tech
- 02.33.00
+ 02.32.52
PLG_SYSTEM_MOKOWAAS_TENANT_DESC
Moko\Plugin\System\MokoWaaSTenant
diff --git a/src/packages/plg_task_mokowaas_tickets/language/en-GB/plg_task_mokowaas_tickets.ini b/src/packages/plg_task_mokowaas_tickets/language/en-GB/plg_task_mokowaas_tickets.ini
new file mode 100644
index 00000000..5b695de4
--- /dev/null
+++ b/src/packages/plg_task_mokowaas_tickets/language/en-GB/plg_task_mokowaas_tickets.ini
@@ -0,0 +1,4 @@
+PLG_TASK_MOKOWAAS_TICKETS="Task - MokoWaaS Ticket Automation"
+PLG_TASK_MOKOWAAS_TICKETS_DESC="Runs scheduled helpdesk automation rules."
+PLG_TASK_MOKOWAAS_TICKETS_AUTOMATION_TITLE="MokoWaaS: Ticket Automation"
+PLG_TASK_MOKOWAAS_TICKETS_AUTOMATION_DESC="Runs time-based automation rules against open tickets (auto-close, SLA escalation, etc.)."
diff --git a/src/packages/plg_task_mokowaas_tickets/language/en-GB/plg_task_mokowaas_tickets.sys.ini b/src/packages/plg_task_mokowaas_tickets/language/en-GB/plg_task_mokowaas_tickets.sys.ini
new file mode 100644
index 00000000..c0dc6562
--- /dev/null
+++ b/src/packages/plg_task_mokowaas_tickets/language/en-GB/plg_task_mokowaas_tickets.sys.ini
@@ -0,0 +1,2 @@
+PLG_TASK_MOKOWAAS_TICKETS="Task - MokoWaaS Ticket Automation"
+PLG_TASK_MOKOWAAS_TICKETS_DESC="Runs scheduled helpdesk automation rules — auto-close, SLA escalation, and time-based actions."
diff --git a/src/packages/plg_task_mokowaas_tickets/mokowaas_tickets.xml b/src/packages/plg_task_mokowaas_tickets/mokowaas_tickets.xml
new file mode 100644
index 00000000..cfa12dde
--- /dev/null
+++ b/src/packages/plg_task_mokowaas_tickets/mokowaas_tickets.xml
@@ -0,0 +1,25 @@
+
+
+ Task - MokoWaaS Ticket Automation
+ mokowaas_tickets
+ Moko Consulting
+ 2026-06-02
+ Copyright (C) 2026 Moko Consulting. All rights reserved.
+ GPL-3.0-or-later
+ hello@mokoconsulting.tech
+ https://mokoconsulting.tech
+ 02.32.52
+ Runs scheduled helpdesk automation rules — auto-close resolved tickets, SLA breach escalation, and time-based actions.
+ Moko\Plugin\Task\MokoWaaSTickets
+
+
+ src
+ services
+ language
+
+
+
+ en-GB/plg_task_mokowaas_tickets.ini
+ en-GB/plg_task_mokowaas_tickets.sys.ini
+
+
diff --git a/src/packages/plg_task_mokowaas_tickets/services/provider.php b/src/packages/plg_task_mokowaas_tickets/services/provider.php
new file mode 100644
index 00000000..e97c8c8e
--- /dev/null
+++ b/src/packages/plg_task_mokowaas_tickets/services/provider.php
@@ -0,0 +1,27 @@
+set(
+ PluginInterface::class,
+ function (Container $container) {
+ $dispatcher = $container->get(DispatcherInterface::class);
+ $plugin = new TicketAutomation($dispatcher, (array) PluginHelper::getPlugin('task', 'mokowaas_tickets'));
+ $plugin->setApplication(Factory::getApplication());
+
+ return $plugin;
+ }
+ );
+ }
+};
diff --git a/src/packages/plg_task_mokowaas_tickets/src/Extension/TicketAutomation.php b/src/packages/plg_task_mokowaas_tickets/src/Extension/TicketAutomation.php
new file mode 100644
index 00000000..3daa7aec
--- /dev/null
+++ b/src/packages/plg_task_mokowaas_tickets/src/Extension/TicketAutomation.php
@@ -0,0 +1,65 @@
+ [
+ 'langConstPrefix' => 'PLG_TASK_MOKOWAAS_TICKETS_AUTOMATION',
+ 'method' => 'runAutomation',
+ ],
+ ];
+
+ protected $autoloadLanguage = true;
+
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ 'onTaskOptionsList' => 'advertiseRoutines',
+ 'onExecuteTask' => 'standardRoutineHandler',
+ 'onContentPrepareForm' => 'enhanceTaskItemForm',
+ ];
+ }
+
+ /**
+ * Run all scheduled automation rules against open tickets.
+ */
+ private function runAutomation(ExecuteTaskEvent $event): int
+ {
+ try
+ {
+ $model = new TicketsModel();
+ $results = $model->runScheduledAutomation();
+
+ $this->logTask(
+ \sprintf('Ticket automation: evaluated %d tickets, acted on %d', $results['evaluated'], $results['acted'])
+ );
+
+ return Status::OK;
+ }
+ catch (\Throwable $e)
+ {
+ $this->logTask('Ticket automation failed: ' . $e->getMessage(), 'error');
+
+ return Status::KNOCKOUT;
+ }
+ }
+}
diff --git a/src/packages/plg_task_mokowaasdemo/mokowaasdemo.xml b/src/packages/plg_task_mokowaasdemo/mokowaasdemo.xml
index c5f0fb25..da69aab9 100644
--- a/src/packages/plg_task_mokowaasdemo/mokowaasdemo.xml
+++ b/src/packages/plg_task_mokowaasdemo/mokowaasdemo.xml
@@ -12,8 +12,7 @@
GNU General Public License version 3 or later; see LICENSE
hello@mokoconsulting.tech
https://mokoconsulting.tech
- 02.33.00
- 02.33.00
+ 02.32.52
PLG_TASK_MOKOWAASDEMO_DESC
Moko\Plugin\Task\MokoWaaSDemo
diff --git a/src/packages/plg_task_mokowaassync/mokowaassync.xml b/src/packages/plg_task_mokowaassync/mokowaassync.xml
index 05ca075b..4f976c8a 100644
--- a/src/packages/plg_task_mokowaassync/mokowaassync.xml
+++ b/src/packages/plg_task_mokowaassync/mokowaassync.xml
@@ -12,7 +12,7 @@
GNU General Public License version 3 or later; see LICENSE
hello@mokoconsulting.tech
https://mokoconsulting.tech
- 02.33.00
+ 02.32.52
PLG_TASK_MOKOWAASSYNC_DESC
Moko\Plugin\Task\MokoWaaSSync
diff --git a/src/packages/plg_webservices_mokowaas/mokowaas.xml b/src/packages/plg_webservices_mokowaas/mokowaas.xml
index a23ea1d2..afbdf4f1 100644
--- a/src/packages/plg_webservices_mokowaas/mokowaas.xml
+++ b/src/packages/plg_webservices_mokowaas/mokowaas.xml
@@ -7,8 +7,7 @@
GPL-3.0-or-later
hello@mokoconsulting.tech
https://mokoconsulting.tech
- 02.33.00
- 02.33.00
+ 02.32.52
Joomla Web Services API routes for MokoWaaS site management — health checks, cache, updates, backups, and site info.
Moko\Plugin\WebServices\MokoWaaS
diff --git a/src/packages/plg_webservices_perfectpublisher/perfectpublisher.xml b/src/packages/plg_webservices_perfectpublisher/perfectpublisher.xml
index 5fe28dab..a8f93ba0 100644
--- a/src/packages/plg_webservices_perfectpublisher/perfectpublisher.xml
+++ b/src/packages/plg_webservices_perfectpublisher/perfectpublisher.xml
@@ -7,8 +7,7 @@
GPL-3.0-or-later
hello@mokoconsulting.tech
https://mokoconsulting.tech
- 02.33.00
- 02.33.00
+ 02.32.52
Joomla Web Services API routes for Perfect Publisher (com_autotweet) — channels, posts, requests, rules, and feeds.
Moko\Plugin\WebServices\PerfectPublisher
diff --git a/src/packages/plg_webservices_perfectpublisher/services/provider.php b/src/packages/plg_webservices_perfectpublisher/services/provider.php
index 25863663..6e5aee61 100644
--- a/src/packages/plg_webservices_perfectpublisher/services/provider.php
+++ b/src/packages/plg_webservices_perfectpublisher/services/provider.php
@@ -8,7 +8,7 @@
* INGROUP: MokoWaaS
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
* PATH: /src/packages/plg_webservices_perfectpublisher/services/provider.php
- * VERSION: 02.33.00
+ * VERSION: 02.32.52
* BRIEF: DI service provider for Perfect Publisher Web Services plugin
*/
diff --git a/src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php b/src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php
index 0a8f80fd..1f36135e 100644
--- a/src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php
+++ b/src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php
@@ -8,7 +8,7 @@
* INGROUP: MokoWaaS
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
* PATH: /src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php
- * VERSION: 02.33.00
+ * VERSION: 02.32.52
* BRIEF: Web Services API plugin for Perfect Publisher (com_autotweet)
*/
diff --git a/src/packages/tpl_mokoonyx b/src/packages/tpl_mokoonyx
index 16a7090f..f3897495 160000
--- a/src/packages/tpl_mokoonyx
+++ b/src/packages/tpl_mokoonyx
@@ -1 +1 @@
-Subproject commit 16a7090f29e0d8622a8bc6a72a7858ebaf6fac64
+Subproject commit f3897495ad93eb9ea8be53bfe2e643c22fd09dec
diff --git a/src/pkg_mokowaas.xml b/src/pkg_mokowaas.xml
index ecb3e62d..f96b5096 100644
--- a/src/pkg_mokowaas.xml
+++ b/src/pkg_mokowaas.xml
@@ -2,7 +2,7 @@
Package - MokoWaaS
mokowaas
- 02.33.00
+ 02.32.52
2026-06-02
Moko Consulting
hello@mokoconsulting.tech
@@ -17,17 +17,20 @@
plg_system_mokowaas_firewall.zip
plg_system_mokowaas_tenant.zip
plg_system_mokowaas_devtools.zip
- plg_system_mokowaas_monitor.zip
+ plg_system_mokowaas_offline.zip
com_mokowaas.zip
mod_mokowaas_cpanel.zip
+
+ mod_mokowaas_cache.zip
plg_webservices_mokowaas.zip
plg_webservices_perfectpublisher.zip
plg_task_mokowaasdemo.zip
plg_task_mokowaassync.zip
+ plg_task_mokowaas_tickets.zip
tpl_mokoonyx.zip
- https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/raw/branch/main/updates.xml
+ https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/updates.xml
diff --git a/src/script.php b/src/script.php
index eb04804c..c75c8044 100644
--- a/src/script.php
+++ b/src/script.php
@@ -32,19 +32,44 @@ class Pkg_MokowaasInstallerScript
*
* @since 2.2.0
*/
+ /**
+ * Runs before package installation/update.
+ *
+ * Fixes MySQL strict mode incompatibility: #__extensions.element is NOT NULL
+ * with no default, causing INSERT failures when Joomla's package installer
+ * creates placeholder rows before processing sub-extension manifests.
+ */
+ public function preflight($type, $parent)
+ {
+ try
+ {
+ $db = Factory::getDbo();
+ $db->setQuery("ALTER TABLE " . $db->quoteName('#__extensions')
+ . " MODIFY " . $db->quoteName('element') . " VARCHAR(100) NOT NULL DEFAULT ''");
+ $db->execute();
+ }
+ catch (\Throwable $e)
+ {
+ // Non-fatal — column may already have a default
+ }
+ }
+
public function postflight($type, $parent)
{
- // Remove legacy extensions from before the package rewrite
+ // Remove legacy extensions and migrate settings before retiring
$this->cleanupLegacyExtensions();
+ $this->migrateStandalonePlugins();
+ $this->removeRetiredExtensions();
$this->enablePlugin('system', 'mokowaas');
$this->enablePlugin('system', 'mokowaas_firewall');
$this->enablePlugin('system', 'mokowaas_tenant');
$this->enablePlugin('system', 'mokowaas_devtools');
- $this->enablePlugin('system', 'mokowaas_monitor');
+ $this->enablePlugin('system', 'mokowaas_offline');
$this->enablePlugin('webservices', 'mokowaas');
$this->enablePlugin('task', 'mokowaasdemo');
$this->enablePlugin('task', 'mokowaassync');
+ $this->enablePlugin('task', 'mokowaas_tickets');
// Migrate params from core plugin to feature plugins (one-time)
$this->migrateFeatureParams();
@@ -52,14 +77,32 @@ class Pkg_MokowaasInstallerScript
// Set up cpanel module on the admin dashboard
$this->setupCpanelModule();
+ // Set up admin sidebar menu module
+ $this->setupAdminMenuModule();
+
+ // Set up cache cleaner status bar module
+ $this->setupCacheModule();
+
+ // Create Support portal menu item on frontend
+ $this->setupSupportMenuItem();
+
+ // Set menu_icon params on submenu items (Joomla only renders img on level 1)
+ $this->fixMenuIcons();
+
// Mark MokoWaaS extensions as protected (prevents disable/uninstall at framework level)
$this->protectExtensions();
+ // Migrate all Moko update server URLs to new format
+ $this->migrateUpdateServerUrls();
+
// Clean up stale/duplicate update sites
$this->cleanupStaleUpdateSites();
// Trigger heartbeat registration
$this->sendHeartbeat();
+
+ // Warn if no license key is configured
+ $this->warnMissingLicenseKey();
}
/**
@@ -126,6 +169,230 @@ class Pkg_MokowaasInstallerScript
}
}
+ /**
+ * Remove extensions that have been retired and merged into core.
+ *
+ * plg_system_mokowaas_monitor was merged into the core plugin in 02.32.00.
+ * Health monitoring is now built into plg_system_mokowaas directly.
+ *
+ * @return void
+ *
+ * @since 02.32.00
+ */
+ private function migrateStandalonePlugins(): void
+ {
+ // Migrate standalone MokoJoomTOS plugin to MokoWaaS Offline Bypass
+ $migrations = [
+ ['old_element' => 'mokojoomtos', 'old_folder' => 'system', 'new_element' => 'mokowaas_offline', 'new_folder' => 'system'],
+ ];
+
+ try
+ {
+ $db = Factory::getDbo();
+
+ foreach ($migrations as $m)
+ {
+ // Check if old plugin exists
+ $query = $db->getQuery(true)
+ ->select([$db->quoteName('extension_id'), $db->quoteName('params')])
+ ->from($db->quoteName('#__extensions'))
+ ->where($db->quoteName('element') . ' = ' . $db->quote($m['old_element']))
+ ->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
+ ->where($db->quoteName('folder') . ' = ' . $db->quote($m['old_folder']));
+ $db->setQuery($query);
+ $old = $db->loadObject();
+
+ if (!$old)
+ {
+ continue;
+ }
+
+ $oldParams = $old->params ?? '{}';
+
+ // Copy params to new plugin (only if new plugin has empty params)
+ $query = $db->getQuery(true)
+ ->select($db->quoteName('params'))
+ ->from($db->quoteName('#__extensions'))
+ ->where($db->quoteName('element') . ' = ' . $db->quote($m['new_element']))
+ ->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
+ ->where($db->quoteName('folder') . ' = ' . $db->quote($m['new_folder']));
+ $db->setQuery($query);
+ $newParams = (string) $db->loadResult();
+
+ if (empty($newParams) || $newParams === '{}' || $newParams === '[]')
+ {
+ $db->setQuery(
+ $db->getQuery(true)
+ ->update($db->quoteName('#__extensions'))
+ ->set($db->quoteName('params') . ' = ' . $db->quote($oldParams))
+ ->where($db->quoteName('element') . ' = ' . $db->quote($m['new_element']))
+ ->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
+ ->where($db->quoteName('folder') . ' = ' . $db->quote($m['new_folder']))
+ )->execute();
+
+ Factory::getApplication()->enqueueMessage(
+ sprintf('Migrated settings from %s to %s.', $m['old_element'], $m['new_element']),
+ 'message'
+ );
+ }
+
+ // Unprotect old plugin
+ $db->setQuery(
+ $db->getQuery(true)
+ ->update($db->quoteName('#__extensions'))
+ ->set($db->quoteName('protected') . ' = 0')
+ ->where($db->quoteName('extension_id') . ' = ' . (int) $old->extension_id)
+ )->execute();
+
+ // Remove old extension record
+ $db->setQuery(
+ $db->getQuery(true)
+ ->delete($db->quoteName('#__extensions'))
+ ->where($db->quoteName('extension_id') . ' = ' . (int) $old->extension_id)
+ )->execute();
+
+ // Remove old update site entries
+ $db->setQuery(
+ $db->getQuery(true)
+ ->delete($db->quoteName('#__update_sites_extensions'))
+ ->where($db->quoteName('extension_id') . ' = ' . (int) $old->extension_id)
+ )->execute();
+
+ // Remove old files
+ $dir = JPATH_PLUGINS . '/' . $m['old_folder'] . '/' . $m['old_element'];
+
+ if (is_dir($dir))
+ {
+ $this->rmdirRecursive($dir);
+ }
+
+ Factory::getApplication()->enqueueMessage(
+ sprintf('Removed standalone %s plugin (replaced by %s).', $m['old_element'], $m['new_element']),
+ 'message'
+ );
+
+ Log::add(
+ sprintf('Migrated %s → %s and removed old plugin', $m['old_element'], $m['new_element']),
+ Log::INFO,
+ 'mokowaas'
+ );
+ }
+ }
+ catch (\Throwable $e)
+ {
+ Log::add('Standalone plugin migration error: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
+ }
+ }
+
+ /**
+ * Remove extensions that have been retired and merged into core.
+ *
+ * @return void
+ *
+ * @since 02.32.00
+ */
+ private function removeRetiredExtensions(): void
+ {
+ $retired = [
+ ['type' => 'plugin', 'folder' => 'system', 'element' => 'mokowaas_monitor'],
+ ['type' => 'plugin', 'folder' => 'system', 'element' => 'mokojoomtos'],
+ ['type' => 'plugin', 'folder' => 'system', 'element' => 'mokoatsautomation'],
+ ['type' => 'plugin', 'folder' => 'webservices', 'element' => 'mokodpcalendarapi'],
+ ['type' => 'plugin', 'folder' => 'system', 'element' => 'mokogallerycalendar'],
+ ];
+
+ try
+ {
+ $db = Factory::getDbo();
+
+ foreach ($retired as $ext)
+ {
+ // Check if installed
+ $query = $db->getQuery(true)
+ ->select($db->quoteName('extension_id'))
+ ->from($db->quoteName('#__extensions'))
+ ->where($db->quoteName('type') . ' = ' . $db->quote($ext['type']))
+ ->where($db->quoteName('folder') . ' = ' . $db->quote($ext['folder']))
+ ->where($db->quoteName('element') . ' = ' . $db->quote($ext['element']));
+ $db->setQuery($query);
+ $extId = (int) $db->loadResult();
+
+ if (!$extId)
+ {
+ continue;
+ }
+
+ // Unprotect so Joomla allows removal
+ $db->setQuery(
+ $db->getQuery(true)
+ ->update($db->quoteName('#__extensions'))
+ ->set($db->quoteName('protected') . ' = 0')
+ ->where($db->quoteName('extension_id') . ' = ' . $extId)
+ )->execute();
+
+ // Remove update site links and update sites
+ $db->setQuery(
+ $db->getQuery(true)
+ ->select($db->quoteName('update_site_id'))
+ ->from($db->quoteName('#__update_sites_extensions'))
+ ->where($db->quoteName('extension_id') . ' = ' . $extId)
+ );
+ $siteIds = $db->loadColumn();
+
+ $db->setQuery(
+ $db->getQuery(true)
+ ->delete($db->quoteName('#__update_sites_extensions'))
+ ->where($db->quoteName('extension_id') . ' = ' . $extId)
+ )->execute();
+
+ if (!empty($siteIds))
+ {
+ $db->setQuery(
+ $db->getQuery(true)
+ ->delete($db->quoteName('#__updates'))
+ ->where($db->quoteName('update_site_id') . ' IN (' . implode(',', $siteIds) . ')')
+ )->execute();
+
+ $db->setQuery(
+ $db->getQuery(true)
+ ->delete($db->quoteName('#__update_sites'))
+ ->where($db->quoteName('update_site_id') . ' IN (' . implode(',', $siteIds) . ')')
+ )->execute();
+ }
+
+ // Remove extension record
+ $db->setQuery(
+ $db->getQuery(true)
+ ->delete($db->quoteName('#__extensions'))
+ ->where($db->quoteName('extension_id') . ' = ' . $extId)
+ )->execute();
+
+ // Remove files
+ $dir = JPATH_PLUGINS . '/' . $ext['folder'] . '/' . $ext['element'];
+
+ if (is_dir($dir))
+ {
+ $this->rmdirRecursive($dir);
+ }
+
+ Factory::getApplication()->enqueueMessage(
+ sprintf('Removed retired extension: %s/%s', $ext['folder'], $ext['element']),
+ 'message'
+ );
+
+ Log::add(
+ sprintf('Removed retired extension %s/%s (ID %d)', $ext['folder'], $ext['element'], $extId),
+ Log::INFO,
+ 'mokowaas'
+ );
+ }
+ }
+ catch (\Throwable $e)
+ {
+ Log::add('Retired extension cleanup error: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
+ }
+ }
+
/**
* Recursively remove a directory.
*
@@ -211,11 +478,12 @@ class Pkg_MokowaasInstallerScript
$db->quote('mokowaas_firewall'),
$db->quote('mokowaas_tenant'),
$db->quote('mokowaas_devtools'),
- $db->quote('mokowaas_monitor'),
+ $db->quote('mokowaas_offline'),
$db->quote('com_mokowaas'),
$db->quote('mod_mokowaas_cpanel'),
$db->quote('mokowaasdemo'),
$db->quote('mokowaassync'),
+ $db->quote('mokowaas_tickets'),
$db->quote('perfectpublisher'),
$db->quote('mokoonyx'),
];
@@ -237,6 +505,42 @@ class Pkg_MokowaasInstallerScript
}
}
+ /**
+ * Rewrite all Moko Consulting update server URLs from the old
+ * raw/branch/main pattern to the new clean /updates.xml pattern.
+ *
+ * Old: https://git.mokoconsulting.tech/MokoConsulting/{repo}/raw/branch/main/updates.xml
+ * New: https://git.mokoconsulting.tech/MokoConsulting/{repo}/updates.xml
+ */
+ private function migrateUpdateServerUrls(): void
+ {
+ try
+ {
+ $db = Factory::getDbo();
+
+ $db->setQuery(
+ "UPDATE " . $db->quoteName('#__update_sites')
+ . " SET " . $db->quoteName('location') . " = REPLACE("
+ . $db->quoteName('location') . ", '/raw/branch/main/updates.xml', '/updates.xml')"
+ . " WHERE " . $db->quoteName('location') . " LIKE " . $db->quote('%mokoconsulting.tech%/raw/branch/main/updates.xml')
+ );
+ $db->execute();
+ $count = $db->getAffectedRows();
+
+ if ($count > 0)
+ {
+ Factory::getApplication()->enqueueMessage(
+ sprintf('Migrated %d Moko update server URL(s) to new format.', $count),
+ 'message'
+ );
+ }
+ }
+ catch (\Throwable $e)
+ {
+ Log::add('Update server URL migration error: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
+ }
+ }
+
/**
* Remove stale and duplicate MokoWaaS update site entries.
*
@@ -507,6 +811,308 @@ class Pkg_MokowaasInstallerScript
}
}
+ /**
+ * Set up the MokoWaaS admin sidebar menu module at position 0.
+ */
+ private function setupAdminMenuModule(): void
+ {
+ try
+ {
+ $db = Factory::getDbo();
+
+ // Enable the module extension
+ $db->setQuery(
+ $db->getQuery(true)
+ ->update($db->quoteName('#__extensions'))
+ ->set($db->quoteName('enabled') . ' = 1')
+ ->where($db->quoteName('type') . ' = ' . $db->quote('module'))
+ ->where($db->quoteName('element') . ' = ' . $db->quote('mod_mokowaas_menu'))
+ )->execute();
+
+ // Check if module instance exists
+ $db->setQuery(
+ $db->getQuery(true)
+ ->select('COUNT(*)')
+ ->from($db->quoteName('#__modules'))
+ ->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokowaas_menu'))
+ );
+
+ if ((int) $db->loadResult() > 0)
+ {
+ return;
+ }
+
+ $module = (object) [
+ 'title' => 'MokoWaaS Menu',
+ 'note' => '',
+ 'content' => '',
+ 'ordering' => 0,
+ 'position' => 'menu',
+ 'checked_out' => null,
+ 'checked_out_time' => null,
+ 'publish_up' => null,
+ 'publish_down' => null,
+ 'published' => 1,
+ 'module' => 'mod_mokowaas_menu',
+ 'access' => 3,
+ 'showtitle' => 0,
+ 'params' => '{}',
+ 'client_id' => 1,
+ 'language' => '*',
+ ];
+
+ $db->insertObject('#__modules', $module, 'id');
+
+ if ((int) $module->id)
+ {
+ $db->insertObject('#__modules_menu', (object) ['moduleid' => (int) $module->id, 'menuid' => 0]);
+ }
+ }
+ catch (\Throwable $e)
+ {
+ Log::add('Admin menu module setup error: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
+ }
+ }
+
+ /**
+ * Set up the cache cleaner module in the admin status bar position.
+ */
+ private function setupCacheModule(): void
+ {
+ try
+ {
+ $db = Factory::getDbo();
+
+ // Enable the module extension
+ $db->setQuery(
+ $db->getQuery(true)
+ ->update($db->quoteName('#__extensions'))
+ ->set($db->quoteName('enabled') . ' = 1')
+ ->where($db->quoteName('type') . ' = ' . $db->quote('module'))
+ ->where($db->quoteName('element') . ' = ' . $db->quote('mod_mokowaas_cache'))
+ )->execute();
+
+ // Check if module instance exists
+ $db->setQuery(
+ $db->getQuery(true)
+ ->select('COUNT(*)')
+ ->from($db->quoteName('#__modules'))
+ ->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokowaas_cache'))
+ );
+
+ if ((int) $db->loadResult() > 0)
+ {
+ return;
+ }
+
+ $module = (object) [
+ 'title' => 'MokoWaaS Cache Cleaner',
+ 'note' => '',
+ 'content' => '',
+ 'ordering' => 8,
+ 'position' => 'status',
+ 'checked_out' => null,
+ 'checked_out_time' => null,
+ 'publish_up' => null,
+ 'publish_down' => null,
+ 'published' => 1,
+ 'module' => 'mod_mokowaas_cache',
+ 'access' => 3,
+ 'showtitle' => 0,
+ 'params' => '{}',
+ 'client_id' => 1,
+ 'language' => '*',
+ ];
+
+ $db->insertObject('#__modules', $module, 'id');
+
+ if ((int) $module->id)
+ {
+ $mm = (object) ['moduleid' => (int) $module->id, 'menuid' => 0];
+ $db->insertObject('#__modules_menu', $mm, 'moduleid');
+ }
+ }
+ catch (\Throwable $e)
+ {
+ Log::add('Cache module setup error: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
+ }
+ }
+
+ /**
+ * Joomla only renders the img column icon for level-1 menu items.
+ * Submenu items (level 2) need menu_icon set in the params JSON.
+ */
+ private function fixMenuIcons(): void
+ {
+ try
+ {
+ $db = Factory::getDbo();
+
+ $iconMap = [
+ 'class:cogs' => 'icon-cogs',
+ 'class:puzzle-piece' => 'icon-puzzle-piece',
+ 'class:headphones' => 'icon-headphones',
+ 'class:file-code' => 'icon-file-code',
+ 'class:lock' => 'icon-lock',
+ 'class:shield-alt' => 'icon-shield-alt',
+ 'class:database' => 'icon-database',
+ 'class:trash' => 'icon-trash',
+ 'class:power-off' => 'icon-power-off',
+ 'class:refresh' => 'icon-refresh',
+ 'class:check-square' => 'icon-check-square',
+ 'class:bolt' => 'icon-bolt',
+ ];
+
+ $db->setQuery(
+ "SELECT id, img, params FROM #__menu"
+ . " WHERE client_id = 1 AND level >= 2"
+ . " AND link LIKE '%com_mokowaas%'"
+ );
+
+ foreach ($db->loadObjectList() as $item)
+ {
+ $icon = $iconMap[$item->img] ?? '';
+
+ if (!$icon)
+ {
+ continue;
+ }
+
+ $params = json_decode($item->params ?: '{}', true) ?: [];
+
+ if (!empty($params['menu_icon']))
+ {
+ continue;
+ }
+
+ $params['menu_icon'] = $icon;
+
+ $db->setQuery(
+ $db->getQuery(true)
+ ->update($db->quoteName('#__menu'))
+ ->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($params)))
+ ->where($db->quoteName('id') . ' = ' . (int) $item->id)
+ )->execute();
+ }
+ }
+ catch (\Throwable $e)
+ {
+ Log::add('Menu icon fix error: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
+ }
+ }
+
+ /**
+ * Create a "Support" menu item on the frontend main menu.
+ */
+ private function setupSupportMenuItem(): void
+ {
+ try
+ {
+ $db = Factory::getDbo();
+
+ $db->setQuery(
+ $db->getQuery(true)
+ ->select('COUNT(*)')
+ ->from($db->quoteName('#__menu'))
+ ->where($db->quoteName('link') . ' LIKE ' . $db->quote('%com_mokowaas&view=tickets%'))
+ ->where($db->quoteName('client_id') . ' = 0')
+ );
+
+ if ((int) $db->loadResult() > 0)
+ {
+ return;
+ }
+
+ $db->setQuery(
+ $db->getQuery(true)
+ ->select($db->quoteName('extension_id'))
+ ->from($db->quoteName('#__extensions'))
+ ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
+ ->where($db->quoteName('type') . ' = ' . $db->quote('component'))
+ );
+ $componentId = (int) $db->loadResult();
+
+ if (!$componentId)
+ {
+ return;
+ }
+
+ $db->setQuery("SELECT id FROM #__menu WHERE menutype = '' AND level = 0 AND client_id = 0 LIMIT 1");
+ $rootId = (int) $db->loadResult() ?: 1;
+
+ $db->setQuery('SELECT MAX(rgt) FROM #__menu WHERE client_id = 0');
+ $maxRgt = (int) $db->loadResult();
+
+ $item = (object) [
+ 'menutype' => 'mainmenu',
+ 'title' => 'Support',
+ 'alias' => 'support',
+ 'note' => '',
+ 'path' => 'support',
+ 'link' => 'index.php?option=com_mokowaas&view=tickets',
+ 'type' => 'component',
+ 'published' => 1,
+ 'parent_id' => $rootId,
+ 'level' => 1,
+ 'component_id' => $componentId,
+ 'checked_out' => null,
+ 'checked_out_time' => null,
+ 'browserNav' => 0,
+ 'access' => 2,
+ 'img' => '',
+ 'template_style_id' => 0,
+ 'params' => '{}',
+ 'lft' => $maxRgt + 1,
+ 'rgt' => $maxRgt + 2,
+ 'home' => 0,
+ 'language' => '*',
+ 'client_id' => 0,
+ ];
+
+ $db->insertObject('#__menu', $item, 'id');
+ $supportId = (int) $item->id;
+
+ // Create "Submit a Ticket" child menu item
+ if ($supportId)
+ {
+ $db->setQuery('SELECT MAX(rgt) FROM #__menu WHERE client_id = 0');
+ $maxRgt2 = (int) $db->loadResult();
+
+ $child = (object) [
+ 'menutype' => 'mainmenu',
+ 'title' => 'Submit a Ticket',
+ 'alias' => 'submit-ticket',
+ 'note' => '',
+ 'path' => 'support/submit-ticket',
+ 'link' => 'index.php?option=com_mokowaas&view=tickets&layout=submit',
+ 'type' => 'component',
+ 'published' => 1,
+ 'parent_id' => $supportId,
+ 'level' => 2,
+ 'component_id' => $componentId,
+ 'checked_out' => null,
+ 'checked_out_time' => null,
+ 'browserNav' => 0,
+ 'access' => 2,
+ 'img' => '',
+ 'template_style_id' => 0,
+ 'params' => '{}',
+ 'lft' => $maxRgt2 + 1,
+ 'rgt' => $maxRgt2 + 2,
+ 'home' => 0,
+ 'language' => '*',
+ 'client_id' => 0,
+ ];
+
+ $db->insertObject('#__menu', $child, 'id');
+ }
+ }
+ catch (\Throwable $e)
+ {
+ Log::add('Support menu setup error: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
+ }
+ }
+
/**
* One-time migration of params from the monolithic core plugin to
* the new feature plugins. Copies security, tenant, and dev params.
@@ -621,4 +1227,57 @@ class Pkg_MokowaasInstallerScript
Log::add('Feature param migration error: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
}
}
+
+ /**
+ * Warn after install/update if no license key (dlid) is configured on the update site.
+ */
+ private function warnMissingLicenseKey(): void
+ {
+ try
+ {
+ $db = Factory::getDbo();
+ $app = Factory::getApplication();
+
+ $query = $db->getQuery(true)
+ ->select([$db->quoteName('update_site_id'), $db->quoteName('extra_query')])
+ ->from($db->quoteName('#__update_sites'))
+ ->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoWaaS%')
+ . ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoWaaS%') . ')')
+ ->setLimit(1);
+ $db->setQuery($query);
+ $site = $db->loadObject();
+
+ if ($site)
+ {
+ $extraQuery = (string) ($site->extra_query ?? '');
+
+ if (!empty($extraQuery) && strpos($extraQuery, 'dlid=') !== false)
+ {
+ parse_str($extraQuery, $parsed);
+
+ if (!empty($parsed['dlid']))
+ {
+ return;
+ }
+ }
+
+ $editUrl = 'index.php?option=com_installer&task=updatesite.edit&update_site_id=' . (int) $site->update_site_id;
+ }
+ else
+ {
+ $editUrl = 'index.php?option=com_installer&view=updatesites';
+ }
+
+ $app->enqueueMessage(
+ 'Moko Consulting License Key Required — '
+ . 'No download key is configured. Updates will not be available until a valid license key is entered. '
+ . 'Enter License Key',
+ 'warning'
+ );
+ }
+ catch (\Throwable $e)
+ {
+ // Silent
+ }
+ }
}