Compare commits

..

2 Commits

Author SHA1 Message Date
jmiller b89ae8cef5 revert: re-enable auto-bump on dev push [skip ci] 2026-06-17 04:46:08 +00:00
jmiller 2ce9847ac5 ci: disable auto-bump on push to dev [skip ci] 2026-06-16 18:21:12 +00:00
65 changed files with 661 additions and 5089 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
<display-name>Package - MokoSuiteClient</display-name> <display-name>Package - MokoSuiteClient</display-name>
<org>MokoConsulting</org> <org>MokoConsulting</org>
<description>White-label identity, security hardening, and tenant restriction layer for Suite-managed Joomla environments</description> <description>White-label identity, security hardening, and tenant restriction layer for Suite-managed Joomla environments</description>
<version>02.34.83</version> <version>02.34.50</version>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license> <license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity> </identity>
<governance> <governance>
-66
View File
@@ -1,66 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoPlatform.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.mokogitea/workflows/rc-revert.yml
# VERSION: 09.23.00
# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge
name: "RC Revert"
on:
pull_request:
types: [closed]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
revert:
name: Rename rc/ back to dev/
runs-on: ubuntu-latest
if: >-
github.event.pull_request.merged == false &&
startsWith(github.event.pull_request.head.ref, 'rc/')
steps:
- name: Rename branch
run: |
BRANCH="${{ github.event.pull_request.head.ref }}"
SUFFIX="${BRANCH#rc/}"
DEV_BRANCH="dev/${SUFFIX}"
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Create dev/ branch from rc/ branch
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"new_branch_name\": \"${DEV_BRANCH}\", \"old_branch_name\": \"${BRANCH}\"}" \
"${API}" 2>/dev/null || true)
if [ "$STATUS" = "201" ]; then
echo "Created branch: ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
else
echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"
exit 1
fi
# Delete rc/ branch
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${API}/${ENCODED}" 2>/dev/null || true)
if [ "$STATUS" = "204" ]; then
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
else
echo "::warning::Failed to delete ${BRANCH} (HTTP ${STATUS})"
fi
echo "### RC Reverted" >> $GITHUB_STEP_SUMMARY
echo "${BRANCH} → ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
+1 -1
View File
@@ -15,7 +15,7 @@
INGROUP: MokoSuiteClient.Documentation INGROUP: MokoSuiteClient.Documentation
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
PATH: ./LICENSE.md PATH: ./LICENSE.md
VERSION: 02.34.83 VERSION: 02.34.50
BRIEF: Project license (GPL-3.0-or-later) BRIEF: Project license (GPL-3.0-or-later)
--> -->
GNU GENERAL PUBLIC LICENSE GNU GENERAL PUBLIC LICENSE
+1 -1
View File
@@ -23,7 +23,7 @@ DEFGROUP: [PROJECT_NAME]
INGROUP: [PROJECT_NAME].Documentation INGROUP: [PROJECT_NAME].Documentation
REPO: [REPOSITORY_URL] REPO: [REPOSITORY_URL]
PATH: /SECURITY.md PATH: /SECURITY.md
VERSION: 02.34.83 VERSION: 02.34.50
BRIEF: Security vulnerability reporting and handling policy BRIEF: Security vulnerability reporting and handling policy
--> -->
+1 -1
View File
@@ -10,7 +10,7 @@ DEFGROUP: MokoSuiteClient.Documentation
INGROUP: MokoStandards.Templates INGROUP: MokoStandards.Templates
REPO: https://github.com/mokoconsulting-tech/MokoSuiteClient REPO: https://github.com/mokoconsulting-tech/MokoSuiteClient
PATH: /docs/update-server.md PATH: /docs/update-server.md
VERSION: 02.34.83 VERSION: 02.34.50
BRIEF: How this extension's Joomla update server file (update.xml) is managed BRIEF: How this extension's Joomla update server file (update.xml) is managed
--> -->
@@ -50,14 +50,14 @@
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomOpenGraph/raw/branch/dev/updates.xml</updateserver> <updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomOpenGraph/raw/branch/dev/updates.xml</updateserver>
</extension> </extension>
<extension> <extension>
<name>MokoSuiteClientBackup</name> <name>MokoJoomBackup</name>
<element>pkg_mokojoombackup</element> <element>pkg_mokojoombackup</element>
<type>package</type> <type>package</type>
<description>Full-site backup and restore for Joomla — database, files, and configuration.</description> <description>Automated backup system with Borg integration, scheduled tasks, and remote storage.</description>
<icon>icon-archive</icon> <icon>icon-archive</icon>
<category>Tools</category> <category>Tools</category>
<article>https://mokoconsulting.tech/support/products/mokosuiteclientbackup</article> <article>https://mokoconsulting.tech/support/products/mokojoombackup</article>
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClientBackup/raw/branch/dev/updates.xml</updateserver> <updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomBackup/raw/branch/dev/updates.xml</updateserver>
</extension> </extension>
<extension> <extension>
<name>MokoJoomHero</name> <name>MokoJoomHero</name>
@@ -1,16 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<config> <config>
<fieldset name="general" label="General" description="General component settings.">
<field name="brand_name" type="text" default="MokoSuiteClient"
label="Brand Name"
description="Displayed in the admin sidebar, dashboard, and emails."
hint="MokoSuiteClient" />
<field name="support_email" type="email" default=""
label="Support Email"
description="Reply-to address for outbound notification emails."
hint="support@example.com" />
</fieldset>
<fieldset name="notifications" label="Email Notifications" description="Configure email recipients for ticket and security notifications."> <fieldset name="notifications" label="Email Notifications" description="Configure email recipients for ticket and security notifications.">
<field name="admin_emails" type="text" default="" <field name="admin_emails" type="text" default=""
label="Admin Email Addresses" label="Admin Email Addresses"
@@ -27,31 +16,6 @@
<option value="1">JYES</option> <option value="1">JYES</option>
<option value="0">JNO</option> <option value="0">JNO</option>
</field> </field>
<field name="spacer_ntfy" type="spacer" label="Push Notifications (ntfy)" />
<field name="ntfy_enabled" type="radio" default="0"
label="Enable ntfy Push"
description="Send push notifications via ntfy for ticket and security events."
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="ntfy_server" type="url" default="https://ntfy.mokoconsulting.tech"
label="ntfy Server URL"
description="Full URL to your ntfy server."
showon="ntfy_enabled:1" />
<field name="ntfy_topic" type="text" default="mokosuiteclient-tickets"
label="Ticket Topic"
description="ntfy topic name for helpdesk ticket notifications."
showon="ntfy_enabled:1" />
<field name="ntfy_security_topic" type="text" default="mokosuiteclient-security"
label="Security Topic"
description="ntfy topic name for security alert notifications. Falls back to ticket topic if empty."
showon="ntfy_enabled:1" />
<field name="ntfy_token" type="password" default=""
label="ntfy Auth Token"
description="Bearer token for authenticated ntfy topics. Leave empty for public topics."
showon="ntfy_enabled:1" />
</fieldset> </fieldset>
<fieldset name="helpdesk" label="Helpdesk Settings" description="Default helpdesk behavior."> <fieldset name="helpdesk" label="Helpdesk Settings" description="Default helpdesk behavior.">
@@ -69,44 +33,6 @@
<option value="1">JYES</option> <option value="1">JYES</option>
<option value="0">JNO</option> <option value="0">JNO</option>
</field> </field>
<field name="satisfaction_enabled" type="radio" default="1"
label="Satisfaction Ratings"
description="Show rating prompt on resolved tickets."
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="max_attachment_size" type="number" default="10"
label="Max Attachment Size (MB)"
description="Maximum upload size per file in megabytes." />
</fieldset>
<fieldset name="email_to_ticket" label="Email-to-Ticket (IMAP)" description="Create tickets from incoming emails via IMAP polling.">
<field name="imap_host" type="text" default=""
label="IMAP Server"
description="IMAP hostname (e.g. imap.gmail.com)"
hint="imap.gmail.com" />
<field name="imap_port" type="number" default="993"
label="Port"
description="IMAP port (993 for SSL, 143 for plain)" />
<field name="imap_ssl" type="radio" default="1"
label="Use SSL"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="imap_user" type="text" default=""
label="Username"
description="IMAP login username or email address." />
<field name="imap_password" type="password" default=""
label="Password"
description="IMAP password or app-specific password." />
<field name="imap_folder" type="text" default="INBOX"
label="Inbox Folder"
description="IMAP folder to poll for new messages." />
<field name="imap_processed_folder" type="text" default="INBOX.Processed"
label="Processed Folder"
description="Move processed emails to this folder. Leave empty to just mark as read." />
</fieldset> </fieldset>
<fieldset name="permissions" label="COM_MOKOSUITECLIENT_ACL_TITLE" <fieldset name="permissions" label="COM_MOKOSUITECLIENT_ACL_TITLE"
@@ -72,9 +72,6 @@ CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_tickets` (
`sla_response_due` DATETIME DEFAULT NULL, `sla_response_due` DATETIME DEFAULT NULL,
`sla_resolution_due` DATETIME DEFAULT NULL, `sla_resolution_due` DATETIME DEFAULT NULL,
`sla_responded` TINYINT NOT NULL DEFAULT 0, `sla_responded` TINYINT NOT NULL DEFAULT 0,
`satisfaction_rating` TINYINT UNSIGNED DEFAULT NULL,
`satisfaction_feedback` TEXT DEFAULT NULL,
`satisfaction_rated_at` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
KEY `idx_status` (`status`), KEY `idx_status` (`status`),
KEY `idx_status_id` (`status_id`), KEY `idx_status_id` (`status_id`),
@@ -114,32 +111,15 @@ CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_ticket_canned` (
PRIMARY KEY (`id`) PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_ticket_attachments` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`ticket_id` INT UNSIGNED NOT NULL,
`reply_id` INT UNSIGNED DEFAULT NULL,
`filename` VARCHAR(255) NOT NULL,
`filepath` VARCHAR(512) NOT NULL,
`filesize` INT UNSIGNED NOT NULL DEFAULT 0,
`mimetype` VARCHAR(100) NOT NULL DEFAULT '',
`uploaded_by` INT NOT NULL DEFAULT 0,
`created` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_ticket` (`ticket_id`),
KEY `idx_reply` (`reply_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_ticket_automation` ( CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_ticket_automation` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`title` VARCHAR(255) NOT NULL, `title` VARCHAR(255) NOT NULL,
`trigger_event` VARCHAR(50) NOT NULL DEFAULT 'ticket_created', `trigger_event` VARCHAR(50) NOT NULL DEFAULT 'ticket_created',
`conditions` TEXT NOT NULL, `conditions` TEXT NOT NULL DEFAULT '[]',
`actions` TEXT NOT NULL, `actions` TEXT NOT NULL DEFAULT '[]',
`behavior` ENUM('append','always_new','skip_if_open') NOT NULL DEFAULT 'append',
`enabled` TINYINT NOT NULL DEFAULT 1, `enabled` TINYINT NOT NULL DEFAULT 1,
`ordering` INT NOT NULL DEFAULT 0, `ordering` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`), PRIMARY KEY (`id`)
KEY `idx_trigger` (`trigger_event`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_ticket_assignees` ( CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_ticket_assignees` (
@@ -36,7 +36,6 @@ class DisplayController extends BaseController
'automation' => 'core.admin', 'automation' => 'core.admin',
'database' => 'core.admin', 'database' => 'core.admin',
'cleanup' => 'mokosuiteclient.cache', 'cleanup' => 'mokosuiteclient.cache',
'ticketsettings' => 'core.admin',
]; ];
public function display($cachable = false, $urlparams = []) public function display($cachable = false, $urlparams = [])
@@ -370,10 +369,6 @@ class DisplayController extends BaseController
'body' => $input->getRaw('body', ''), 'body' => $input->getRaw('body', ''),
'priority' => $input->getString('priority', 'normal'), 'priority' => $input->getString('priority', 'normal'),
'category_id' => $input->getInt('category_id', 0), 'category_id' => $input->getInt('category_id', 0),
'contact_id' => $input->getInt('contact_id', 0),
'assign_users' => $input->get('assign_users', [], 'ARRAY'),
'assign_groups' => $input->get('assign_groups', [], 'ARRAY'),
'custom_fields' => $input->get('custom_fields', [], 'ARRAY'),
])); ]));
} }
@@ -410,85 +405,10 @@ class DisplayController extends BaseController
$this->jsonResponse($this->getModel('Tickets')->updateStatus( $this->jsonResponse($this->getModel('Tickets')->updateStatus(
$input->getInt('ticket_id', 0), $input->getInt('ticket_id', 0),
$input->getInt('status', 0) $input->getString('status', '')
)); ));
} }
// ==================================================================
// Ticket Settings — Status/Priority CRUD
// ==================================================================
public function saveStatus()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin'))
{
$this->jsonForbidden();
return;
}
$input = Factory::getApplication()->getInput();
$this->jsonResponse($this->getModel('Tickets')->saveStatus([
'id' => $input->getInt('id', 0),
'title' => $input->getString('title', ''),
'alias' => $input->getString('alias', ''),
'color' => $input->getString('color', 'bg-secondary'),
'is_default' => $input->getInt('is_default', 0),
'is_closed' => $input->getInt('is_closed', 0),
'ordering' => $input->getInt('ordering', 0),
]));
}
public function deleteStatus()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin'))
{
$this->jsonForbidden();
return;
}
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$this->jsonResponse($this->getModel('Tickets')->deleteStatus($id));
}
public function savePriority()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin'))
{
$this->jsonForbidden();
return;
}
$input = Factory::getApplication()->getInput();
$this->jsonResponse($this->getModel('Tickets')->savePriority([
'id' => $input->getInt('id', 0),
'title' => $input->getString('title', ''),
'alias' => $input->getString('alias', ''),
'color' => $input->getString('color', 'bg-secondary'),
'is_default' => $input->getInt('is_default', 0),
'ordering' => $input->getInt('ordering', 0),
]));
}
public function deletePriority()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin'))
{
$this->jsonForbidden();
return;
}
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$this->jsonResponse($this->getModel('Tickets')->deletePriority($id));
}
// ================================================================== // ==================================================================
// KB Search // KB Search
// ================================================================== // ==================================================================
@@ -606,19 +526,6 @@ class DisplayController extends BaseController
$this->jsonResponse(['success' => true, 'message' => 'Category deleted.']); $this->jsonResponse(['success' => true, 'message' => 'Category deleted.']);
} }
public function reorderCategory()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); }
$order = json_decode(Factory::getApplication()->getInput()->getRaw('order', '[]'), true);
if (!is_array($order)) { $this->jsonResponse(['success' => false, 'message' => 'Invalid order']); return; }
$db = Factory::getDbo();
foreach ($order as $i => $id) {
$db->setQuery('UPDATE ' . $db->quoteName('#__mokosuiteclient_ticket_categories') . ' SET ordering = ' . (int) $i . ' WHERE id = ' . (int) $id)->execute();
}
$this->jsonResponse(['success' => true, 'message' => 'Order saved.']);
}
public function saveCanned() public function saveCanned()
{ {
Session::checkToken() or die(Text::_('JINVALID_TOKEN')); Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
@@ -646,83 +553,6 @@ class DisplayController extends BaseController
$this->jsonResponse(['success' => true, 'message' => 'Canned response deleted.']); $this->jsonResponse(['success' => true, 'message' => 'Canned response deleted.']);
} }
public function reorderCanned()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); }
$order = json_decode(Factory::getApplication()->getInput()->getRaw('order', '[]'), true);
if (!is_array($order)) { $this->jsonResponse(['success' => false, 'message' => 'Invalid order']); return; }
$db = Factory::getDbo();
foreach ($order as $i => $id) {
$db->setQuery('UPDATE ' . $db->quoteName('#__mokosuiteclient_ticket_canned') . ' SET ordering = ' . (int) $i . ' WHERE id = ' . (int) $id)->execute();
}
$this->jsonResponse(['success' => true, 'message' => 'Order saved.']);
}
public function uploadAttachment()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); }
$input = Factory::getApplication()->getInput();
$ticketId = $input->getInt('ticket_id', 0);
$replyId = $input->getInt('reply_id', 0) ?: null;
if (!$ticketId) { $this->jsonResponse(['success' => false, 'message' => 'Missing ticket_id']); return; }
$files = $input->files->get('attachments', [], 'raw');
if (empty($files) || empty($files['name'])) { $this->jsonResponse(['success' => false, 'message' => 'No files uploaded']); return; }
$saved = \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::upload($ticketId, $replyId, $files);
$this->jsonResponse(['success' => true, 'message' => count($saved) . ' file(s) uploaded', 'count' => count($saved)]);
}
public function downloadAttachment()
{
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); }
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$db = Factory::getDbo();
$db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuiteclient_ticket_attachments')->where('id = ' . $id));
$att = $db->loadObject();
if (!$att) { throw new \RuntimeException('Attachment not found', 404); }
$path = \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::getAbsolutePath($att);
if (!file_exists($path)) { throw new \RuntimeException('File not found', 404); }
$app = Factory::getApplication();
$app->setHeader('Content-Type', $att->mimetype ?: 'application/octet-stream');
$app->setHeader('Content-Disposition', 'attachment; filename="' . $att->filename . '"');
$app->setHeader('Content-Length', (string) filesize($path));
$app->sendHeaders();
readfile($path);
$app->close();
}
public function deleteAttachment()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); }
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$ok = \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::delete($id);
$this->jsonResponse(['success' => $ok, 'message' => $ok ? 'Attachment deleted' : 'Not found']);
}
public function rateTicket()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
$input = Factory::getApplication()->getInput();
$ticketId = $input->getInt('ticket_id', 0);
$rating = $input->getInt('rating', 0);
$feedback = $input->getString('feedback', '');
if (!$ticketId || $rating < 1 || $rating > 5) {
$this->jsonResponse(['success' => false, 'message' => 'Invalid rating (1-5)']);
return;
}
$db = Factory::getDbo();
$db->setQuery(
'UPDATE ' . $db->quoteName('#__mokosuiteclient_tickets')
. ' SET satisfaction_rating = ' . $rating
. ', satisfaction_feedback = ' . $db->quote($feedback)
. ', satisfaction_rated_at = ' . $db->quote(Factory::getDate()->toSql())
. ' WHERE id = ' . $ticketId
)->execute();
$this->jsonResponse(['success' => true, 'message' => 'Thank you for your feedback!']);
}
public function saveAutomation() public function saveAutomation()
{ {
Session::checkToken() or die(Text::_('JINVALID_TOKEN')); Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
@@ -734,7 +564,6 @@ class DisplayController extends BaseController
'trigger_event' => $input->getString('trigger_event', 'ticket_created'), 'trigger_event' => $input->getString('trigger_event', 'ticket_created'),
'conditions' => $input->getRaw('conditions', '[]'), 'conditions' => $input->getRaw('conditions', '[]'),
'actions' => $input->getRaw('actions', '[]'), 'actions' => $input->getRaw('actions', '[]'),
'behavior' => $input->getString('behavior', 'append'),
'enabled' => 1, 'enabled' => 1,
'ordering' => 0, 'ordering' => 0,
]; ];
@@ -765,19 +594,6 @@ class DisplayController extends BaseController
$this->jsonResponse(['success' => true, 'message' => 'Rule updated.']); $this->jsonResponse(['success' => true, 'message' => 'Rule updated.']);
} }
public function reorderAutomation()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); }
$order = json_decode(Factory::getApplication()->getInput()->getRaw('order', '[]'), true);
if (!is_array($order)) { $this->jsonResponse(['success' => false, 'message' => 'Invalid order']); return; }
$db = Factory::getDbo();
foreach ($order as $i => $id) {
$db->setQuery('UPDATE ' . $db->quoteName('#__mokosuiteclient_ticket_automation') . ' SET ordering = ' . (int) $i . ' WHERE id = ' . (int) $id)->execute();
}
$this->jsonResponse(['success' => true, 'message' => 'Order saved.']);
}
// ================================================================== // ==================================================================
// Settings Import/Export (#132) // Settings Import/Export (#132)
// ================================================================== // ==================================================================
@@ -575,39 +575,6 @@ class TicketsModel extends BaseDatabaseModel
return $db->loadObjectList() ?: []; return $db->loadObjectList() ?: [];
} }
/**
* Get backend users for assignee selection.
*/
public function getBackendUsers(): array
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select(['u.id', 'u.name', 'u.username'])
->from($db->quoteName('#__users', 'u'))
->where($db->quoteName('u.block') . ' = 0')
->order($db->quoteName('u.name') . ' ASC')
);
return $db->loadObjectList() ?: [];
}
/**
* Get Joomla user groups for assignee selection.
*/
public function getUserGroups(): array
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select(['id', 'title'])
->from($db->quoteName('#__usergroups'))
->order($db->quoteName('title') . ' ASC')
);
return $db->loadObjectList() ?: [];
}
/** /**
* Get Joomla custom field groups assigned to a ticket category. * Get Joomla custom field groups assigned to a ticket category.
*/ */
@@ -1133,117 +1100,6 @@ class TicketsModel extends BaseDatabaseModel
return $db->loadObjectList() ?: []; return $db->loadObjectList() ?: [];
} }
// ==================================================================
// Status/Priority CRUD
// ==================================================================
public function saveStatus(array $data): array
{
$db = $this->getDatabase();
$obj = (object) $data;
if (!empty($obj->title) && empty($obj->alias))
{
$obj->alias = strtolower(preg_replace('/[^a-z0-9]+/i', '_', $obj->title));
}
if (empty($obj->id))
{
unset($obj->id);
$db->insertObject('#__mokosuiteclient_ticket_statuses', $obj, 'id');
return ['status' => 'ok', 'id' => (int) $obj->id, 'message' => 'Status created'];
}
$db->updateObject('#__mokosuiteclient_ticket_statuses', $obj, 'id');
return ['status' => 'ok', 'id' => (int) $obj->id, 'message' => 'Status updated'];
}
public function deleteStatus(int $id): array
{
if ($id < 1)
{
return ['status' => 'error', 'message' => 'Invalid ID'];
}
$db = $this->getDatabase();
// Check no tickets use this status
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokosuiteclient_tickets'))
->where($db->quoteName('status_id') . ' = ' . $id)
);
if ((int) $db->loadResult() > 0)
{
return ['status' => 'error', 'message' => 'Cannot delete — status is in use by tickets'];
}
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokosuiteclient_ticket_statuses'))
->where($db->quoteName('id') . ' = ' . $id)
)->execute();
return ['status' => 'ok', 'message' => 'Status deleted'];
}
public function savePriority(array $data): array
{
$db = $this->getDatabase();
$obj = (object) $data;
if (!empty($obj->title) && empty($obj->alias))
{
$obj->alias = strtolower(preg_replace('/[^a-z0-9]+/i', '_', $obj->title));
}
if (empty($obj->id))
{
unset($obj->id);
$db->insertObject('#__mokosuiteclient_ticket_priorities', $obj, 'id');
return ['status' => 'ok', 'id' => (int) $obj->id, 'message' => 'Priority created'];
}
$db->updateObject('#__mokosuiteclient_ticket_priorities', $obj, 'id');
return ['status' => 'ok', 'id' => (int) $obj->id, 'message' => 'Priority updated'];
}
public function deletePriority(int $id): array
{
if ($id < 1)
{
return ['status' => 'error', 'message' => 'Invalid ID'];
}
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokosuiteclient_tickets'))
->where($db->quoteName('priority_id') . ' = ' . $id)
);
if ((int) $db->loadResult() > 0)
{
return ['status' => 'error', 'message' => 'Cannot delete — priority is in use by tickets'];
}
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokosuiteclient_ticket_priorities'))
->where($db->quoteName('id') . ' = ' . $id)
)->execute();
return ['status' => 'ok', 'message' => 'Priority deleted'];
}
// ================================================================== // ==================================================================
// Akeeba Ticket System Importer // Akeeba Ticket System Importer
// ================================================================== // ==================================================================
@@ -1,177 +0,0 @@
<?php
/**
* @package MokoSuiteClient
* @subpackage com_mokosuiteclient
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteClient\Administrator\Service;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Filesystem\File;
use Joomla\CMS\Filesystem\Folder;
use Joomla\CMS\Log\Log;
class AttachmentService
{
private const STORAGE_DIR = JPATH_ROOT . '/media/com_mokosuiteclient/attachments';
private const ALLOWED_EXTENSIONS = [
'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg',
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'txt', 'rtf',
'zip', 'gz', 'tar',
];
private const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
/**
* Upload file(s) for a ticket or reply.
*
* @param int $ticketId Ticket ID
* @param int|null $replyId Reply ID (null for ticket-level attachments)
* @param array $files $_FILES array entry (single or multi)
* @return array Saved attachment records
*/
public static function upload(int $ticketId, ?int $replyId, array $files): array
{
$saved = [];
// Normalize single file to array format
if (!is_array($files['name'])) {
$files = [
'name' => [$files['name']],
'type' => [$files['type']],
'tmp_name' => [$files['tmp_name']],
'error' => [$files['error']],
'size' => [$files['size']],
];
}
$ticketDir = self::STORAGE_DIR . '/' . $ticketId;
if (!is_dir($ticketDir)) {
Folder::create($ticketDir);
}
$userId = (int) Factory::getUser()->id;
$db = Factory::getDbo();
for ($i = 0, $count = count($files['name']); $i < $count; $i++)
{
if ($files['error'][$i] !== UPLOAD_ERR_OK) {
continue;
}
$originalName = File::makeSafe($files['name'][$i]);
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
// Validate extension
if (!in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
Log::add("Attachment rejected: disallowed extension .{$ext}", Log::WARNING, 'mokosuiteclient');
continue;
}
// Validate size
if ($files['size'][$i] > self::MAX_FILE_SIZE) {
Log::add("Attachment rejected: file too large ({$files['size'][$i]} bytes)", Log::WARNING, 'mokosuiteclient');
continue;
}
// Generate unique filename to prevent overwrites
$storedName = uniqid('att_', true) . '.' . $ext;
$destPath = $ticketDir . '/' . $storedName;
if (!File::upload($files['tmp_name'][$i], $destPath)) {
Log::add("Attachment upload failed: {$originalName}", Log::ERROR, 'mokosuiteclient');
continue;
}
$record = (object) [
'ticket_id' => $ticketId,
'reply_id' => $replyId,
'filename' => $originalName,
'filepath' => $ticketId . '/' . $storedName,
'filesize' => $files['size'][$i],
'mimetype' => $files['type'][$i],
'uploaded_by' => $userId,
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuiteclient_ticket_attachments', $record, 'id');
$saved[] = $record;
}
return $saved;
}
/**
* Get attachments for a ticket.
*/
public static function getForTicket(int $ticketId): array
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select('a.*, u.name AS uploader_name')
->from($db->quoteName('#__mokosuiteclient_ticket_attachments', 'a'))
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = a.uploaded_by')
->where($db->quoteName('a.ticket_id') . ' = ' . $ticketId)
->order('a.created ASC')
);
return $db->loadObjectList() ?: [];
}
/**
* Get the absolute filesystem path for an attachment.
*/
public static function getAbsolutePath(object $attachment): string
{
return self::STORAGE_DIR . '/' . $attachment->filepath;
}
/**
* Delete an attachment (file + DB record).
*/
public static function delete(int $attachmentId): bool
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select('*')
->from('#__mokosuiteclient_ticket_attachments')
->where('id = ' . $attachmentId)
);
$att = $db->loadObject();
if (!$att) {
return false;
}
$path = self::STORAGE_DIR . '/' . $att->filepath;
if (file_exists($path)) {
File::delete($path);
}
$db->setQuery(
$db->getQuery(true)
->delete('#__mokosuiteclient_ticket_attachments')
->where('id = ' . $attachmentId)
)->execute();
return true;
}
/**
* Format file size for display.
*/
public static function formatSize(int $bytes): string
{
if ($bytes < 1024) return $bytes . ' B';
if ($bytes < 1048576) return round($bytes / 1024, 1) . ' KB';
return round($bytes / 1048576, 1) . ' MB';
}
}
@@ -1,241 +0,0 @@
<?php
/**
* @package MokoSuiteClient
* @subpackage com_mokosuiteclient
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteClient\Administrator\Service;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Log\Log;
/**
* Automation rule engine — evaluates trigger/condition/action rules.
*
* Called from event hooks (system plugin, task plugin) whenever
* a triggering event occurs. Loads matching rules, checks conditions,
* and executes actions.
*
* @since 02.35.00
*/
class AutomationEngine
{
/**
* Fire all matching rules for a given trigger event.
*
* @param string $triggerEvent Event name (ticket_created, user_login, etc.)
* @param array $context Context data (ticket object, user data, etc.)
*/
public static function fire(string $triggerEvent, array $context = []): void
{
try
{
$rules = self::getActiveRules($triggerEvent);
foreach ($rules as $rule)
{
$conditions = json_decode($rule->conditions, true) ?: [];
$actions = json_decode($rule->actions, true) ?: [];
if (self::evaluateConditions($conditions, $context))
{
self::executeActions($actions, $rule, $context);
}
}
}
catch (\Throwable $e)
{
Log::add('Automation engine error: ' . $e->getMessage(), Log::ERROR, 'mokosuiteclient');
}
}
/**
* Get active automation rules for a trigger event.
*/
private static function getActiveRules(string $event): array
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select('*')
->from('#__mokosuiteclient_ticket_automation')
->where($db->quoteName('trigger_event') . ' = ' . $db->quote($event))
->where($db->quoteName('enabled') . ' = 1')
->order('ordering ASC')
);
return $db->loadObjectList() ?: [];
}
/**
* Evaluate all conditions (AND logic).
*/
private static function evaluateConditions(array $conditions, array $context): bool
{
foreach ($conditions as $c)
{
$field = $c['field'] ?? '';
$op = $c['op'] ?? 'eq';
$expected = $c['value'] ?? '';
$actual = $context[$field] ?? '';
switch ($op)
{
case 'eq': if ((string) $actual !== (string) $expected) return false; break;
case 'neq': if ((string) $actual === (string) $expected) return false; break;
case 'gt': if ((float) $actual <= (float) $expected) return false; break;
case 'lt': if ((float) $actual >= (float) $expected) return false; break;
case 'in':
$values = array_map('trim', explode(',', $expected));
if (!in_array((string) $actual, $values, true)) return false;
break;
case 'not_in':
$values = array_map('trim', explode(',', $expected));
if (in_array((string) $actual, $values, true)) return false;
break;
}
}
return true;
}
/**
* Execute actions for a matched rule.
*/
private static function executeActions(array $actions, object $rule, array $context): void
{
$db = Factory::getDbo();
$ticketId = (int) ($context['ticket_id'] ?? $context['id'] ?? 0);
foreach ($actions as $action)
{
$type = $action['type'] ?? '';
$value = $action['value'] ?? '';
try
{
switch ($type)
{
case 'set_status':
if ($ticketId) {
$db->setQuery("UPDATE {$db->quoteName('#__mokosuiteclient_tickets')} SET status = {$db->quote($value)}, modified = {$db->quote(Factory::getDate()->toSql())} WHERE id = {$ticketId}")->execute();
}
break;
case 'set_priority':
if ($ticketId) {
$db->setQuery("UPDATE {$db->quoteName('#__mokosuiteclient_tickets')} SET priority = {$db->quote($value)}, modified = {$db->quote(Factory::getDate()->toSql())} WHERE id = {$ticketId}")->execute();
}
break;
case 'assign':
if ($ticketId) {
$db->setQuery("UPDATE {$db->quoteName('#__mokosuiteclient_tickets')} SET assigned_to = {$db->quote($value)}, modified = {$db->quote(Factory::getDate()->toSql())} WHERE id = {$ticketId}")->execute();
}
break;
case 'add_note':
if ($ticketId) {
$note = (object) [
'ticket_id' => $ticketId,
'user_id' => 0,
'body' => $value ?: '[Automation: ' . ($rule->title ?? '') . ']',
'is_internal' => 1,
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuiteclient_ticket_replies', $note);
}
break;
case 'send_email':
NotificationService::securityAlert(
'automation',
'Automation: ' . ($rule->title ?? ''),
$value ?: 'Rule triggered for ticket #' . $ticketId
);
break;
case 'send_ntfy':
NotificationService::pushNtfySecurity(
'automation',
'Automation: ' . ($rule->title ?? ''),
$value ?: 'Rule triggered for ticket #' . $ticketId
);
break;
case 'close':
if ($ticketId) {
$db->setQuery("UPDATE {$db->quoteName('#__mokosuiteclient_tickets')} SET status = 'closed', closed = {$db->quote(Factory::getDate()->toSql())}, modified = {$db->quote(Factory::getDate()->toSql())} WHERE id = {$ticketId}")->execute();
}
break;
case 'create_ticket':
self::createTicketFromAutomation($rule, $context, $value);
break;
}
}
catch (\Throwable $e)
{
Log::add("Automation action {$type} failed: " . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
}
}
}
/**
* Create a ticket from automation (with behavior: append/always_new/skip_if_open).
*/
private static function createTicketFromAutomation(object $rule, array $context, string $subject): void
{
$db = Factory::getDbo();
$behavior = $rule->behavior ?? 'append';
$userId = (int) ($context['user_id'] ?? 0);
$catId = (int) ($context['category_id'] ?? 0);
if ($behavior !== 'always_new' && $userId > 0)
{
// Check for existing open ticket
$query = $db->getQuery(true)
->select('id')
->from('#__mokosuiteclient_tickets')
->where('created_by = ' . $userId)
->where("status NOT IN ('closed', 'resolved')");
if ($catId > 0) {
$query->where('category_id = ' . $catId);
}
$db->setQuery($query, 0, 1);
$existingId = (int) $db->loadResult();
if ($existingId > 0)
{
if ($behavior === 'skip_if_open') return;
// append — add reply to existing ticket
$reply = (object) [
'ticket_id' => $existingId,
'user_id' => 0,
'body' => $subject ?: '[Automation: ' . ($rule->title ?? '') . ']',
'is_internal' => 1,
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuiteclient_ticket_replies', $reply);
return;
}
}
// Create new ticket
$ticket = (object) [
'subject' => $subject ?: 'Automation: ' . ($rule->title ?? ''),
'body' => $context['body'] ?? '',
'status' => 'open',
'priority' => $context['priority'] ?? 'normal',
'category_id' => $catId ?: null,
'created_by' => $userId,
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuiteclient_tickets', $ticket, 'id');
}
}
@@ -70,9 +70,6 @@ class NotificationService
Log::add('Notification send failed to ' . $email . ': ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); Log::add('Notification send failed to ' . $email . ': ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
} }
} }
// Push notification via ntfy
self::pushNtfy($event, $ticket, $subject);
} }
catch (\Throwable $e) catch (\Throwable $e)
{ {
@@ -335,159 +332,6 @@ class NotificationService
} }
} }
// ==================================================================
// Ntfy Push Notifications (#205)
// ==================================================================
/**
* Send a push notification via ntfy for ticket events.
*/
private static function pushNtfy(string $event, object $ticket, string $title): void
{
$config = self::getNotificationConfig();
$ntfyEnabled = $config['ntfy_enabled'] ?? '0';
if (!$ntfyEnabled)
{
return;
}
$ntfyServer = rtrim($config['ntfy_server'] ?? 'https://ntfy.mokoconsulting.tech', '/');
$ntfyTopic = $config['ntfy_topic'] ?? 'mokosuiteclient-tickets';
$ntfyToken = $config['ntfy_token'] ?? '';
$tagMap = [
'ticket_created' => 'ticket,new',
'ticket_replied' => 'speech_balloon',
'status_changed' => 'arrows_counterclockwise',
'ticket_assigned' => 'bust_in_silhouette',
];
$priorityMap = [
'ticket_created' => '4',
'ticket_replied' => '3',
'status_changed' => '3',
'ticket_assigned' => '3',
];
$siteUrl = rtrim(Uri::root(), '/');
$ticketUrl = $siteUrl . '/administrator/index.php?option=com_mokosuiteclient&view=ticket&id=' . ($ticket->id ?? 0);
$message = self::buildNtfyMessage($event, $ticket);
$headers = [
'Title: ' . $title,
'Priority: ' . ($priorityMap[$event] ?? '3'),
'Tags: ' . ($tagMap[$event] ?? 'ticket'),
'Click: ' . $ticketUrl,
];
if ($ntfyToken !== '')
{
$headers[] = 'Authorization: Bearer ' . $ntfyToken;
}
$url = $ntfyServer . '/' . $ntfyTopic;
try
{
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $message);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode < 200 || $httpCode >= 300)
{
Log::add("Ntfy push failed (HTTP {$httpCode}) for event {$event}", Log::WARNING, 'mokosuiteclient');
}
}
catch (\Throwable $e)
{
Log::add('Ntfy push error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
}
}
/**
* Build a short ntfy message body for ticket events.
*/
private static function buildNtfyMessage(string $event, object $ticket): string
{
$subject = $ticket->subject ?? 'Ticket #' . ($ticket->id ?? '?');
switch ($event)
{
case 'ticket_created':
$priority = ucfirst($ticket->priority ?? 'normal');
return "New ticket: {$subject}\nPriority: {$priority}";
case 'ticket_replied':
return "Reply on: {$subject}";
case 'status_changed':
$status = ucwords(str_replace('_', ' ', $ticket->status ?? ''));
return "Status → {$status}: {$subject}";
case 'ticket_assigned':
return "Assigned to you: {$subject}";
default:
return $subject;
}
}
/**
* Send a push notification via ntfy for security events.
*/
public static function pushNtfySecurity(string $event, string $title, string $body): void
{
$config = self::getNotificationConfig();
$ntfyEnabled = $config['ntfy_enabled'] ?? '0';
if (!$ntfyEnabled)
{
return;
}
$ntfyServer = rtrim($config['ntfy_server'] ?? 'https://ntfy.mokoconsulting.tech', '/');
$ntfyTopic = $config['ntfy_security_topic'] ?? $config['ntfy_topic'] ?? 'mokosuiteclient-security';
$ntfyToken = $config['ntfy_token'] ?? '';
$headers = [
'Title: [Security] ' . $title,
'Priority: 5',
'Tags: warning,shield',
];
if ($ntfyToken !== '')
{
$headers[] = 'Authorization: Bearer ' . $ntfyToken;
}
$url = $ntfyServer . '/' . $ntfyTopic;
try
{
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
curl_exec($ch);
curl_close($ch);
}
catch (\Throwable $e)
{
Log::add('Ntfy security push error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
}
}
// ================================================================== // ==================================================================
// Security Event Notifications (#131) // Security Event Notifications (#131)
// ================================================================== // ==================================================================
@@ -563,9 +407,6 @@ class NotificationService
Log::add('Security alert send failed: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); Log::add('Security alert send failed: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
} }
} }
// Also push via ntfy
self::pushNtfySecurity($event, $subject, $body);
} }
catch (\Throwable $e) catch (\Throwable $e)
{ {
@@ -23,7 +23,6 @@ class HtmlView extends BaseHtmlView
protected $priorities = []; protected $priorities = [];
protected $customFields = []; protected $customFields = [];
protected $fieldValues = []; protected $fieldValues = [];
protected $attachments = [];
public function display($tpl = null) public function display($tpl = null)
{ {
@@ -44,9 +43,6 @@ class HtmlView extends BaseHtmlView
$this->fieldValues = $model->getFieldValues($id); $this->fieldValues = $model->getFieldValues($id);
} }
// Load attachments
$this->attachments = \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::getForTicket($id);
if (!$this->ticket) if (!$this->ticket)
{ {
Factory::getApplication()->enqueueMessage('Ticket not found.', 'error'); Factory::getApplication()->enqueueMessage('Ticket not found.', 'error');
@@ -25,8 +25,6 @@ class HtmlView extends BaseHtmlView
protected $contacts = []; protected $contacts = [];
protected $statuses = []; protected $statuses = [];
protected $priorities = []; protected $priorities = [];
protected $backendUsers = [];
protected $userGroups = [];
public function display($tpl = null) public function display($tpl = null)
{ {
@@ -48,8 +46,6 @@ class HtmlView extends BaseHtmlView
$this->overdue = $model->getOverdueTickets(); $this->overdue = $model->getOverdueTickets();
$this->atsAvailable = $model->checkAtsAvailable(); $this->atsAvailable = $model->checkAtsAvailable();
$this->contacts = $model->getContacts(); $this->contacts = $model->getContacts();
$this->backendUsers = $model->getBackendUsers();
$this->userGroups = $model->getUserGroups();
$this->addToolbar(); $this->addToolbar();
@@ -1,41 +0,0 @@
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later
*
* @package MokoSuiteClient
* @subpackage Component
*/
namespace Moko\Component\MokoSuiteClient\Administrator\View\Ticketsettings;
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $statuses = [];
protected $priorities = [];
public function display($tpl = null)
{
$model = $this->getModel('Tickets');
$this->statuses = $model->getStatuses();
$this->priorities = $model->getPriorities();
$this->addToolbar();
parent::display($tpl);
}
protected function addToolbar(): void
{
ToolbarHelper::title(Text::_('COM_MOKOSUITE_TICKET_SETTINGS'), 'cog');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient&view=tickets');
}
}
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -9,62 +9,43 @@ $token = Session::getFormToken();
$saveUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.saveAutomation&format=json'); $saveUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.saveAutomation&format=json');
$deleteUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.deleteAutomation&format=json'); $deleteUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.deleteAutomation&format=json');
$toggleUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.toggleAutomation&format=json'); $toggleUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.toggleAutomation&format=json');
$reorderUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.reorderAutomation&format=json');
$triggerLabels = [ $triggerLabels = ['ticket_created' => 'On Ticket Created', 'ticket_replied' => 'On Reply', 'status_changed' => 'On Status Change', 'scheduled' => 'Scheduled (Cron)'];
'ticket_created' => 'On Ticket Created',
'ticket_replied' => 'On Reply',
'status_changed' => 'On Status Change',
'ticket_assigned' => 'On Assignment',
'user_login' => 'On User Login',
'user_register' => 'On User Register',
'user_login_failed' => 'On Failed Login',
'content_save' => 'On Article Save',
'extension_install' => 'On Extension Install',
'scheduled' => 'Scheduled (Cron)',
];
$conditionFields = ['status', 'priority', 'category_id', 'assigned_to', 'sla_responded', 'age_hours'];
$conditionOps = ['eq' => '=', 'neq' => '≠', 'gt' => '>', 'lt' => '<', 'in' => 'in', 'not_in' => 'not in'];
$actionTypes = ['set_status', 'set_priority', 'assign', 'add_note', 'send_email', 'send_ntfy', 'close'];
?> ?>
<div id="mokosuiteclient-automation"> <div id="mokosuiteclient-automation">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<h4><?php echo count($rules); ?> Automation Rules</h4> <h4><?php echo count($rules); ?> Automation Rules</h4>
<button type="button" class="btn btn-primary btn-sm" onclick="openRuleModal(0)"> <button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newRuleModal">
<span class="icon-plus"></span> Add Rule <span class="icon-plus"></span> Add Rule
</button> </button>
</div> </div>
<div id="rules-list">
<?php foreach ($rules as $r): ?> <?php foreach ($rules as $r): ?>
<?php $conditions = json_decode($r->conditions, true) ?: []; $actions = json_decode($r->actions, true) ?: []; ?> <?php $conditions = json_decode($r->conditions, true) ?: []; $actions = json_decode($r->actions, true) ?: []; ?>
<div class="card mb-2 rule-card <?php echo !$r->enabled ? 'opacity-50' : ''; ?>" data-id="<?php echo $r->id; ?>" draggable="true"> <div class="card mb-2 <?php echo !$r->enabled ? 'opacity-50' : ''; ?>" data-id="<?php echo $r->id; ?>">
<div class="card-body py-2"> <div class="card-body py-2">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1" style="cursor:pointer;" onclick="openRuleModal(<?php echo $r->id; ?>)"> <div>
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<span class="icon-menu text-muted" style="cursor:grab;"></span> <div class="form-check form-switch">
<div class="form-check form-switch" onclick="event.stopPropagation();">
<input type="checkbox" class="form-check-input rule-toggle" data-id="<?php echo $r->id; ?>" <?php echo $r->enabled ? 'checked' : ''; ?>> <input type="checkbox" class="form-check-input rule-toggle" data-id="<?php echo $r->id; ?>" <?php echo $r->enabled ? 'checked' : ''; ?>>
</div> </div>
<strong><?php echo htmlspecialchars($r->title); ?></strong> <strong><?php echo htmlspecialchars($r->title); ?></strong>
<span class="badge bg-secondary"><?php echo $triggerLabels[$r->trigger_event] ?? $r->trigger_event; ?></span> <span class="badge bg-secondary"><?php echo $triggerLabels[$r->trigger_event] ?? $r->trigger_event; ?></span>
</div> </div>
<div class="small text-muted mt-1 ms-4"> <div class="small text-muted mt-1">
<?php if (!empty($conditions)): ?>
<span class="text-primary">IF</span> <span class="text-primary">IF</span>
<?php foreach ($conditions as $i => $c): ?> <?php foreach ($conditions as $i => $c): ?>
<?php echo $i > 0 ? ' AND ' : ''; ?><code><?php echo htmlspecialchars($c['field'] ?? ''); ?></code> <?php echo $conditionOps[$c['op'] ?? ''] ?? $c['op'] ?? ''; ?> <em><?php echo htmlspecialchars($c['value'] ?? ''); ?></em> <?php echo $i > 0 ? ' AND ' : ''; ?><?php echo htmlspecialchars($c['field'] ?? ''); ?> <?php echo htmlspecialchars($c['op'] ?? ''); ?> <?php echo htmlspecialchars($c['value'] ?? ''); ?>
<?php endforeach; ?> <?php endforeach; ?>
<?php endif; ?> <span class="text-success ms-2">THEN</span>
<span class="text-success ms-1">THEN</span>
<?php foreach ($actions as $a): ?> <?php foreach ($actions as $a): ?>
<code><?php echo htmlspecialchars($a['type'] ?? ''); ?></code><?php if (!empty($a['value'])): ?>=<em><?php echo htmlspecialchars(mb_substr($a['value'], 0, 30)); ?></em><?php endif; ?> <?php echo htmlspecialchars($a['type'] ?? ''); ?>=<?php echo htmlspecialchars(mb_substr($a['value'] ?? '', 0, 30)); ?>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
</div> </div>
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-rule" data-id="<?php echo $r->id; ?>" onclick="event.stopPropagation();"> <button type="button" class="btn btn-sm btn-outline-danger btn-delete-rule" data-id="<?php echo $r->id; ?>">
<span class="icon-trash"></span> <span class="icon-trash"></span>
</button> </button>
</div> </div>
@@ -75,44 +56,34 @@ $actionTypes = ['set_status', 'set_priority', 'assign', 'add_note', 'send_email'
<?php if (empty($rules)): ?> <?php if (empty($rules)): ?>
<div class="alert alert-info">No automation rules. Click "Add Rule" to create one.</div> <div class="alert alert-info">No automation rules. Click "Add Rule" to create one.</div>
<?php endif; ?> <?php endif; ?>
</div>
</div> </div>
<!-- Rule Modal --> <!-- New Rule Modal -->
<div class="modal fade" id="ruleModal" tabindex="-1"> <div class="modal fade" id="newRuleModal" tabindex="-1">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"><h5 id="ruleModalTitle">Add Automation Rule</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div> <div class="modal-header"><h5>Add Automation Rule</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body"> <div class="modal-body">
<input type="hidden" id="rule-id" value="0"> <div class="mb-3">
<div class="row mb-3">
<div class="col-5">
<label class="form-label">Title</label> <label class="form-label">Title</label>
<input type="text" id="rule-title" class="form-control" required> <input type="text" id="rule-title" class="form-control" required>
</div> </div>
<div class="col-4"> <div class="mb-3">
<label class="form-label">Trigger</label> <label class="form-label">Trigger</label>
<select id="rule-trigger" class="form-select"> <select id="rule-trigger" class="form-select">
<?php foreach ($triggerLabels as $k => $v): ?><option value="<?php echo $k; ?>"><?php echo $v; ?></option><?php endforeach; ?> <?php foreach ($triggerLabels as $k => $v): ?><option value="<?php echo $k; ?>"><?php echo $v; ?></option><?php endforeach; ?>
</select> </select>
</div> </div>
<div class="col-3"> <div class="mb-3">
<label class="form-label">Behavior</label> <label class="form-label">Conditions (JSON)</label>
<select id="rule-behavior" class="form-select"> <textarea id="rule-conditions" class="form-control font-monospace" rows="3" placeholder='[{"field":"status","op":"eq","value":"resolved"}]'></textarea>
<option value="append">Append to existing</option> <small class="text-muted">Fields: status, priority, category_id, assigned_to, sla_responded, age_hours. Ops: eq, neq, gt, lt, in, not_in</small>
<option value="always_new">Always new ticket</option>
<option value="skip_if_open">Skip if open</option>
</select>
</div> </div>
<div class="mb-3">
<label class="form-label">Actions (JSON)</label>
<textarea id="rule-actions" class="form-control font-monospace" rows="3" placeholder='[{"type":"set_status","value":"closed"}]'></textarea>
<small class="text-muted">Types: set_status, set_priority, assign, add_note, send_email</small>
</div> </div>
<label class="form-label">Conditions <small class="text-muted">(all must match)</small></label>
<div id="conditions-builder" class="mb-3"></div>
<button type="button" class="btn btn-sm btn-outline-secondary mb-3" onclick="addConditionRow()"><span class="icon-plus"></span> Add Condition</button>
<label class="form-label">Actions</label>
<div id="actions-builder" class="mb-3"></div>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="addActionRow()"><span class="icon-plus"></span> Add Action</button>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
@@ -124,174 +95,47 @@ $actionTypes = ['set_status', 'set_priority', 'assign', 'add_note', 'send_email'
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
var tokenKey = '<?php echo $token; ?>'; var token = '<?php echo $token; ?>';
var condFields = <?php echo json_encode($conditionFields); ?>;
var condOps = <?php echo json_encode($conditionOps); ?>;
var actTypes = <?php echo json_encode($actionTypes); ?>;
// Rule data store for editing // Save new rule
var ruleData = {};
<?php foreach ($rules as $r): ?>
ruleData[<?php echo $r->id; ?>] = {
title: <?php echo json_encode($r->title); ?>,
trigger_event: <?php echo json_encode($r->trigger_event); ?>,
behavior: <?php echo json_encode($r->behavior ?? 'append'); ?>,
conditions: <?php echo $r->conditions ?: '[]'; ?>,
actions: <?php echo $r->actions ?: '[]'; ?>
};
<?php endforeach; ?>
// ── Builder helpers ─────────────────────────────────────────
function makeSelect(cls, options, selected) {
var sel = document.createElement('select');
sel.className = 'form-select ' + cls;
options.forEach(function(o) {
var opt = document.createElement('option');
opt.value = o.value;
opt.textContent = o.label;
if (o.value === selected) opt.selected = true;
sel.appendChild(opt);
});
return sel;
}
function makeRemoveBtn() {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-outline-danger';
btn.innerHTML = '<span class="icon-minus"></span>';
btn.addEventListener('click', function() { this.parentNode.remove(); });
return btn;
}
window.addConditionRow = function(field, op, value) {
var div = document.createElement('div');
div.className = 'input-group input-group-sm mb-1';
div.appendChild(makeSelect('cond-field', condFields.map(function(f){return {value:f, label:f}}), field));
div.appendChild(makeSelect('cond-op', Object.keys(condOps).map(function(k){return {value:k, label:condOps[k]}}), op));
var inp = document.createElement('input');
inp.type = 'text'; inp.className = 'form-control cond-value'; inp.placeholder = 'value'; inp.value = value || '';
div.appendChild(inp);
div.appendChild(makeRemoveBtn());
document.getElementById('conditions-builder').appendChild(div);
};
window.addActionRow = function(type, value) {
var div = document.createElement('div');
div.className = 'input-group input-group-sm mb-1';
div.appendChild(makeSelect('act-type', actTypes.map(function(t){return {value:t, label:t}}), type));
var inp = document.createElement('input');
inp.type = 'text'; inp.className = 'form-control act-value'; inp.placeholder = 'value'; inp.value = value || '';
div.appendChild(inp);
div.appendChild(makeRemoveBtn());
document.getElementById('actions-builder').appendChild(div);
};
// ── Open modal ──────────────────────────────────────────────
window.openRuleModal = function(id) {
document.getElementById('rule-id').value = id;
document.getElementById('conditions-builder').innerHTML = '';
document.getElementById('actions-builder').innerHTML = '';
if (id > 0 && ruleData[id]) {
document.getElementById('ruleModalTitle').textContent = 'Edit Automation Rule';
document.getElementById('rule-title').value = ruleData[id].title;
document.getElementById('rule-trigger').value = ruleData[id].trigger_event;
document.getElementById('rule-behavior').value = ruleData[id].behavior || 'append';
ruleData[id].conditions.forEach(function(c) { addConditionRow(c.field, c.op, c.value); });
ruleData[id].actions.forEach(function(a) { addActionRow(a.type, a.value); });
} else {
document.getElementById('ruleModalTitle').textContent = 'Add Automation Rule';
document.getElementById('rule-title').value = '';
document.getElementById('rule-trigger').value = 'ticket_created';
document.getElementById('rule-behavior').value = 'append';
addConditionRow();
addActionRow();
}
new bootstrap.Modal(document.getElementById('ruleModal')).show();
};
// ── Save rule ───────────────────────────────────────────────
document.getElementById('btn-save-rule').addEventListener('click', function() { document.getElementById('btn-save-rule').addEventListener('click', function() {
var conditions = [];
document.querySelectorAll('#conditions-builder .input-group').forEach(function(row) {
var f = row.querySelector('.cond-field').value;
var o = row.querySelector('.cond-op').value;
var v = row.querySelector('.cond-value').value;
if (f && v) conditions.push({field:f, op:o, value:v});
});
var actions = [];
document.querySelectorAll('#actions-builder .input-group').forEach(function(row) {
var t = row.querySelector('.act-type').value;
var v = row.querySelector('.act-value').value;
if (t) actions.push({type:t, value:v});
});
var fd = new FormData(); var fd = new FormData();
fd.append('id', document.getElementById('rule-id').value); fd.append('id', '0');
fd.append('title', document.getElementById('rule-title').value); fd.append('title', document.getElementById('rule-title').value);
fd.append('trigger_event', document.getElementById('rule-trigger').value); fd.append('trigger_event', document.getElementById('rule-trigger').value);
fd.append('behavior', document.getElementById('rule-behavior').value); fd.append('conditions', document.getElementById('rule-conditions').value || '[]');
fd.append('conditions', JSON.stringify(conditions)); fd.append('actions', document.getElementById('rule-actions').value || '[]');
fd.append('actions', JSON.stringify(actions)); fd.append(token, '1');
fd.append(tokenKey, '1');
fetch('<?php echo $saveUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}}) fetch('<?php echo $saveUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()}) .then(function(r){return r.json()})
.then(function(d){ if (d.success) location.reload(); else Joomla.renderMessages({error:[d.message]}); }); .then(function(d){ if (d.success) location.reload(); else Joomla.renderMessages({error:[d.message]}); });
}); });
// ── Toggle ────────────────────────────────────────────────── // Toggle rule
document.querySelectorAll('.rule-toggle').forEach(function(cb) { document.querySelectorAll('.rule-toggle').forEach(function(cb) {
cb.addEventListener('change', function() { cb.addEventListener('change', function() {
var fd = new FormData(); var fd = new FormData();
fd.append('id', this.dataset.id); fd.append('id', this.dataset.id);
fd.append('enabled', this.checked ? '1' : '0'); fd.append('enabled', this.checked ? '1' : '0');
fd.append(tokenKey, '1'); fd.append(token, '1');
fetch('<?php echo $toggleUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}}) fetch('<?php echo $toggleUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()}) .then(function(r){return r.json()})
.then(function(d){ if (!d.success) Joomla.renderMessages({error:[d.message]}); else this.closest('.card').classList.toggle('opacity-50', !this.checked); }.bind(this)); .then(function(d){ if (!d.success) Joomla.renderMessages({error:[d.message]}); });
}); });
}); });
// ── Delete ────────────────────────────────────────────────── // Delete rule
document.querySelectorAll('.btn-delete-rule').forEach(function(btn) { document.querySelectorAll('.btn-delete-rule').forEach(function(btn) {
btn.addEventListener('click', function() { btn.addEventListener('click', function() {
if (!confirm('Delete this rule?')) return; if (!confirm('Delete this rule?')) return;
var card = this.closest('.card'); var card = this.closest('.card');
var fd = new FormData(); var fd = new FormData();
fd.append('id', this.dataset.id); fd.append('id', this.dataset.id);
fd.append(tokenKey, '1'); fd.append(token, '1');
fetch('<?php echo $deleteUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}}) fetch('<?php echo $deleteUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()}) .then(function(r){return r.json()})
.then(function(d){ if (d.success) card.remove(); else Joomla.renderMessages({error:[d.message]}); }); .then(function(d){ if (d.success) card.remove(); else Joomla.renderMessages({error:[d.message]}); });
}); });
}); });
// ── Drag-and-drop reorder ───────────────────────────────────
var list = document.getElementById('rules-list');
var dragCard = null;
list.addEventListener('dragstart', function(e) {
dragCard = e.target.closest('.rule-card');
if (dragCard) dragCard.style.opacity = '0.5';
});
list.addEventListener('dragend', function() { if (dragCard) dragCard.style.opacity = ''; dragCard = null; });
list.addEventListener('dragover', function(e) {
e.preventDefault();
var target = e.target.closest('.rule-card');
if (target && target !== dragCard) {
var rect = target.getBoundingClientRect();
if ((e.clientY - rect.top) > rect.height / 2) target.parentNode.insertBefore(dragCard, target.nextSibling);
else target.parentNode.insertBefore(dragCard, target);
}
});
list.addEventListener('drop', function(e) {
e.preventDefault();
var ids = [];
document.querySelectorAll('.rule-card').forEach(function(c) { ids.push(c.dataset.id); });
var fd = new FormData();
fd.append('order', JSON.stringify(ids));
fd.append(tokenKey, '1');
fetch('<?php echo $reorderUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}});
});
}); });
</script> </script>
@@ -9,46 +9,23 @@ $categories = $this->categories;
$token = Session::getFormToken(); $token = Session::getFormToken();
$saveUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.saveCanned&format=json'); $saveUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.saveCanned&format=json');
$deleteUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.deleteCanned&format=json'); $deleteUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.deleteCanned&format=json');
$reorderUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.reorderCanned&format=json');
// Build category map for filter display
$catMap = [0 => 'All Categories'];
foreach ($categories as $cat)
{
$catMap[$cat->id] = $cat->title;
}
?> ?>
<div id="mokosuiteclient-canned"> <div id="mokosuiteclient-canned">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex align-items-center gap-3"> <h4><?php echo count($responses); ?> Canned Responses</h4>
<h4 class="mb-0"><?php echo count($responses); ?> Canned Responses</h4> <button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newCannedModal">
<select id="canned-filter-category" class="form-select form-select-sm" style="width:auto;">
<option value="">All Categories</option>
<?php foreach ($categories as $cat): ?>
<option value="<?php echo $cat->id; ?>"><?php echo htmlspecialchars($cat->title); ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="button" class="btn btn-primary btn-sm" onclick="openCannedModal(0)">
<span class="icon-plus"></span> Add Response <span class="icon-plus"></span> Add Response
</button> </button>
</div> </div>
<div id="canned-list">
<?php foreach ($responses as $r): ?> <?php foreach ($responses as $r): ?>
<div class="card mb-2 canned-card" data-id="<?php echo $r->id; ?>" data-category="<?php echo (int) $r->category_id; ?>" style="cursor:grab;"> <div class="card mb-2" data-id="<?php echo $r->id; ?>">
<div class="card-body py-2"> <div class="card-body py-2">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1" style="cursor:pointer;" onclick="openCannedModal(<?php echo $r->id; ?>)"> <div>
<div class="d-flex align-items-center gap-2">
<span class="icon-menu text-muted" style="cursor:grab;" title="Drag to reorder"></span>
<strong><?php echo htmlspecialchars($r->title); ?></strong> <strong><?php echo htmlspecialchars($r->title); ?></strong>
<?php if (!empty($r->category_id) && isset($catMap[$r->category_id])): ?> <p class="text-muted small mb-0 mt-1"><?php echo htmlspecialchars(mb_substr($r->body, 0, 150)); ?></p>
<span class="badge bg-secondary"><?php echo htmlspecialchars($catMap[$r->category_id]); ?></span>
<?php endif; ?>
</div>
<p class="text-muted small mb-0 mt-1 ms-4"><?php echo htmlspecialchars(mb_substr(strip_tags($r->body), 0, 150)); ?></p>
</div> </div>
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-canned" data-id="<?php echo $r->id; ?>"> <button type="button" class="btn btn-sm btn-outline-danger btn-delete-canned" data-id="<?php echo $r->id; ?>">
<span class="icon-trash"></span> <span class="icon-trash"></span>
@@ -59,21 +36,16 @@ foreach ($categories as $cat)
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($responses)): ?> <?php if (empty($responses)): ?>
<div class="alert alert-info" id="canned-empty">No canned responses yet. Click "Add Response" to create one.</div> <div class="alert alert-info">No canned responses yet. Click "Add Response" to create one.</div>
<?php endif; ?> <?php endif; ?>
</div>
</div> </div>
<!-- Canned Response Modal (create + edit) --> <!-- New Canned Modal -->
<div class="modal fade" id="cannedModal" tabindex="-1"> <div class="modal fade" id="newCannedModal" tabindex="-1">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header"><h5>Add Canned Response</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<h5 id="cannedModalTitle">Add Canned Response</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body"> <div class="modal-body">
<input type="hidden" id="canned-id" value="0">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Title</label> <label class="form-label">Title</label>
<input type="text" id="canned-title" class="form-control" required> <input type="text" id="canned-title" class="form-control" required>
@@ -81,7 +53,7 @@ foreach ($categories as $cat)
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Category (optional)</label> <label class="form-label">Category (optional)</label>
<select id="canned-category" class="form-select"> <select id="canned-category" class="form-select">
<option value="">No category</option> <option value="">All categories</option>
<?php foreach ($categories as $cat): ?> <?php foreach ($categories as $cat): ?>
<option value="<?php echo $cat->id; ?>"><?php echo htmlspecialchars($cat->title); ?></option> <option value="<?php echo $cat->id; ?>"><?php echo htmlspecialchars($cat->title); ?></option>
<?php endforeach; ?> <?php endforeach; ?>
@@ -89,7 +61,7 @@ foreach ($categories as $cat)
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Response Text</label> <label class="form-label">Response Text</label>
<textarea id="canned-body" class="form-control" rows="8" required></textarea> <textarea id="canned-body" class="form-control" rows="6" required></textarea>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
@@ -102,46 +74,15 @@ foreach ($categories as $cat)
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
var tokenKey = '<?php echo $token; ?>'; var token = '<?php echo $token; ?>';
// ── Response data store (for edit modal) ────────────────────
var responseData = {};
<?php foreach ($responses as $r): ?>
responseData[<?php echo $r->id; ?>] = {
title: <?php echo json_encode($r->title); ?>,
body: <?php echo json_encode($r->body); ?>,
category_id: <?php echo json_encode($r->category_id ?? ''); ?>
};
<?php endforeach; ?>
// ── Open modal for create (id=0) or edit ────────────────────
window.openCannedModal = function(id) {
document.getElementById('canned-id').value = id;
if (id > 0 && responseData[id]) {
document.getElementById('cannedModalTitle').textContent = 'Edit Canned Response';
document.getElementById('canned-title').value = responseData[id].title;
document.getElementById('canned-body').value = responseData[id].body;
document.getElementById('canned-category').value = responseData[id].category_id || '';
} else {
document.getElementById('cannedModalTitle').textContent = 'Add Canned Response';
document.getElementById('canned-title').value = '';
document.getElementById('canned-body').value = '';
document.getElementById('canned-category').value = '';
}
new bootstrap.Modal(document.getElementById('cannedModal')).show();
};
// ── Save (create or update) ─────────────────────────────────
document.getElementById('btn-save-canned').addEventListener('click', function() { document.getElementById('btn-save-canned').addEventListener('click', function() {
var title = document.getElementById('canned-title').value.trim();
if (!title) { Joomla.renderMessages({error:['Title is required']}); return; }
var fd = new FormData(); var fd = new FormData();
fd.append('id', document.getElementById('canned-id').value); fd.append('id', '0');
fd.append('title', title); fd.append('title', document.getElementById('canned-title').value);
fd.append('body', document.getElementById('canned-body').value); fd.append('body', document.getElementById('canned-body').value);
fd.append('category_id', document.getElementById('canned-category').value); fd.append('category_id', document.getElementById('canned-category').value);
fd.append(tokenKey, '1'); fd.append(token, '1');
fetch('<?php echo $saveUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}}) fetch('<?php echo $saveUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()}) .then(function(r){return r.json()})
.then(function(d){ .then(function(d){
@@ -150,78 +91,17 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
}); });
// ── Delete ──────────────────────────────────────────────────
document.querySelectorAll('.btn-delete-canned').forEach(function(btn) { document.querySelectorAll('.btn-delete-canned').forEach(function(btn) {
btn.addEventListener('click', function(e) { btn.addEventListener('click', function() {
e.stopPropagation();
if (!confirm('Delete this canned response?')) return; if (!confirm('Delete this canned response?')) return;
var card = this.closest('.card'); var card = this.closest('.card');
var fd = new FormData(); var fd = new FormData();
fd.append('id', this.dataset.id); fd.append('id', this.dataset.id);
fd.append(tokenKey, '1'); fd.append(token, '1');
fetch('<?php echo $deleteUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}}) fetch('<?php echo $deleteUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()}) .then(function(r){return r.json()})
.then(function(d){ if (d.success) card.remove(); else Joomla.renderMessages({error:[d.message]}); }); .then(function(d){ if (d.success) card.remove(); else Joomla.renderMessages({error:[d.message]}); });
}); });
}); });
// ── Category filter ─────────────────────────────────────────
document.getElementById('canned-filter-category').addEventListener('change', function() {
var catId = this.value;
document.querySelectorAll('.canned-card').forEach(function(card) {
if (!catId || card.dataset.category === catId) {
card.style.display = '';
} else {
card.style.display = 'none';
}
});
});
// ── Drag-and-drop reorder ───────────────────────────────────
var list = document.getElementById('canned-list');
var dragCard = null;
list.addEventListener('dragstart', function(e) {
dragCard = e.target.closest('.canned-card');
if (dragCard) {
dragCard.style.opacity = '0.5';
e.dataTransfer.effectAllowed = 'move';
}
});
list.addEventListener('dragend', function() {
if (dragCard) dragCard.style.opacity = '';
dragCard = null;
});
list.addEventListener('dragover', function(e) {
e.preventDefault();
var target = e.target.closest('.canned-card');
if (target && target !== dragCard) {
var rect = target.getBoundingClientRect();
var after = (e.clientY - rect.top) > rect.height / 2;
if (after) {
target.parentNode.insertBefore(dragCard, target.nextSibling);
} else {
target.parentNode.insertBefore(dragCard, target);
}
}
});
list.addEventListener('drop', function(e) {
e.preventDefault();
// Persist new order
var ids = [];
document.querySelectorAll('.canned-card').forEach(function(c) { ids.push(c.dataset.id); });
var fd = new FormData();
fd.append('order', JSON.stringify(ids));
fd.append(tokenKey, '1');
fetch('<?php echo $reorderUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}});
});
// Make cards draggable
document.querySelectorAll('.canned-card').forEach(function(card) {
card.setAttribute('draggable', 'true');
});
}); });
</script> </script>
@@ -9,7 +9,6 @@ $users = $this->users;
$token = Session::getFormToken(); $token = Session::getFormToken();
$saveUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.saveCategory&format=json'); $saveUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.saveCategory&format=json');
$deleteUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.deleteCategory&format=json'); $deleteUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.deleteCategory&format=json');
$reorderUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.reorderCategory&format=json');
?> ?>
<div id="mokosuiteclient-categories"> <div id="mokosuiteclient-categories">
@@ -23,11 +22,10 @@ $reorderUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.reorde
<div class="card"> <div class="card">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped mb-0" id="cat-table"> <table class="table table-striped mb-0" id="cat-table">
<thead><tr><th style="width:30px"></th><th>Title</th><th>SLA Response</th><th>SLA Resolution</th><th>Auto-Assign</th><th>Active</th><th></th></tr></thead> <thead><tr><th>Title</th><th>SLA Response</th><th>SLA Resolution</th><th>Auto-Assign</th><th>Active</th><th></th></tr></thead>
<tbody> <tbody>
<?php foreach ($categories as $c): ?> <?php foreach ($categories as $c): ?>
<tr data-id="<?php echo $c->id; ?>" draggable="true"> <tr data-id="<?php echo $c->id; ?>">
<td><span class="icon-menu text-muted" style="cursor:grab;"></span></td>
<td><input type="text" class="form-control form-control-sm cat-field" data-field="title" value="<?php echo htmlspecialchars($c->title); ?>"></td> <td><input type="text" class="form-control form-control-sm cat-field" data-field="title" value="<?php echo htmlspecialchars($c->title); ?>"></td>
<td><input type="number" class="form-control form-control-sm cat-field" data-field="sla_response_minutes" value="<?php echo $c->sla_response_minutes; ?>" style="width:80px"> min</td> <td><input type="number" class="form-control form-control-sm cat-field" data-field="sla_response_minutes" value="<?php echo $c->sla_response_minutes; ?>" style="width:80px"> min</td>
<td><input type="number" class="form-control form-control-sm cat-field" data-field="sla_resolution_minutes" value="<?php echo $c->sla_resolution_minutes; ?>" style="width:80px"> min</td> <td><input type="number" class="form-control form-control-sm cat-field" data-field="sla_resolution_minutes" value="<?php echo $c->sla_resolution_minutes; ?>" style="width:80px"> min</td>
@@ -124,39 +122,5 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
tr.querySelector('input').focus(); tr.querySelector('input').focus();
}); });
// Drag-and-drop reorder
var tbody = document.querySelector('#cat-table tbody');
var dragRow = null;
tbody.addEventListener('dragstart', function(e) {
dragRow = e.target.closest('tr');
if (dragRow) dragRow.style.opacity = '0.5';
});
tbody.addEventListener('dragend', function() {
if (dragRow) dragRow.style.opacity = '';
dragRow = null;
});
tbody.addEventListener('dragover', function(e) {
e.preventDefault();
var target = e.target.closest('tr');
if (target && target !== dragRow) {
var rect = target.getBoundingClientRect();
if ((e.clientY - rect.top) > rect.height / 2) {
target.parentNode.insertBefore(dragRow, target.nextSibling);
} else {
target.parentNode.insertBefore(dragRow, target);
}
}
});
tbody.addEventListener('drop', function(e) {
e.preventDefault();
var ids = [];
tbody.querySelectorAll('tr[data-id]').forEach(function(r) { if (r.dataset.id !== '0') ids.push(r.dataset.id); });
var fd = new FormData();
fd.append('order', JSON.stringify(ids));
fd.append(token, '1');
fetch('<?php echo $reorderUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}});
});
}); });
</script> </script>
@@ -8,17 +8,6 @@ use Joomla\CMS\Session\Session;
$t = $this->ticket; $t = $this->ticket;
$canned = $this->cannedResponses; $canned = $this->cannedResponses;
$token = Session::getFormToken(); $token = Session::getFormToken();
$attachments = $this->attachments;
$downloadUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.downloadAttachment');
$uploadUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.uploadAttachment&format=json');
$deleteAttUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.deleteAttachment&format=json');
// Group attachments by reply_id (null = ticket-level)
$attByReply = [];
foreach ($attachments as $att) {
$key = $att->reply_id ?? 0;
$attByReply[$key][] = $att;
}
$statuses = $this->statuses ?? []; $statuses = $this->statuses ?? [];
$priorities = $this->priorities ?? []; $priorities = $this->priorities ?? [];
@@ -36,21 +25,7 @@ $priorities = $this->priorities ?? [];
</div> </div>
<span class="badge bg-dark">Original</span> <span class="badge bg-dark">Original</span>
</div> </div>
<div class="card-body"> <div class="card-body"><?php echo nl2br($this->escape($t->body)); ?></div>
<?php echo nl2br($this->escape($t->body)); ?>
<?php if (!empty($attByReply[0])): ?>
<hr>
<div class="small">
<strong>Attachments:</strong>
<?php foreach ($attByReply[0] as $att): ?>
<a href="<?php echo $downloadUrl . '&id=' . $att->id; ?>" class="d-inline-block me-3">
<span class="icon-download"></span> <?php echo $this->escape($att->filename); ?>
<span class="text-muted">(<?php echo \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::formatSize($att->filesize); ?>)</span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div> </div>
<!-- Replies --> <!-- Replies -->
@@ -65,21 +40,7 @@ $priorities = $this->priorities ?? [];
<span class="badge bg-warning text-dark">Internal Note</span> <span class="badge bg-warning text-dark">Internal Note</span>
<?php endif; ?> <?php endif; ?>
</div> </div>
<div class="card-body"> <div class="card-body"><?php echo nl2br($this->escape($reply->body)); ?></div>
<?php echo nl2br($this->escape($reply->body)); ?>
<?php if (!empty($attByReply[$reply->id])): ?>
<hr>
<div class="small">
<strong>Attachments:</strong>
<?php foreach ($attByReply[$reply->id] as $att): ?>
<a href="<?php echo $downloadUrl . '&id=' . $att->id; ?>" class="d-inline-block me-3">
<span class="icon-download"></span> <?php echo $this->escape($att->filename); ?>
<span class="text-muted">(<?php echo \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::formatSize($att->filesize); ?>)</span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
@@ -98,10 +59,6 @@ $priorities = $this->priorities ?? [];
</div> </div>
<?php endif; ?> <?php endif; ?>
<textarea id="reply-body" class="form-control mb-2" rows="5" placeholder="Type your reply..."></textarea> <textarea id="reply-body" class="form-control mb-2" rows="5" placeholder="Type your reply..."></textarea>
<div class="mb-2">
<input type="file" id="reply-attachments" class="form-control form-control-sm" multiple
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.zip">
</div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="button" class="btn btn-primary" id="btn-reply" <button type="button" class="btn btn-primary" id="btn-reply"
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.addTicketReply&format=json'); ?>" data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.addTicketReply&format=json'); ?>"
@@ -188,45 +145,6 @@ $priorities = $this->priorities ?? [];
</div> </div>
<?php endif; ?> <?php endif; ?>
<!-- Satisfaction Rating -->
<?php
$isClosed = in_array($t->status, ['resolved', 'closed'], true);
$hasRating = !empty($t->satisfaction_rating);
?>
<?php if ($hasRating): ?>
<div class="card mb-3">
<div class="card-header"><strong>Satisfaction</strong></div>
<div class="card-body text-center">
<div class="mb-1">
<?php for ($s = 1; $s <= 5; $s++): ?>
<span style="font-size:1.5rem;color:<?php echo $s <= $t->satisfaction_rating ? '#f5a623' : '#dee2e6'; ?>;">&#9733;</span>
<?php endfor; ?>
</div>
<div class="text-muted small"><?php echo $t->satisfaction_rating; ?>/5</div>
<?php if (!empty($t->satisfaction_feedback)): ?>
<p class="small mt-2 mb-0"><?php echo $this->escape($t->satisfaction_feedback); ?></p>
<?php endif; ?>
</div>
</div>
<?php elseif ($isClosed): ?>
<div class="card mb-3" id="rating-card">
<div class="card-header"><strong>Rate this Support</strong></div>
<div class="card-body text-center">
<div class="mb-2" id="star-rating">
<?php for ($s = 1; $s <= 5; $s++): ?>
<span class="star-btn" data-value="<?php echo $s; ?>" style="font-size:2rem;cursor:pointer;color:#dee2e6;">&#9733;</span>
<?php endfor; ?>
</div>
<textarea id="rating-feedback" class="form-control form-control-sm mb-2" rows="2" placeholder="Optional feedback..."></textarea>
<button type="button" class="btn btn-primary btn-sm" id="btn-rate"
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.rateTicket&format=json'); ?>"
data-ticket="<?php echo $t->id; ?>" data-token="<?php echo $token; ?>" disabled>
Submit Rating
</button>
</div>
</div>
<?php endif; ?>
<!-- Status actions --> <!-- Status actions -->
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header"><strong>Actions</strong></div> <div class="card-header"><strong>Actions</strong></div>
@@ -272,39 +190,22 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
} }
// Reply buttons (with attachment upload) // Reply buttons
document.querySelectorAll('#btn-reply, #btn-internal').forEach(function(btn) { document.querySelectorAll('#btn-reply, #btn-internal').forEach(function(btn) {
btn.addEventListener('click', function() { btn.addEventListener('click', function() {
var body = document.getElementById('reply-body').value.trim(); var body = document.getElementById('reply-body').value.trim();
var fileInput = document.getElementById('reply-attachments'); if (!body) return;
if (!body && (!fileInput || !fileInput.files.length)) return;
var el = this; var el = this;
el.disabled = true; el.disabled = true;
var fd = new FormData(); var fd = new FormData();
fd.append('ticket_id', el.dataset.ticket); fd.append('ticket_id', el.dataset.ticket);
fd.append('body', body || '(attachment)'); fd.append('body', body);
fd.append('is_internal', el.dataset.internal || '0'); fd.append('is_internal', el.dataset.internal || '0');
fd.append(el.dataset.token, '1'); fd.append(el.dataset.token, '1');
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}}) fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()}) .then(function(r){return r.json()})
.then(function(d){ .then(function(d){ if(d.success) location.reload(); else Joomla.renderMessages({error:[d.message]}); })
if (!d.success) { Joomla.renderMessages({error:[d.message]}); el.disabled = false; return; } .finally(function(){ el.disabled = false; });
// Upload attachments if any
if (fileInput && fileInput.files.length > 0) {
var afd = new FormData();
afd.append('ticket_id', el.dataset.ticket);
if (d.reply_id) afd.append('reply_id', d.reply_id);
for (var i = 0; i < fileInput.files.length; i++) {
afd.append('attachments[' + i + ']', fileInput.files[i]);
}
afd.append(el.dataset.token, '1');
fetch('<?php echo $uploadUrl; ?>', {method:'POST', body:afd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(){ location.reload(); });
} else {
location.reload();
}
})
.catch(function(){ el.disabled = false; });
}); });
}); });
@@ -323,42 +224,5 @@ document.addEventListener('DOMContentLoaded', function() {
.finally(function(){ el.disabled = false; }); .finally(function(){ el.disabled = false; });
}); });
}); });
// Star rating
var selectedRating = 0;
document.querySelectorAll('.star-btn').forEach(function(star) {
star.addEventListener('mouseenter', function() {
var val = parseInt(this.dataset.value);
document.querySelectorAll('.star-btn').forEach(function(s) {
s.style.color = parseInt(s.dataset.value) <= val ? '#f5a623' : '#dee2e6';
});
});
star.addEventListener('mouseleave', function() {
document.querySelectorAll('.star-btn').forEach(function(s) {
s.style.color = parseInt(s.dataset.value) <= selectedRating ? '#f5a623' : '#dee2e6';
});
});
star.addEventListener('click', function() {
selectedRating = parseInt(this.dataset.value);
document.getElementById('btn-rate').disabled = false;
});
});
var rateBtn = document.getElementById('btn-rate');
if (rateBtn) {
rateBtn.addEventListener('click', function() {
if (!selectedRating) return;
var el = this;
el.disabled = true;
var fd = new FormData();
fd.append('ticket_id', el.dataset.ticket);
fd.append('rating', selectedRating);
fd.append('feedback', document.getElementById('rating-feedback').value);
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) location.reload(); else Joomla.renderMessages({error:[d.message]}); })
.finally(function(){ el.disabled = false; });
});
}
}); });
</script> </script>
@@ -182,26 +182,6 @@ $token = Session::getFormToken();
</select> </select>
</div> </div>
</div> </div>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">Assign Users</label>
<select name="assign_users[]" class="form-select" multiple size="4">
<?php foreach ($this->backendUsers as $u): ?>
<option value="<?php echo $u->id; ?>"><?php echo $this->escape($u->name); ?></option>
<?php endforeach; ?>
</select>
<small class="text-muted">Hold Ctrl/Cmd to select multiple</small>
</div>
<div class="col-md-6">
<label class="form-label">Assign Groups</label>
<select name="assign_groups[]" class="form-select" multiple size="4">
<?php foreach ($this->userGroups as $g): ?>
<option value="<?php echo $g->id; ?>"><?php echo $this->escape($g->title); ?></option>
<?php endforeach; ?>
</select>
<small class="text-muted">Hold Ctrl/Cmd to select multiple</small>
</div>
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Description</label> <label class="form-label">Description</label>
<textarea name="body" class="form-control" rows="6" required></textarea> <textarea name="body" class="form-control" rows="6" required></textarea>
@@ -1,203 +0,0 @@
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later
*
* @package MokoSuiteClient
* @subpackage Component
*/
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$token = Session::getFormToken();
$colorOptions = [
'bg-primary', 'bg-secondary', 'bg-success', 'bg-danger',
'bg-warning text-dark', 'bg-info text-dark', 'bg-dark', 'bg-light text-dark',
];
?>
<div class="row">
<!-- Statuses -->
<div class="col-lg-6">
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><span class="fa-solid fa-circle-dot"></span> Ticket Statuses</strong>
</div>
<div class="card-body p-0">
<table class="table table-striped mb-0">
<thead>
<tr>
<th>Title</th>
<th class="w-10 text-center">Color</th>
<th class="w-10 text-center">Default</th>
<th class="w-10 text-center">Closed?</th>
<th class="w-10 text-center">Order</th>
<th class="w-10 text-center">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($this->statuses as $s): ?>
<tr>
<td><?php echo $this->escape($s->title); ?> <small class="text-muted">(<?php echo $this->escape($s->alias); ?>)</small></td>
<td class="text-center"><span class="badge <?php echo $this->escape($s->color); ?>">&nbsp;&nbsp;&nbsp;</span></td>
<td class="text-center"><?php echo $s->is_default ? '<span class="badge bg-success">Yes</span>' : ''; ?></td>
<td class="text-center"><?php echo $s->is_closed ? '<span class="badge bg-dark">Closed</span>' : ''; ?></td>
<td class="text-center"><?php echo (int) $s->ordering; ?></td>
<td class="text-center">
<button class="btn btn-sm btn-outline-primary" onclick="editStatus(<?php echo htmlspecialchars(json_encode($s)); ?>)">
<span class="icon-pencil"></span>
</button>
<a href="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.deleteStatus&id=' . $s->id . '&' . $token . '=1'); ?>"
class="btn btn-sm btn-outline-danger"
onclick="return confirm('Delete this status?')">
<span class="icon-trash"></span>
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="card-footer">
<form method="post" action="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.saveStatus'); ?>" id="statusForm" class="row g-2 align-items-end">
<input type="hidden" name="id" id="status-id" value="0">
<div class="col-md-3">
<label class="form-label small">Title</label>
<input type="text" name="title" id="status-title" class="form-control form-control-sm" required>
</div>
<div class="col-md-2">
<label class="form-label small">Alias</label>
<input type="text" name="alias" id="status-alias" class="form-control form-control-sm">
</div>
<div class="col-md-2">
<label class="form-label small">Color</label>
<select name="color" id="status-color" class="form-select form-select-sm">
<?php foreach ($colorOptions as $c): ?>
<option value="<?php echo $c; ?>"><?php echo $c; ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-1">
<label class="form-label small">Order</label>
<input type="number" name="ordering" id="status-ordering" class="form-control form-control-sm" value="0">
</div>
<div class="col-md-1 text-center">
<label class="form-label small">Default</label>
<input type="checkbox" name="is_default" id="status-default" value="1" class="form-check-input">
</div>
<div class="col-md-1 text-center">
<label class="form-label small">Closed</label>
<input type="checkbox" name="is_closed" id="status-closed" value="1" class="form-check-input">
</div>
<div class="col-md-2">
<input type="hidden" name="<?php echo $token; ?>" value="1">
<button type="submit" class="btn btn-sm btn-primary w-100" id="status-btn">Add</button>
</div>
</form>
</div>
</div>
</div>
<!-- Priorities -->
<div class="col-lg-6">
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><span class="fa-solid fa-flag"></span> Ticket Priorities</strong>
</div>
<div class="card-body p-0">
<table class="table table-striped mb-0">
<thead>
<tr>
<th>Title</th>
<th class="w-10 text-center">Color</th>
<th class="w-10 text-center">Default</th>
<th class="w-10 text-center">Order</th>
<th class="w-10 text-center">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($this->priorities as $p): ?>
<tr>
<td><?php echo $this->escape($p->title); ?> <small class="text-muted">(<?php echo $this->escape($p->alias); ?>)</small></td>
<td class="text-center"><span class="badge <?php echo $this->escape($p->color); ?>">&nbsp;&nbsp;&nbsp;</span></td>
<td class="text-center"><?php echo $p->is_default ? '<span class="badge bg-success">Yes</span>' : ''; ?></td>
<td class="text-center"><?php echo (int) $p->ordering; ?></td>
<td class="text-center">
<button class="btn btn-sm btn-outline-primary" onclick="editPriority(<?php echo htmlspecialchars(json_encode($p)); ?>)">
<span class="icon-pencil"></span>
</button>
<a href="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.deletePriority&id=' . $p->id . '&' . $token . '=1'); ?>"
class="btn btn-sm btn-outline-danger"
onclick="return confirm('Delete this priority?')">
<span class="icon-trash"></span>
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="card-footer">
<form method="post" action="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.savePriority'); ?>" id="priorityForm" class="row g-2 align-items-end">
<input type="hidden" name="id" id="priority-id" value="0">
<div class="col-md-3">
<label class="form-label small">Title</label>
<input type="text" name="title" id="priority-title" class="form-control form-control-sm" required>
</div>
<div class="col-md-2">
<label class="form-label small">Alias</label>
<input type="text" name="alias" id="priority-alias" class="form-control form-control-sm">
</div>
<div class="col-md-2">
<label class="form-label small">Color</label>
<select name="color" id="priority-color" class="form-select form-select-sm">
<?php foreach ($colorOptions as $c): ?>
<option value="<?php echo $c; ?>"><?php echo $c; ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-1">
<label class="form-label small">Order</label>
<input type="number" name="ordering" id="priority-ordering" class="form-control form-control-sm" value="0">
</div>
<div class="col-md-1 text-center">
<label class="form-label small">Default</label>
<input type="checkbox" name="is_default" id="priority-default" value="1" class="form-check-input">
</div>
<div class="col-md-3">
<input type="hidden" name="<?php echo $token; ?>" value="1">
<button type="submit" class="btn btn-sm btn-primary w-100" id="priority-btn">Add</button>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
function editStatus(s) {
document.getElementById('status-id').value = s.id;
document.getElementById('status-title').value = s.title;
document.getElementById('status-alias').value = s.alias;
document.getElementById('status-color').value = s.color;
document.getElementById('status-ordering').value = s.ordering;
document.getElementById('status-default').checked = !!parseInt(s.is_default);
document.getElementById('status-closed').checked = !!parseInt(s.is_closed);
document.getElementById('status-btn').textContent = 'Update';
}
function editPriority(p) {
document.getElementById('priority-id').value = p.id;
document.getElementById('priority-title').value = p.title;
document.getElementById('priority-alias').value = p.alias;
document.getElementById('priority-color').value = p.color;
document.getElementById('priority-ordering').value = p.ordering;
document.getElementById('priority-default').checked = !!parseInt(p.is_default);
document.getElementById('priority-btn').textContent = 'Update';
}
</script>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1,261 +0,0 @@
<?php
/**
* @package MokoSuiteClient
* @subpackage com_mokosuiteclient
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuiteClient\Api\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Controller\BaseController;
/**
* Helpdesk Tickets REST API controller.
*
* GET /api/index.php/v1/mokosuiteclient/tickets - list tickets
* GET /api/index.php/v1/mokosuiteclient/tickets/{id} - get single ticket with replies
* POST /api/index.php/v1/mokosuiteclient/tickets - create ticket
* PATCH /api/index.php/v1/mokosuiteclient/tickets/{id} - update ticket fields
* POST /api/index.php/v1/mokosuiteclient/tickets/{id}/reply - add reply
*
* @since 02.35.00
*/
class TicketsController extends BaseController
{
/**
* GET /tickets — list tickets with optional filters.
*/
public function displayList(): void
{
$this->requireAuth('core.manage', 'com_mokosuiteclient');
$app = Factory::getApplication();
$db = Factory::getDbo();
$input = $app->getInput();
$query = $db->getQuery(true)
->select('t.*, s.title AS status_title, p.title AS priority_title, c.title AS category_title, u.name AS created_by_name')
->from($db->quoteName('#__mokosuiteclient_tickets', 't'))
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_statuses', 's') . ' ON s.id = t.status_id')
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_priorities', 'p') . ' ON p.id = t.priority_id')
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_categories', 'c') . ' ON c.id = t.category_id')
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
->order('t.created DESC');
// Filters
$status = $input->getString('status', '');
if ($status) {
$query->where($db->quoteName('t.status') . ' = ' . $db->quote($status));
}
$categoryId = $input->getInt('category_id', 0);
if ($categoryId) {
$query->where($db->quoteName('t.category_id') . ' = ' . $categoryId);
}
$assignedTo = $input->getInt('assigned_to', 0);
if ($assignedTo) {
$query->where($db->quoteName('t.assigned_to') . ' = ' . $assignedTo);
}
$limit = min($input->getInt('limit', 25), 100);
$offset = $input->getInt('offset', 0);
$db->setQuery($query, $offset, $limit);
$tickets = $db->loadObjectList() ?: [];
// Total count
$countQuery = $db->getQuery(true)->select('COUNT(*)')->from('#__mokosuiteclient_tickets');
$db->setQuery($countQuery);
$total = (int) $db->loadResult();
$this->sendJson(200, [
'tickets' => $tickets,
'total' => $total,
'limit' => $limit,
'offset' => $offset,
]);
}
/**
* GET /tickets/{id} — single ticket with replies and attachments.
*/
public function displayItem(): void
{
$this->requireAuth('core.manage', 'com_mokosuiteclient');
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$db = Factory::getDbo();
// Ticket
$db->setQuery(
$db->getQuery(true)
->select('t.*, s.title AS status_title, p.title AS priority_title, c.title AS category_title, u.name AS created_by_name')
->from($db->quoteName('#__mokosuiteclient_tickets', 't'))
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_statuses', 's') . ' ON s.id = t.status_id')
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_priorities', 'p') . ' ON p.id = t.priority_id')
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_categories', 'c') . ' ON c.id = t.category_id')
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
->where('t.id = ' . $id)
);
$ticket = $db->loadObject();
if (!$ticket) {
$this->sendJson(404, ['error' => 'Ticket not found']);
return;
}
// Replies
$db->setQuery(
$db->getQuery(true)
->select('r.*, u.name AS user_name')
->from($db->quoteName('#__mokosuiteclient_ticket_replies', 'r'))
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id')
->where('r.ticket_id = ' . $id)
->order('r.created ASC')
);
$ticket->replies = $db->loadObjectList() ?: [];
// Attachments
$ticket->attachments = \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::getForTicket($id);
$this->sendJson(200, $ticket);
}
/**
* POST /tickets — create a new ticket.
*/
public function create(): void
{
$this->requireAuth('core.manage', 'com_mokosuiteclient');
$input = Factory::getApplication()->getInput();
$db = Factory::getDbo();
$subject = $input->getString('subject', '');
$body = $input->getRaw('body', '');
if (empty($subject)) {
$this->sendJson(400, ['error' => 'Subject is required']);
return;
}
$ticket = (object) [
'subject' => $subject,
'body' => $body,
'status' => 'open',
'status_id' => $input->getInt('status_id', 0) ?: null,
'priority' => $input->getString('priority', 'normal'),
'priority_id' => $input->getInt('priority_id', 0) ?: null,
'category_id' => $input->getInt('category_id', 0) ?: null,
'created_by' => (int) Factory::getUser()->id,
'assigned_to' => $input->getInt('assigned_to', 0) ?: null,
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuiteclient_tickets', $ticket, 'id');
// Trigger notification
\Moko\Component\MokoSuiteClient\Administrator\Service\NotificationService::notify('ticket_created', $ticket);
$this->sendJson(201, ['id' => (int) $ticket->id, 'message' => 'Ticket created']);
}
/**
* PATCH /tickets/{id} — update ticket fields.
*/
public function update(): void
{
$this->requireAuth('core.manage', 'com_mokosuiteclient');
$input = Factory::getApplication()->getInput();
$id = $input->getInt('id', 0);
$db = Factory::getDbo();
$fields = [];
$updatable = ['status', 'status_id', 'priority', 'priority_id', 'category_id', 'assigned_to'];
foreach ($updatable as $field) {
$value = $input->get($field, null, 'raw');
if ($value !== null) {
$fields[$field] = $value;
}
}
if (empty($fields)) {
$this->sendJson(400, ['error' => 'No fields to update']);
return;
}
$sets = [];
foreach ($fields as $k => $v) {
$sets[] = $db->quoteName($k) . ' = ' . $db->quote($v);
}
$sets[] = 'modified = ' . $db->quote(Factory::getDate()->toSql());
$db->setQuery('UPDATE ' . $db->quoteName('#__mokosuiteclient_tickets') . ' SET ' . implode(', ', $sets) . ' WHERE id = ' . $id)->execute();
$this->sendJson(200, ['id' => $id, 'message' => 'Ticket updated', 'updated' => array_keys($fields)]);
}
/**
* POST /tickets/{id}/reply — add a reply.
*/
public function reply(): void
{
$this->requireAuth('core.manage', 'com_mokosuiteclient');
$input = Factory::getApplication()->getInput();
$ticketId = $input->getInt('id', 0);
$body = $input->getRaw('body', '');
if (!$ticketId || empty($body)) {
$this->sendJson(400, ['error' => 'ticket_id and body are required']);
return;
}
$db = Factory::getDbo();
$reply = (object) [
'ticket_id' => $ticketId,
'user_id' => (int) Factory::getUser()->id,
'body' => $body,
'is_internal' => $input->getInt('is_internal', 0),
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuiteclient_ticket_replies', $reply, 'id');
// Notify
$db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuiteclient_tickets')->where('id = ' . $ticketId));
$ticket = $db->loadObject();
if ($ticket) {
\Moko\Component\MokoSuiteClient\Administrator\Service\NotificationService::notify('ticket_replied', $ticket, ['reply_body' => $body]);
}
$this->sendJson(201, ['reply_id' => (int) $reply->id, 'message' => 'Reply added']);
}
// ── Helpers ──────────────────────────────────────────────────
private function requireAuth(string $action, string $asset): void
{
$user = Factory::getUser();
if (!$user->authorise($action, $asset)) {
$this->sendJson(403, ['error' => 'Not authorized']);
}
}
private function sendJson(int $code, $payload): void
{
$app = Factory::getApplication();
$app->setHeader('Content-Type', 'application/json', true);
$app->setHeader('Status', (string) $code, true);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$app->close();
}
}
@@ -1,400 +0,0 @@
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later
*
* @package MokoSuiteClient
* @subpackage API
*/
namespace Moko\Component\MokoSuiteClient\Api\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\CMS\User\UserHelper;
use Joomla\Registry\Registry;
/**
* Remote user management API controller.
*
* All endpoints require the MokoSuiteClient health API token via
* Authorization: Bearer header. Actions are performed on behalf
* of MokoSuiteClientHQ for security incident response and maintenance.
*
* @since 02.35.00
*/
class UsersController extends BaseController
{
/**
* Route to the appropriate action based on task.
*/
public function execute($task = 'export'): void
{
$app = Factory::getApplication();
$method = $app->input->getMethod();
// Authenticate via health API token
if (!$this->authenticateToken())
{
$this->sendJson(401, ['error' => 'Invalid or missing token']);
return;
}
// Get the master usernames to protect them
$this->masterUsernames = $this->getMasterUsernames();
match ($task)
{
'resetPasswords' => $this->resetPasswords(),
'reset2fa' => $this->reset2fa(),
'disableAll' => $this->disableAll(),
'enableAll' => $this->enableAll(),
'forceLogout' => $this->forceLogout(),
'export' => $this->exportUsers(),
default => $this->sendJson(400, ['error' => 'Unknown action: ' . $task]),
};
}
/** @var array Master usernames that should be protected from mass actions */
private array $masterUsernames = [];
/**
* Reset all user passwords and force change on next login.
* Excludes master user accounts.
*
* POST /api/index.php/v1/mokosuiteclient/users/reset-passwords
*/
private function resetPasswords(): void
{
$db = Factory::getDbo();
$now = Factory::getDate()->toSql();
$count = 0;
$users = $this->getNonMasterUsers();
foreach ($users as $user)
{
// Generate a random password
$newPassword = UserHelper::genRandomPassword(16);
$hashed = UserHelper::hashPassword($newPassword);
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__users'))
->set($db->quoteName('password') . ' = ' . $db->quote($hashed))
->set($db->quoteName('requireReset') . ' = 1')
->set($db->quoteName('lastResetTime') . ' = ' . $db->quote($now))
->where($db->quoteName('id') . ' = ' . (int) $user->id)
)->execute();
$count++;
}
$this->sendJson(200, [
'status' => 'ok',
'action' => 'reset_passwords',
'count' => $count,
'message' => sprintf('%d user password(s) reset. Users must set a new password on next login.', $count),
]);
}
/**
* Reset/disable 2FA (OTP) for all non-master users.
*
* POST /api/index.php/v1/mokosuiteclient/users/reset-2fa
*/
private function reset2fa(): void
{
$db = Factory::getDbo();
$count = 0;
$users = $this->getNonMasterUsers();
$userIds = array_map(fn($u) => (int) $u->id, $users);
if (!empty($userIds))
{
// Remove OTP config from user profiles
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__user_profiles'))
->where($db->quoteName('user_id') . ' IN (' . implode(',', $userIds) . ')')
->where($db->quoteName('profile_key') . ' LIKE ' . $db->quote('joomlatoken.%'))
)->execute();
$count += $db->getAffectedRows();
// Also clear any MFA/2FA records
try
{
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__user_mfa'))
->where($db->quoteName('user_id') . ' IN (' . implode(',', $userIds) . ')')
)->execute();
$count += $db->getAffectedRows();
}
catch (\Throwable $e)
{
// Table may not exist on older Joomla versions
}
}
$this->sendJson(200, [
'status' => 'ok',
'action' => 'reset_2fa',
'count' => $count,
'message' => sprintf('2FA/MFA disabled for %d user(s).', \count($userIds)),
]);
}
/**
* Disable (block) all user accounts except master users.
*
* POST /api/index.php/v1/mokosuiteclient/users/disable-all
*/
private function disableAll(): void
{
$db = Factory::getDbo();
$masterIds = $this->getMasterUserIds();
$where = !empty($masterIds)
? $db->quoteName('id') . ' NOT IN (' . implode(',', $masterIds) . ')'
: '1=1';
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__users'))
->set($db->quoteName('block') . ' = 1')
->where($where)
->where($db->quoteName('block') . ' = 0')
)->execute();
$count = $db->getAffectedRows();
$this->sendJson(200, [
'status' => 'ok',
'action' => 'disable_all',
'count' => $count,
'message' => sprintf('%d user account(s) disabled. Master users preserved.', $count),
]);
}
/**
* Re-enable (unblock) all user accounts.
*
* POST /api/index.php/v1/mokosuiteclient/users/enable-all
*/
private function enableAll(): void
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__users'))
->set($db->quoteName('block') . ' = 0')
->where($db->quoteName('block') . ' = 1')
)->execute();
$count = $db->getAffectedRows();
$this->sendJson(200, [
'status' => 'ok',
'action' => 'enable_all',
'count' => $count,
'message' => sprintf('%d user account(s) re-enabled.', $count),
]);
}
/**
* Force logout all active sessions.
*
* POST /api/index.php/v1/mokosuiteclient/users/force-logout
*/
private function forceLogout(): void
{
$db = Factory::getDbo();
// Clear all sessions except the current API session
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__session'))
->where($db->quoteName('client_id') . ' IN (0, 1)')
)->execute();
$count = $db->getAffectedRows();
$this->sendJson(200, [
'status' => 'ok',
'action' => 'force_logout',
'count' => $count,
'message' => sprintf('%d active session(s) terminated.', $count),
]);
}
/**
* Export user list with basic info.
*
* GET /api/index.php/v1/mokosuiteclient/users/export
*/
private function exportUsers(): void
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select([
$db->quoteName('u.id'),
$db->quoteName('u.name'),
$db->quoteName('u.username'),
$db->quoteName('u.email'),
$db->quoteName('u.block'),
$db->quoteName('u.lastvisitDate'),
$db->quoteName('u.registerDate'),
])
->from($db->quoteName('#__users', 'u'))
->order($db->quoteName('u.name') . ' ASC')
);
$users = $db->loadObjectList() ?: [];
// Add group names for each user
foreach ($users as $user)
{
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('g.title'))
->from($db->quoteName('#__usergroups', 'g'))
->join('INNER', $db->quoteName('#__user_usergroup_map', 'm') . ' ON m.group_id = g.id')
->where($db->quoteName('m.user_id') . ' = ' . (int) $user->id)
);
$user->groups = $db->loadColumn() ?: [];
}
$this->sendJson(200, [
'status' => 'ok',
'action' => 'export',
'count' => \count($users),
'users' => $users,
]);
}
// ── Helpers ──────────────────────────────────────────────────────
/**
* Authenticate the request using the health API token.
*/
private function authenticateToken(): bool
{
$app = Factory::getApplication();
$auth = $app->input->server->get('HTTP_AUTHORIZATION', '', 'STRING');
$token = '';
if (str_starts_with($auth, 'Bearer '))
{
$token = substr($auth, 7);
}
if (empty($token))
{
// Also check JSON body for backwards compatibility
$token = $app->input->json->get('token', '', 'RAW');
}
if (empty($token))
{
return false;
}
$plugin = PluginHelper::getPlugin('system', 'mokosuiteclient');
if (!$plugin)
{
return false;
}
$params = new Registry($plugin->params);
$healthToken = $params->get('health_api_token', '');
return !empty($healthToken) && hash_equals($healthToken, $token);
}
/**
* Get master usernames from the MokoSuiteClient plugin config.
*/
private function getMasterUsernames(): array
{
$helperFile = JPATH_PLUGINS . '/system/mokosuiteclient/Helper/MokoSuiteClientHelper.php';
if (file_exists($helperFile))
{
require_once $helperFile;
if (method_exists(\Moko\Plugin\System\MokoSuiteClient\Helper\MokoSuiteClientHelper::class, 'getMasterUsernames'))
{
return \Moko\Plugin\System\MokoSuiteClient\Helper\MokoSuiteClientHelper::getMasterUsernames();
}
}
return [];
}
/**
* Get user IDs of master users.
*/
private function getMasterUserIds(): array
{
if (empty($this->masterUsernames))
{
return [];
}
$db = Factory::getDbo();
$quoted = array_map([$db, 'quote'], $this->masterUsernames);
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('id'))
->from($db->quoteName('#__users'))
->where($db->quoteName('username') . ' IN (' . implode(',', $quoted) . ')')
);
return array_map('intval', $db->loadColumn() ?: []);
}
/**
* Get all non-master user records.
*/
private function getNonMasterUsers(): array
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select([$db->quoteName('id'), $db->quoteName('username'), $db->quoteName('email')])
->from($db->quoteName('#__users'));
$masterIds = $this->getMasterUserIds();
if (!empty($masterIds))
{
$query->where($db->quoteName('id') . ' NOT IN (' . implode(',', $masterIds) . ')');
}
return $db->setQuery($query)->loadObjectList() ?: [];
}
/**
* Send JSON response and terminate.
*/
private function sendJson(int $code, array $data): void
{
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_SLASHES);
Factory::getApplication()->close();
}
}
@@ -7,9 +7,9 @@
<license>GPL-3.0-or-later</license> <license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl> <authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.83-dev</version> <version>02.34.50-dev</version>
<description>MOD_MOKOSUITE_CACHE_DESC</description> <description>MOD_MOKOSUITECLIENT_CACHE_DESC</description>
<namespace path="src">Moko\Module\MokoSuiteCache</namespace> <namespace path="src">Moko\Module\MokoSuiteClientCache</namespace>
<files> <files>
<folder module="mod_mokosuiteclient_cache">services</folder> <folder module="mod_mokosuiteclient_cache">services</folder>
@@ -7,9 +7,9 @@
<license>GPL-3.0-or-later</license> <license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl> <authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.83-dev</version> <version>02.34.50-dev</version>
<description>MOD_MOKOSUITE_CPANEL_DESC</description> <description>MOD_MOKOSUITECLIENT_CPANEL_DESC</description>
<namespace path="src">Moko\Module\MokoSuiteCpanel</namespace> <namespace path="src">Moko\Module\MokoSuiteClientCpanel</namespace>
<files> <files>
<folder module="mod_mokosuiteclient_cpanel">services</folder> <folder module="mod_mokosuiteclient_cpanel">services</folder>
@@ -25,64 +25,64 @@
<config> <config>
<fields name="params"> <fields name="params">
<fieldset name="basic" <fieldset name="basic"
label="MOD_MOKOSUITE_CPANEL_FIELDSET_DISPLAY" label="MOD_MOKOSUITECLIENT_CPANEL_FIELDSET_DISPLAY"
description="MOD_MOKOSUITE_CPANEL_FIELDSET_DISPLAY_DESC"> description="MOD_MOKOSUITECLIENT_CPANEL_FIELDSET_DISPLAY_DESC">
<field name="collapsed" type="radio" default="1" <field name="collapsed" type="radio" default="1"
label="MOD_MOKOSUITE_CPANEL_COLLAPSED_LABEL" label="MOD_MOKOSUITECLIENT_CPANEL_COLLAPSED_LABEL"
description="MOD_MOKOSUITE_CPANEL_COLLAPSED_DESC" description="MOD_MOKOSUITECLIENT_CPANEL_COLLAPSED_DESC"
layout="joomla.form.field.radio.switcher"> layout="joomla.form.field.radio.switcher">
<option value="1">JYES</option> <option value="1">JYES</option>
<option value="0">JNO</option> <option value="0">JNO</option>
</field> </field>
<field name="show_health" type="radio" default="1" <field name="show_health" type="radio" default="1"
label="MOD_MOKOSUITE_CPANEL_SHOW_HEALTH_LABEL" label="MOD_MOKOSUITECLIENT_CPANEL_SHOW_HEALTH_LABEL"
layout="joomla.form.field.radio.switcher"> layout="joomla.form.field.radio.switcher">
<option value="0">JHIDE</option> <option value="0">JHIDE</option>
<option value="1">JSHOW</option> <option value="1">JSHOW</option>
</field> </field>
<field name="show_stats" type="radio" default="1" <field name="show_stats" type="radio" default="1"
label="MOD_MOKOSUITE_CPANEL_SHOW_STATS_LABEL" label="MOD_MOKOSUITECLIENT_CPANEL_SHOW_STATS_LABEL"
description="MOD_MOKOSUITE_CPANEL_SHOW_STATS_DESC" description="MOD_MOKOSUITECLIENT_CPANEL_SHOW_STATS_DESC"
layout="joomla.form.field.radio.switcher"> layout="joomla.form.field.radio.switcher">
<option value="0">JHIDE</option> <option value="0">JHIDE</option>
<option value="1">JSHOW</option> <option value="1">JSHOW</option>
</field> </field>
<field name="show_disk" type="radio" default="1" <field name="show_disk" type="radio" default="1"
label="MOD_MOKOSUITE_CPANEL_SHOW_DISK_LABEL" label="MOD_MOKOSUITECLIENT_CPANEL_SHOW_DISK_LABEL"
layout="joomla.form.field.radio.switcher"> layout="joomla.form.field.radio.switcher">
<option value="0">JHIDE</option> <option value="0">JHIDE</option>
<option value="1">JSHOW</option> <option value="1">JSHOW</option>
</field> </field>
<field name="show_ip" type="radio" default="1" <field name="show_ip" type="radio" default="1"
label="MOD_MOKOSUITE_CPANEL_SHOW_IP_LABEL" label="MOD_MOKOSUITECLIENT_CPANEL_SHOW_IP_LABEL"
layout="joomla.form.field.radio.switcher"> layout="joomla.form.field.radio.switcher">
<option value="0">JHIDE</option> <option value="0">JHIDE</option>
<option value="1">JSHOW</option> <option value="1">JSHOW</option>
</field> </field>
<field name="show_plugins" type="radio" default="1" <field name="show_plugins" type="radio" default="1"
label="MOD_MOKOSUITE_CPANEL_SHOW_PLUGINS_LABEL" label="MOD_MOKOSUITECLIENT_CPANEL_SHOW_PLUGINS_LABEL"
layout="joomla.form.field.radio.switcher"> layout="joomla.form.field.radio.switcher">
<option value="0">JHIDE</option> <option value="0">JHIDE</option>
<option value="1">JSHOW</option> <option value="1">JSHOW</option>
</field> </field>
<field name="show_actions" type="radio" default="1" <field name="show_actions" type="radio" default="1"
label="MOD_MOKOSUITE_CPANEL_SHOW_ACTIONS_LABEL" label="MOD_MOKOSUITECLIENT_CPANEL_SHOW_ACTIONS_LABEL"
description="MOD_MOKOSUITE_CPANEL_SHOW_ACTIONS_DESC" description="MOD_MOKOSUITECLIENT_CPANEL_SHOW_ACTIONS_DESC"
layout="joomla.form.field.radio.switcher"> layout="joomla.form.field.radio.switcher">
<option value="0">JHIDE</option> <option value="0">JHIDE</option>
<option value="1">JSHOW</option> <option value="1">JSHOW</option>
</field> </field>
<field name="show_versions" type="radio" default="1" <field name="show_versions" type="radio" default="1"
label="MOD_MOKOSUITE_CPANEL_SHOW_VERSIONS_LABEL" label="MOD_MOKOSUITECLIENT_CPANEL_SHOW_VERSIONS_LABEL"
description="MOD_MOKOSUITE_CPANEL_SHOW_VERSIONS_DESC" description="MOD_MOKOSUITECLIENT_CPANEL_SHOW_VERSIONS_DESC"
layout="joomla.form.field.radio.switcher"> layout="joomla.form.field.radio.switcher">
<option value="0">JHIDE</option> <option value="0">JHIDE</option>
<option value="1">JSHOW</option> <option value="1">JSHOW</option>
@@ -22,7 +22,7 @@ $healthOk = $healthOk ?? true;
$counts = $counts ?? (object) ['articles' => 0, 'users' => 0, 'extensions' => 0, 'updates' => 0]; $counts = $counts ?? (object) ['articles' => 0, 'users' => 0, 'extensions' => 0, 'updates' => 0];
$disk = $disk ?? (object) ['free_mb' => null, 'total_mb' => null]; $disk = $disk ?? (object) ['free_mb' => null, 'total_mb' => null];
$currentIp = $currentIp ?? ''; $currentIp = $currentIp ?? '';
$collapsed = $params->get('collapsed', 0); $collapsed = $params->get('collapsed', 1);
$showHealth = $params->get('show_health', 1); $showHealth = $params->get('show_health', 1);
$showStats = $params->get('show_stats', 1); $showStats = $params->get('show_stats', 1);
$showDisk = $params->get('show_disk', 1); $showDisk = $params->get('show_disk', 1);
@@ -7,7 +7,7 @@
<license>GPL-3.0-or-later</license> <license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl> <authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.83-dev</version> <version>02.34.50-dev</version>
<description>MokoSuiteClient admin sidebar menu — renders a dedicated MokoSuiteClient section in the admin menu before Joomla's default menu.</description> <description>MokoSuiteClient admin sidebar menu — renders a dedicated MokoSuiteClient section in the admin menu before Joomla's default menu.</description>
<namespace path="src">Moko\Module\MokoSuiteClientMenu</namespace> <namespace path="src">Moko\Module\MokoSuiteClientMenu</namespace>
@@ -2,9 +2,9 @@
/** /**
* MokoSuiteClient Admin Sidebar Menu * MokoSuiteClient Admin Sidebar Menu
* *
* Each installed Moko component gets its own top-level collapsible section. * Renders MokoSuiteClient static views first, then auto-discovers installed
* com_mokosuiteclienthq is always pinned first. com_mokosuiteclient uses static views * Moko components from #__menu and renders their submenu items as
* as children. All other components auto-discover their submenu items. * nested MetisMenu collapsible sections.
*/ */
defined('_JEXEC') or die; defined('_JEXEC') or die;
@@ -17,8 +17,8 @@ $app = Factory::getApplication();
$currentOption = $app->getInput()->get('option', ''); $currentOption = $app->getInput()->get('option', '');
$currentView = $app->getInput()->get('view', ''); $currentView = $app->getInput()->get('view', '');
// ── Static views for com_mokosuiteclient ────────────────────────────────── // ── Static MokoSuiteClient views ────────────────────────────────────────────
$mokosuiteclientStaticViews = [ $mokosuiteclientItems = [
['icon' => 'icon-cogs', 'title' => 'Dashboard', 'link' => 'index.php?option=com_mokosuiteclient'], ['icon' => 'icon-cogs', 'title' => 'Dashboard', 'link' => 'index.php?option=com_mokosuiteclient'],
['icon' => 'fa-solid fa-handshake-angle', 'title' => 'Helpdesk', 'link' => 'index.php?option=com_mokosuiteclient&view=tickets'], ['icon' => 'fa-solid fa-handshake-angle', 'title' => 'Helpdesk', 'link' => 'index.php?option=com_mokosuiteclient&view=tickets'],
['icon' => 'icon-puzzle-piece', 'title' => 'Extensions', 'link' => 'index.php?option=com_mokosuiteclient&view=extensions'], ['icon' => 'icon-puzzle-piece', 'title' => 'Extensions', 'link' => 'index.php?option=com_mokosuiteclient&view=extensions'],
@@ -30,25 +30,27 @@ $mokosuiteclientStaticViews = [
['icon' => 'icon-power-off', 'title' => 'Feature Plugins', 'link' => 'index.php?option=com_plugins&filter[folder]=system&filter[search]=mokosuiteclient'], ['icon' => 'icon-power-off', 'title' => 'Feature Plugins', 'link' => 'index.php?option=com_plugins&filter[folder]=system&filter[search]=mokosuiteclient'],
]; ];
// ── Auto-discover all Moko components from #__menu ────────────────── // ── Auto-discover Moko component menus from #__menu ──────────────────
$mokoComponents = []; $mokoComponents = [];
try try
{ {
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
// Find all Moko component menu items (exclude com_mokosuiteclient — handled above)
$db->setQuery( $db->setQuery(
"SELECT m.id, m.title, m.link, m.level, m.parent_id, m.img, e.element" "SELECT m.id, m.title, m.link, m.level, m.parent_id, m.img, e.element"
. " FROM " . $db->quoteName('#__menu') . " m" . " FROM " . $db->quoteName('#__menu') . " m"
. " LEFT JOIN " . $db->quoteName('#__extensions') . " e ON m.component_id = e.extension_id" . " LEFT JOIN " . $db->quoteName('#__extensions') . " e ON m.component_id = e.extension_id"
. " WHERE m.client_id = 1 AND m.level >= 1 AND m.published = 1" . " WHERE m.client_id = 1 AND m.level >= 1 AND m.published = 1"
. " AND e.element LIKE 'com_moko%'" . " AND e.element LIKE 'com_moko%'"
. " AND e.element != 'com_mokosuiteclient'"
. " AND e.enabled = 1" . " AND e.enabled = 1"
. " ORDER BY e.element, m.level, m.lft" . " ORDER BY e.element, m.level, m.lft"
); );
$menuItems = $db->loadObjectList() ?: []; $menuItems = $db->loadObjectList() ?: [];
// Load language files for discovered components // Load sys.ini language files for discovered components
$lang = Factory::getLanguage(); $lang = Factory::getLanguage();
$loadedLangs = []; $loadedLangs = [];
foreach ($menuItems as $m) foreach ($menuItems as $m)
@@ -90,101 +92,86 @@ catch (\Throwable $e)
// Silent — menu works without auto-discovered components // Silent — menu works without auto-discovered components
} }
// Override com_mokosuiteclient children with static views // ── Determine active state ───────────────────────────────────────────
if (isset($mokoComponents['com_mokosuiteclient'])) $mokosuiteclientActive = ($currentOption === 'com_mokosuiteclient');
{ $anyMokoActive = $mokosuiteclientActive;
$mokoComponents['com_mokosuiteclient']['children'] = $mokosuiteclientStaticViews;
$mokoComponents['com_mokosuiteclient']['icon'] = 'icon-shield-alt';
}
else
{
// com_mokosuiteclient not in admin menu — add it manually
$mokoComponents['com_mokosuiteclient'] = [
'id' => 0,
'title' => 'MokoSuiteClient',
'link' => 'index.php?option=com_mokosuiteclient',
'icon' => 'icon-shield-alt',
'element' => 'com_mokosuiteclient',
'children' => $mokosuiteclientStaticViews,
];
}
// ── Sort: com_mokosuiteclienthq first, then alphabetical by title ───────── foreach ($mokoComponents as $comp)
$hq = null;
$rest = [];
foreach ($mokoComponents as $key => $comp)
{ {
if ($key === 'com_mokosuiteclienthq') $parsed = [];
parse_str(parse_url($comp['link'], PHP_URL_QUERY) ?? '', $parsed);
if (($parsed['option'] ?? '') === $currentOption)
{ {
$hq = $comp; $anyMokoActive = true;
}
else
{
$rest[$key] = $comp;
} }
} }
usort($rest, fn($a, $b) => strcasecmp($a['title'], $b['title'])); $topClass = 'item parent item-level-1' . ($anyMokoActive ? ' mm-active' : '');
$topCollapse = 'collapse-level-1 mm-collapse' . ($anyMokoActive ? ' mm-show' : '');
$sorted = [];
if ($hq !== null)
{
$sorted[] = $hq;
}
foreach ($rest as $comp)
{
$sorted[] = $comp;
}
?> ?>
<style> <style>
.sidebar-wrapper .mokosuiteclient-ext-item > a { padding-inline-start: 1.5rem; } .sidebar-wrapper .item-level-1 > a { padding-inline-start: 1.5rem; }
.sidebar-wrapper .mokosuiteclient-ext-child > a { padding-inline-start: 2.5rem; } .sidebar-wrapper .mokosuiteclient-menu-item > a { padding-inline-start: 2rem; }
.sidebar-wrapper .mokosuiteclient-menu-child > a { padding-inline-start: 2.5rem; }
</style> </style>
<ul class="nav flex-column main-nav"> <ul class="nav flex-column main-nav">
<?php foreach ($sorted as $comp): ?> <li class="<?php echo $topClass; ?>">
<a class="has-arrow" href="#" aria-label="MokoSuiteClient">
<span class="icon-shield-alt" aria-hidden="true"></span>
<span class="sidebar-item-title">MokoSuiteClient</span>
</a>
<ul class="<?php echo $topCollapse; ?>" style="padding-inline-start:0.5rem;">
<?php // ── MokoSuiteClient static items ── ?>
<?php foreach ($mokosuiteclientItems as $item): ?>
<?php
$active = false;
$parsed = [];
parse_str(parse_url($item['link'], PHP_URL_QUERY) ?? '', $parsed);
if (($parsed['option'] ?? '') === $currentOption)
{
$active = empty($parsed['view'])
? ($currentView === '' || $currentView === 'dashboard')
: ($currentView === ($parsed['view'] ?? ''));
}
$liClass = 'item mokosuiteclient-menu-item' . ($active ? ' mm-active' : '');
$aClass = 'no-dropdown' . ($active ? ' mm-active' : '');
?>
<li class="<?php echo $liClass; ?>">
<a class="<?php echo $aClass; ?>" href="<?php echo Route::_($item['link']); ?>"<?php echo $active ? ' aria-current="page"' : ''; ?>>
<span class="<?php echo $item['icon']; ?>" aria-hidden="true" style="display:inline-block!important;width:1.25em;text-align:center;margin-inline-end:0.4em;"></span>
<span class="sidebar-item-title"><?php echo $item['title']; ?></span>
</a>
</li>
<?php endforeach; ?>
<?php // ── Auto-discovered Moko components with submenus ── ?>
<?php foreach ($mokoComponents as $comp): ?>
<?php <?php
$compParsed = []; $compParsed = [];
parse_str(parse_url($comp['link'], PHP_URL_QUERY) ?? '', $compParsed); parse_str(parse_url($comp['link'], PHP_URL_QUERY) ?? '', $compParsed);
$compOption = $compParsed['option'] ?? ''; $compActive = ($compParsed['option'] ?? '') === $currentOption;
$compActive = ($compOption === $currentOption);
// For com_mokosuiteclient static children, also check the plugins filter link
if (!$compActive && $comp['element'] === 'com_mokosuiteclient' && $currentOption === 'com_plugins')
{
$compActive = true;
}
$hasChildren = !empty($comp['children']); $hasChildren = !empty($comp['children']);
$liClass = 'item mokosuiteclient-ext-item' . ($hasChildren ? ' parent item-level-1' : '') . ($compActive ? ' mm-active' : ''); $compLiClass = 'item mokosuiteclient-menu-item' . ($hasChildren ? ' parent' : '') . ($compActive ? ' mm-active' : '');
$aClass = ($hasChildren ? 'has-arrow' : 'no-dropdown') . ($compActive ? ' mm-active' : ''); $compAClass = ($hasChildren ? 'has-arrow' : 'no-dropdown') . ($compActive ? ' mm-active' : '');
$childCollapse = 'collapse-level-1 mm-collapse' . ($compActive ? ' mm-show' : ''); $childCollapse = 'collapse-level-2 mm-collapse' . ($compActive ? ' mm-show' : '');
?> ?>
<li class="<?php echo $liClass; ?>"> <li class="<?php echo $compLiClass; ?>">
<a class="<?php echo $aClass; ?>" href="<?php echo $hasChildren ? '#' : Route::_($comp['link']); ?>"<?php echo ($compActive && !$hasChildren) ? ' aria-current="page"' : ''; ?>> <a class="<?php echo $compAClass; ?>" href="<?php echo $hasChildren ? '#' : Route::_($comp['link']); ?>"<?php echo ($compActive && !$hasChildren) ? ' aria-current="page"' : ''; ?>>
<span class="<?php echo $comp['icon']; ?>" aria-hidden="true" style="display:inline-block!important;width:1.25em;text-align:center;margin-inline-end:0.4em;"></span> <span class="<?php echo $comp['icon']; ?>" aria-hidden="true" style="display:inline-block!important;width:1.25em;text-align:center;margin-inline-end:0.4em;"></span>
<span class="sidebar-item-title"><?php echo $comp['title']; ?></span> <span class="sidebar-item-title"><?php echo $comp['title']; ?></span>
</a> </a>
<?php if ($hasChildren): ?> <?php if ($hasChildren): ?>
<ul class="<?php echo $childCollapse; ?>" style="padding-inline-start:0.5rem;"> <ul class="<?php echo $childCollapse; ?>" style="padding-inline-start:0.75rem;">
<?php foreach ($comp['children'] as $child): ?> <?php foreach ($comp['children'] as $child): ?>
<?php <?php
$childParsed = []; $childParsed = [];
parse_str(parse_url($child['link'], PHP_URL_QUERY) ?? '', $childParsed); parse_str(parse_url($child['link'], PHP_URL_QUERY) ?? '', $childParsed);
$childOption = $childParsed['option'] ?? ''; $childActive = ($childParsed['option'] ?? '') === $currentOption
$childView = $childParsed['view'] ?? ''; && ($childParsed['view'] ?? '') === $currentView;
$childLiClass = 'item mokosuiteclient-menu-child' . ($childActive ? ' mm-active' : '');
$childActive = false;
if ($childOption === $currentOption)
{
$childActive = empty($childView)
? ($currentView === '' || $currentView === 'dashboard')
: ($currentView === $childView);
}
$childLiClass = 'item mokosuiteclient-ext-child' . ($childActive ? ' mm-active' : '');
$childAClass = 'no-dropdown' . ($childActive ? ' mm-active' : ''); $childAClass = 'no-dropdown' . ($childActive ? ' mm-active' : '');
?> ?>
<li class="<?php echo $childLiClass; ?>"> <li class="<?php echo $childLiClass; ?>">
@@ -198,4 +185,7 @@ foreach ($rest as $comp)
<?php endif; ?> <?php endif; ?>
</li> </li>
<?php endforeach; ?> <?php endforeach; ?>
</ul>
</li>
</ul> </ul>
@@ -22,7 +22,7 @@
* DEFGROUP: Joomla.Plugin * DEFGROUP: Joomla.Plugin
* INGROUP: MokoSuiteClient * INGROUP: MokoSuiteClient
* REPO: https://github.com/mokoconsulting-tech/mokosuiteclient * REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
* VERSION: 02.34.83 * VERSION: 02.34.50
* PATH: /src/Extension/MokoSuiteClient.php * PATH: /src/Extension/MokoSuiteClient.php
* NOTE: Core system plugin for MokoSuiteClient admin tools suite * NOTE: Core system plugin for MokoSuiteClient admin tools suite
*/ */
@@ -163,7 +163,6 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface
{ {
$this->handleOneTimeLogin(); $this->handleOneTimeLogin();
$this->checkSetupRequired(); $this->checkSetupRequired();
$this->ensureAdminModulesActive();
} }
} }
@@ -2158,238 +2157,19 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Admin Module Self-Healing // Download Key Preservation
// ------------------------------------------------------------------ // ------------------------------------------------------------------
/** /**
* Ensure MokoSuiteClient admin modules are published with correct positions. * Preserve download keys across Joomla extension updates.
* *
* Runs once per session to self-heal if modules were accidentally * Joomla's installer can wipe the extra_query column (which holds
* unpublished or had their position cleared. * download keys / dlid) when rebuilding or reinstalling update sites.
* This method keeps a backup of all non-empty extra_query values and
* restores any that get cleared.
*
* @return void
*
* @since 02.34.12
*/ */
private function ensureAdminModulesActive(): void
{
$session = \Joomla\CMS\Factory::getSession();
if ($session->get('mokosuiteclient.modules_checked', false))
{
return;
}
$session->set('mokosuiteclient.modules_checked', true);
$modules = [
'mod_mokosuiteclient_cpanel' => ['position' => 'top', 'title' => 'MokoSuiteClient', 'access' => 6, 'ordering' => 0],
'mod_mokosuiteclient_menu' => ['position' => 'menu', 'title' => 'MokoSuiteClient Menu', 'access' => 3, 'ordering' => 0],
'mod_mokosuiteclient_cache' => ['position' => 'status', 'title' => 'MokoSuiteClient Cache Cleaner', 'access' => 3, 'ordering' => 0],
];
try
{
$db = \Joomla\CMS\Factory::getDbo();
$app = \Joomla\CMS\Factory::getApplication();
foreach ($modules as $element => $config)
{
// Check if extension is installed
$db->setQuery(
$db->getQuery(true)
->select('extension_id')
->from('#__extensions')
->where('element = ' . $db->quote($element))
->where('type = ' . $db->quote('module'))
);
if (!(int) $db->loadResult()) continue;
// Find existing module instance
$db->setQuery(
$db->getQuery(true)
->select('id, published, position')
->from('#__modules')
->where('module = ' . $db->quote($element))
->where('client_id = 1')
->setLimit(1)
);
$mod = $db->loadObject();
$model = $app->bootComponent('com_modules')
->getMVCFactory()
->createModel('Module', 'Administrator', ['ignore_request' => true]);
if ($mod)
{
// Check if repair needed
$needsFix = (int) $mod->published !== 1 || $mod->position !== $config['position'];
if (!$needsFix)
{
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from('#__modules_menu')
->where('moduleid = ' . (int) $mod->id)
);
$needsFix = (int) $db->loadResult() === 0;
}
if ($needsFix)
{
$data = $model->getItem($mod->id)->getProperties();
$data['published'] = 1;
$data['position'] = $config['position'];
$data['ordering'] = $config['ordering'] ?? 0;
$data['assignment'] = 0;
$model->save($data);
}
// Ensure module is first in its position
$db->setQuery(
$db->getQuery(true)
->select('MIN(' . $db->quoteName('ordering') . ')')
->from('#__modules')
->where($db->quoteName('position') . ' = ' . $db->quote($config['position']))
->where($db->quoteName('client_id') . ' = 1')
->where($db->quoteName('id') . ' != ' . (int) $mod->id)
);
$minOther = $db->loadResult();
if ($minOther !== null)
{
// Re-read current ordering for this module
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('ordering'))
->from('#__modules')
->where($db->quoteName('id') . ' = ' . (int) $mod->id)
);
$currentOrdering = (int) $db->loadResult();
if ($currentOrdering >= (int) $minOther)
{
$newOrdering = (int) $minOther - 1;
$data = $model->getItem($mod->id)->getProperties();
$data['ordering'] = $newOrdering;
$data['assignment'] = 0;
$model->save($data);
}
}
}
else
{
// Module instance deleted — recreate it
$data = [
'title' => $config['title'],
'module' => $element,
'position' => $config['position'],
'published' => 1,
'access' => $config['access'],
'ordering' => $config['ordering'] ?? 0,
'showtitle' => 0,
'client_id' => 1,
'language' => '*',
'params' => '{}',
'assignment' => 0,
];
$model->save($data);
}
}
}
catch (\Throwable $e)
{
// Silent — don't break the admin if self-heal fails
}
}
// ------------------------------------------------------------------
// Automation Engine Event Hooks (#151)
// ------------------------------------------------------------------
/**
* Fire automation rules for user registration.
*/
public function onUserAfterSave($user, $isnew, $success, $msg): void
{
if (!$isnew || !$success) return;
class_exists(\Moko\Component\MokoSuiteClient\Administrator\Service\AutomationEngine::class, true) && \Moko\Component\MokoSuiteClient\Administrator\Service\AutomationEngine::fire('user_register', [
'user_id' => (int) ($user['id'] ?? 0),
'username' => $user['username'] ?? '',
'email' => $user['email'] ?? '',
'name' => $user['name'] ?? '',
]);
}
/**
* Fire automation rules on article save.
*/
public function onContentAfterSave($context, $article, $isNew): void
{
if ($context !== 'com_content.article') return;
class_exists(\Moko\Component\MokoSuiteClient\Administrator\Service\AutomationEngine::class, true) && \Moko\Component\MokoSuiteClient\Administrator\Service\AutomationEngine::fire('content_save', [
'article_id' => (int) ($article->id ?? 0),
'title' => $article->title ?? '',
'is_new' => $isNew ? '1' : '0',
'catid' => (int) ($article->catid ?? 0),
'user_id' => (int) ($article->modified_by ?? $article->created_by ?? 0),
]);
}
// ------------------------------------------------------------------
// Security Event Notifications (#147)
// ------------------------------------------------------------------
/**
* Notify on successful admin login.
*/
public function onUserAfterLogin($options): void
{
if (!($options['user'] ?? null)) return;
$user = $options['user'];
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$name = $user->username ?? $user->name ?? 'unknown';
// Fire automation for any login
class_exists(\Moko\Component\MokoSuiteClient\Administrator\Service\AutomationEngine::class, true) && \Moko\Component\MokoSuiteClient\Administrator\Service\AutomationEngine::fire('user_login', [
'user_id' => (int) ($user->id ?? 0),
'username' => $name,
'ip' => $ip,
'client' => $this->app->isClient('administrator') ? 'admin' : 'site',
]);
// Security notification for backend logins only
if (!$this->app->isClient('administrator')) return;
class_exists(\Moko\Component\MokoSuiteClient\Administrator\Service\NotificationService::class, true) && \Moko\Component\MokoSuiteClient\Administrator\Service\NotificationService::securityAlert(
'admin_login',
"Admin login: {$name}",
"User: {$name}\nIP: {$ip}\nTime: " . gmdate('Y-m-d H:i:s') . " UTC"
);
}
/**
* Track failed login attempts and notify after threshold.
*/
public function onUserLoginFailure($response): void
{
if (!$this->app->isClient('administrator')) return;
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$username = $response['username'] ?? 'unknown';
// Track in session — notify after 3 failures from same IP
$session = \Joomla\CMS\Factory::getSession();
$key = 'mokosuiteclient.login_failures.' . md5($ip);
$count = (int) $session->get($key, 0) + 1;
$session->set($key, $count);
if ($count >= 3 && $count % 3 === 0)
{
class_exists(\Moko\Component\MokoSuiteClient\Administrator\Service\NotificationService::class, true) && \Moko\Component\MokoSuiteClient\Administrator\Service\NotificationService::securityAlert(
'login_failure',
"Failed login attempts: {$count} from {$ip}",
"Username: {$username}\nIP: {$ip}\nAttempts: {$count}\nTime: " . gmdate('Y-m-d H:i:s') . " UTC"
);
}
}
} }
@@ -8,7 +8,7 @@
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: Joomla.Plugin * DEFGROUP: Joomla.Plugin
* INGROUP: MokoSuiteClient * INGROUP: MokoSuiteClient
* VERSION: 02.34.83 * VERSION: 02.34.50
* PATH: /src/Field/CopyableTokenField.php * PATH: /src/Field/CopyableTokenField.php
* BRIEF: Read-only token field with a copy-to-clipboard button * BRIEF: Read-only token field with a copy-to-clipboard button
*/ */
@@ -30,7 +30,7 @@
<license>GNU General Public License version 3 or later; see LICENSE.md</license> <license>GNU General Public License version 3 or later; see LICENSE.md</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl> <authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.83-dev</version> <version>02.34.50-dev</version>
<description>MokoSuiteClient core system plugin — coordinates feature plugins, heartbeat, health checks, and admin customizations.</description> <description>MokoSuiteClient core system plugin — coordinates feature plugins, heartbeat, health checks, and admin customizations.</description>
<namespace path=".">Moko\Plugin\System\MokoSuiteClient</namespace> <namespace path=".">Moko\Plugin\System\MokoSuiteClient</namespace>
<scriptfile>script.php</scriptfile> <scriptfile>script.php</scriptfile>
@@ -68,13 +68,13 @@
addfieldprefix="Moko\Plugin\System\MokoSuiteClient\Field" addfieldprefix="Moko\Plugin\System\MokoSuiteClient\Field"
> >
<fieldset name="basic" <fieldset name="basic"
label="PLG_SYSTEM_MOKOSUITE_FIELDSET_CORE_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_FIELDSET_CORE_LABEL"
description="PLG_SYSTEM_MOKOSUITE_FIELDSET_CORE_DESC"> description="PLG_SYSTEM_MOKOSUITECLIENT_FIELDSET_CORE_DESC">
<field <field
name="health_api_token" name="health_api_token"
type="CopyableToken" type="CopyableToken"
label="PLG_SYSTEM_MOKOSUITE_HEALTH_TOKEN_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_HEALTH_TOKEN_LABEL"
description="PLG_SYSTEM_MOKOSUITE_HEALTH_TOKEN_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_HEALTH_TOKEN_DESC"
default="" default=""
filter="raw" filter="raw"
readonly="true" readonly="true"
@@ -22,7 +22,7 @@
* DEFGROUP: Joomla.Plugin * DEFGROUP: Joomla.Plugin
* INGROUP: MokoSuiteClient * INGROUP: MokoSuiteClient
* REPO: https://github.com/mokoconsulting-tech/mokosuiteclient * REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
* VERSION: 02.34.83 * VERSION: 02.34.50
* PATH: /src/script.php * PATH: /src/script.php
* BRIEF: Installation script for MokoSuiteClient plugin * BRIEF: Installation script for MokoSuiteClient plugin
* NOTE: Handles installation, update, and uninstallation tasks including language override deployment * NOTE: Handles installation, update, and uninstallation tasks including language override deployment
@@ -767,7 +767,7 @@ class plgSystemMokoSuiteClientInstallerScript implements InstallerScriptInterfac
'id_holder' => '', 'id_holder' => '',
'title_holder' => '', 'title_holder' => '',
'table_name' => '', 'table_name' => '',
'text_prefix' => 'PLG_SYSTEM_MOKOSUITE', 'text_prefix' => 'PLG_SYSTEM_MOKOSUITECLIENT',
]; ];
$db->insertObject('#__action_log_config', $config); $db->insertObject('#__action_log_config', $config);
@@ -22,7 +22,7 @@
* DEFGROUP: Joomla.Plugin * DEFGROUP: Joomla.Plugin
* INGROUP: MokoSuiteClient * INGROUP: MokoSuiteClient
* REPO: https://github.com/mokoconsulting-tech/mokosuiteclient * REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
* VERSION: 02.34.83 * VERSION: 02.34.50
* PATH: /src/services/provider.php * PATH: /src/services/provider.php
* BRIEF: Service provider for dependency injection in Joomla 5.x * BRIEF: Service provider for dependency injection in Joomla 5.x
* NOTE: Registers the plugin with Joomla's DI container * NOTE: Registers the plugin with Joomla's DI container
@@ -1,29 +0,0 @@
; MokoSuiteClient DB-IP Plugin
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GPL-3.0-or-later
; IP Geolocation by DB-IP — https://db-ip.com
PLG_SYSTEM_MOKOSUITE_DBIP="System - MokoSuiteClient DB-IP"
PLG_SYSTEM_MOKOSUITE_DBIP_DESC="IP geolocation for MokoSuiteClient using DB-IP Lite databases. Ships with country-level data; city-level data is downloaded from CDN or loaded from a local file."
PLG_SYSTEM_MOKOSUITE_DBIP_FIELDSET_BASIC="DB-IP Settings"
PLG_SYSTEM_MOKOSUITE_DBIP_FIELDSET_BASIC_DESC="Configure IP geolocation database source and level."
PLG_SYSTEM_MOKOSUITE_DBIP_SOURCE_LABEL="Database Source"
PLG_SYSTEM_MOKOSUITE_DBIP_SOURCE_DESC="CDN downloads the city database automatically from the configured URL. Local uses a MMDB file you provide on the server."
PLG_SYSTEM_MOKOSUITE_DBIP_SOURCE_CDN="CDN (auto-download)"
PLG_SYSTEM_MOKOSUITE_DBIP_SOURCE_LOCAL="Local file"
PLG_SYSTEM_MOKOSUITE_DBIP_DATABASE_LEVEL_LABEL="Database Level"
PLG_SYSTEM_MOKOSUITE_DBIP_DATABASE_LEVEL_DESC="Country is bundled (~8 MB). City provides region, city, and coordinates but requires a separate download (~125 MB)."
PLG_SYSTEM_MOKOSUITE_DBIP_DATABASE_COUNTRY="Country (bundled)"
PLG_SYSTEM_MOKOSUITE_DBIP_DATABASE_CITY="City (remote download)"
PLG_SYSTEM_MOKOSUITE_DBIP_AUTO_UPDATE_LABEL="Auto-Update Database"
PLG_SYSTEM_MOKOSUITE_DBIP_AUTO_UPDATE_DESC="Automatically download the latest city database monthly when an admin visits the backend."
PLG_SYSTEM_MOKOSUITE_DBIP_CDN_URL_LABEL="CDN Download URL"
PLG_SYSTEM_MOKOSUITE_DBIP_CDN_URL_DESC="URL to download the city-level MMDB file. Default points to the MokoConsulting geoip-data repository."
PLG_SYSTEM_MOKOSUITE_DBIP_LOCAL_PATH_LABEL="Local MMDB Path"
PLG_SYSTEM_MOKOSUITE_DBIP_LOCAL_PATH_DESC="Absolute path to a DB-IP MMDB file on the server (e.g. /home/user/dbip-city-lite.mmdb)."
@@ -1,6 +0,0 @@
; MokoSuiteClient DB-IP Plugin (system strings)
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GPL-3.0-or-later
PLG_SYSTEM_MOKOSUITE_DBIP="System - MokoSuiteClient DB-IP"
PLG_SYSTEM_MOKOSUITE_DBIP_DESC="IP geolocation for MokoSuiteClient using DB-IP Lite databases."
@@ -1,404 +0,0 @@
<?php
declare(strict_types=1);
namespace MaxMind\Db;
use MaxMind\Db\Reader\Decoder;
use MaxMind\Db\Reader\InvalidDatabaseException;
use MaxMind\Db\Reader\Metadata;
use MaxMind\Db\Reader\Util;
/**
* Instances of this class provide a reader for the MaxMind DB format. IP
* addresses can be looked up using the get method.
*/
class Reader
{
/**
* @var int
*/
private static $DATA_SECTION_SEPARATOR_SIZE = 16;
/**
* @var string
*/
private static $METADATA_START_MARKER = "\xAB\xCD\xEFMaxMind.com";
/**
* @var int<0, max>
*/
private static $METADATA_START_MARKER_LENGTH = 14;
/**
* @var int
*/
private static $METADATA_MAX_SIZE = 131072; // 128 * 1024 = 128KiB
/**
* @var Decoder
*/
private $decoder;
/**
* @var resource
*/
private $fileHandle;
/**
* @var int
*/
private $fileSize;
/**
* @var int
*/
private $ipV4Start;
/**
* @var Metadata
*/
private $metadata;
/**
* Constructs a Reader for the MaxMind DB format. The file passed to it must
* be a valid MaxMind DB file such as a DBIP database file.
*
* @param string $database the MaxMind DB file to use
*
* @throws \InvalidArgumentException for invalid database path or unknown arguments
* @throws InvalidDatabaseException
* if the database is invalid or there is an error reading
* from it
*/
public function __construct(string $database)
{
if (\func_num_args() !== 1) {
throw new \ArgumentCountError(
\sprintf('%s() expects exactly 1 parameter, %d given', __METHOD__, \func_num_args())
);
}
if (is_dir($database)) {
// This matches the error that the C extension throws.
throw new InvalidDatabaseException(
"Error opening database file ($database). Is this a valid MaxMind DB file?"
);
}
$fileHandle = @fopen($database, 'rb');
if ($fileHandle === false) {
throw new \InvalidArgumentException(
"The file \"$database\" does not exist or is not readable."
);
}
$this->fileHandle = $fileHandle;
$fstat = fstat($fileHandle);
if ($fstat === false) {
throw new \UnexpectedValueException(
"Error determining the size of \"$database\"."
);
}
$this->fileSize = $fstat['size'];
$start = $this->findMetadataStart($database);
$metadataDecoder = new Decoder($this->fileHandle, $start);
[$metadataArray] = $metadataDecoder->decode($start);
$this->metadata = new Metadata($metadataArray);
$this->decoder = new Decoder(
$this->fileHandle,
$this->metadata->searchTreeSize + self::$DATA_SECTION_SEPARATOR_SIZE
);
$this->ipV4Start = $this->ipV4StartNode();
}
/**
* Retrieves the record for the IP address.
*
* @param string $ipAddress the IP address to look up
*
* @throws \BadMethodCallException if this method is called on a closed database
* @throws \InvalidArgumentException if something other than a single IP address is passed to the method
* @throws InvalidDatabaseException
* if the database is invalid or there is an error reading
* from it
*
* @return mixed the record for the IP address
*/
public function get(string $ipAddress)
{
if (\func_num_args() !== 1) {
throw new \ArgumentCountError(
\sprintf('%s() expects exactly 1 parameter, %d given', __METHOD__, \func_num_args())
);
}
[$record] = $this->getWithPrefixLen($ipAddress);
return $record;
}
/**
* Retrieves the record for the IP address and its associated network prefix length.
*
* @param string $ipAddress the IP address to look up
*
* @throws \BadMethodCallException if this method is called on a closed database
* @throws \InvalidArgumentException if something other than a single IP address is passed to the method
* @throws InvalidDatabaseException
* if the database is invalid or there is an error reading
* from it
*
* @return array{0:mixed, 1:int} an array where the first element is the record and the
* second the network prefix length for the record
*/
public function getWithPrefixLen(string $ipAddress): array
{
if (\func_num_args() !== 1) {
throw new \ArgumentCountError(
\sprintf('%s() expects exactly 1 parameter, %d given', __METHOD__, \func_num_args())
);
}
if (!\is_resource($this->fileHandle)) {
throw new \BadMethodCallException(
'Attempt to read from a closed MaxMind DB.'
);
}
[$pointer, $prefixLen] = $this->findAddressInTree($ipAddress);
if ($pointer === 0) {
return [null, $prefixLen];
}
return [$this->resolveDataPointer($pointer), $prefixLen];
}
/**
* @return array{0:int, 1:int}
*/
private function findAddressInTree(string $ipAddress): array
{
$packedAddr = @inet_pton($ipAddress);
if ($packedAddr === false) {
throw new \InvalidArgumentException(
"The value \"$ipAddress\" is not a valid IP address."
);
}
$rawAddress = unpack('C*', $packedAddr);
if ($rawAddress === false) {
throw new InvalidDatabaseException(
'Could not unpack the unsigned char of the packed in_addr representation.'
);
}
$bitCount = \count($rawAddress) * 8;
// The first node of the tree is always node 0, at the beginning of the
// value
$node = 0;
$metadata = $this->metadata;
// Check if we are looking up an IPv4 address in an IPv6 tree. If this
// is the case, we can skip over the first 96 nodes.
if ($metadata->ipVersion === 6) {
if ($bitCount === 32) {
$node = $this->ipV4Start;
}
} elseif ($metadata->ipVersion === 4 && $bitCount === 128) {
throw new \InvalidArgumentException(
"Error looking up $ipAddress. You attempted to look up an"
. ' IPv6 address in an IPv4-only database.'
);
}
$nodeCount = $metadata->nodeCount;
for ($i = 0; $i < $bitCount && $node < $nodeCount; ++$i) {
$tempBit = 0xFF & $rawAddress[($i >> 3) + 1];
$bit = 1 & ($tempBit >> 7 - ($i % 8));
$node = $this->readNode($node, $bit);
}
if ($node === $nodeCount) {
// Record is empty
return [0, $i];
}
if ($node > $nodeCount) {
// Record is a data pointer
return [$node, $i];
}
throw new InvalidDatabaseException(
'Invalid or corrupt database. Maximum search depth reached without finding a leaf node'
);
}
private function ipV4StartNode(): int
{
// If we have an IPv4 database, the start node is the first node
if ($this->metadata->ipVersion === 4) {
return 0;
}
$node = 0;
for ($i = 0; $i < 96 && $node < $this->metadata->nodeCount; ++$i) {
$node = $this->readNode($node, 0);
}
return $node;
}
private function readNode(int $nodeNumber, int $index): int
{
$baseOffset = $nodeNumber * $this->metadata->nodeByteSize;
switch ($this->metadata->recordSize) {
case 24:
$bytes = Util::read($this->fileHandle, $baseOffset + $index * 3, 3);
$rc = unpack('N', "\x00" . $bytes);
if ($rc === false) {
throw new InvalidDatabaseException(
'Could not unpack the unsigned long of the node.'
);
}
[, $node] = $rc;
return $node;
case 28:
$bytes = Util::read($this->fileHandle, $baseOffset + 3 * $index, 4);
if ($index === 0) {
$middle = (0xF0 & \ord($bytes[3])) >> 4;
} else {
$middle = 0x0F & \ord($bytes[0]);
}
$rc = unpack('N', \chr($middle) . substr($bytes, $index, 3));
if ($rc === false) {
throw new InvalidDatabaseException(
'Could not unpack the unsigned long of the node.'
);
}
[, $node] = $rc;
return $node;
case 32:
$bytes = Util::read($this->fileHandle, $baseOffset + $index * 4, 4);
$rc = unpack('N', $bytes);
if ($rc === false) {
throw new InvalidDatabaseException(
'Could not unpack the unsigned long of the node.'
);
}
[, $node] = $rc;
return $node;
default:
throw new InvalidDatabaseException(
'Unknown record size: '
. $this->metadata->recordSize
);
}
}
/**
* @return mixed
*/
private function resolveDataPointer(int $pointer)
{
$resolved = $pointer - $this->metadata->nodeCount
+ $this->metadata->searchTreeSize;
if ($resolved >= $this->fileSize) {
throw new InvalidDatabaseException(
"The MaxMind DB file's search tree is corrupt"
);
}
[$data] = $this->decoder->decode($resolved);
return $data;
}
/*
* This is an extremely naive but reasonably readable implementation. There
* are much faster algorithms (e.g., Boyer-Moore) for this if speed is ever
* an issue, but I suspect it won't be.
*/
private function findMetadataStart(string $filename): int
{
$handle = $this->fileHandle;
$fileSize = $this->fileSize;
$marker = self::$METADATA_START_MARKER;
$markerLength = self::$METADATA_START_MARKER_LENGTH;
$minStart = $fileSize - min(self::$METADATA_MAX_SIZE, $fileSize);
for ($offset = $fileSize - $markerLength; $offset >= $minStart; --$offset) {
if (fseek($handle, $offset) !== 0) {
break;
}
$value = fread($handle, $markerLength);
if ($value === $marker) {
return $offset + $markerLength;
}
}
throw new InvalidDatabaseException(
"Error opening database file ($filename). "
. 'Is this a valid MaxMind DB file?'
);
}
/**
* @throws \InvalidArgumentException if arguments are passed to the method
* @throws \BadMethodCallException if the database has been closed
*
* @return Metadata object for the database
*/
public function metadata(): Metadata
{
if (\func_num_args()) {
throw new \ArgumentCountError(
\sprintf('%s() expects exactly 0 parameters, %d given', __METHOD__, \func_num_args())
);
}
// Not technically required, but this makes it consistent with
// C extension and it allows us to change our implementation later.
if (!\is_resource($this->fileHandle)) {
throw new \BadMethodCallException(
'Attempt to read from a closed MaxMind DB.'
);
}
return clone $this->metadata;
}
/**
* Closes the MaxMind DB and returns resources to the system.
*
* @throws \Exception
* if an I/O error occurs
*/
public function close(): void
{
if (\func_num_args()) {
throw new \ArgumentCountError(
\sprintf('%s() expects exactly 0 parameters, %d given', __METHOD__, \func_num_args())
);
}
if (!\is_resource($this->fileHandle)) {
throw new \BadMethodCallException(
'Attempt to close a closed MaxMind DB.'
);
}
fclose($this->fileHandle);
}
}
@@ -1,452 +0,0 @@
<?php
declare(strict_types=1);
namespace MaxMind\Db\Reader;
// @codingStandardsIgnoreLine
class Decoder
{
/**
* @var resource
*/
private $fileStream;
/**
* @var int
*/
private $pointerBase;
/**
* This is only used for unit testing.
*
* @var bool
*/
private $pointerTestHack;
/**
* @var bool
*/
private $switchByteOrder;
private const _EXTENDED = 0;
private const _POINTER = 1;
private const _UTF8_STRING = 2;
private const _DOUBLE = 3;
private const _BYTES = 4;
private const _UINT16 = 5;
private const _UINT32 = 6;
private const _MAP = 7;
private const _INT32 = 8;
private const _UINT64 = 9;
private const _UINT128 = 10;
private const _ARRAY = 11;
// 12 is the container type
// 13 is the end marker type
private const _BOOLEAN = 14;
private const _FLOAT = 15;
/**
* @param resource $fileStream
*/
public function __construct(
$fileStream,
int $pointerBase = 0,
bool $pointerTestHack = false
) {
$this->fileStream = $fileStream;
$this->pointerBase = $pointerBase;
$this->pointerTestHack = $pointerTestHack;
$this->switchByteOrder = $this->isPlatformLittleEndian();
}
/**
* @return array<mixed>
*/
public function decode(int $offset): array
{
$ctrlByte = \ord(Util::read($this->fileStream, $offset, 1));
++$offset;
$type = $ctrlByte >> 5;
// Pointers are a special case, we don't read the next $size bytes, we
// use the size to determine the length of the pointer and then follow
// it.
if ($type === self::_POINTER) {
[$pointer, $offset] = $this->decodePointer($ctrlByte, $offset);
// for unit testing
if ($this->pointerTestHack) {
return [$pointer];
}
[$result] = $this->decode($pointer);
return [$result, $offset];
}
if ($type === self::_EXTENDED) {
$nextByte = \ord(Util::read($this->fileStream, $offset, 1));
$type = $nextByte + 7;
if ($type < 8) {
throw new InvalidDatabaseException(
'Something went horribly wrong in the decoder. An extended type '
. 'resolved to a type number < 8 ('
. $type
. ')'
);
}
++$offset;
}
[$size, $offset] = $this->sizeFromCtrlByte($ctrlByte, $offset);
return $this->decodeByType($type, $offset, $size);
}
/**
* @param int<0, max> $size
*
* @return array{0:mixed, 1:int}
*/
private function decodeByType(int $type, int $offset, int $size): array
{
switch ($type) {
case self::_MAP:
return $this->decodeMap($size, $offset);
case self::_ARRAY:
return $this->decodeArray($size, $offset);
case self::_BOOLEAN:
return [$this->decodeBoolean($size), $offset];
}
$newOffset = $offset + $size;
$bytes = Util::read($this->fileStream, $offset, $size);
switch ($type) {
case self::_BYTES:
case self::_UTF8_STRING:
return [$bytes, $newOffset];
case self::_DOUBLE:
$this->verifySize(8, $size);
return [$this->decodeDouble($bytes), $newOffset];
case self::_FLOAT:
$this->verifySize(4, $size);
return [$this->decodeFloat($bytes), $newOffset];
case self::_INT32:
return [$this->decodeInt32($bytes, $size), $newOffset];
case self::_UINT16:
case self::_UINT32:
case self::_UINT64:
case self::_UINT128:
return [$this->decodeUint($bytes, $size), $newOffset];
default:
throw new InvalidDatabaseException(
'Unknown or unexpected type: ' . $type
);
}
}
private function verifySize(int $expected, int $actual): void
{
if ($expected !== $actual) {
throw new InvalidDatabaseException(
"The MaxMind DB file's data section contains bad data (unknown data type or corrupt data)"
);
}
}
/**
* @return array{0:array<mixed>, 1:int}
*/
private function decodeArray(int $size, int $offset): array
{
$array = [];
for ($i = 0; $i < $size; ++$i) {
[$value, $offset] = $this->decode($offset);
$array[] = $value;
}
return [$array, $offset];
}
private function decodeBoolean(int $size): bool
{
return $size !== 0;
}
private function decodeDouble(string $bytes): float
{
// This assumes IEEE 754 doubles, but most (all?) modern platforms
// use them.
$rc = unpack('E', $bytes);
if ($rc === false) {
throw new InvalidDatabaseException(
'Could not unpack a double value from the given bytes.'
);
}
[, $double] = $rc;
return $double;
}
private function decodeFloat(string $bytes): float
{
// This assumes IEEE 754 floats, but most (all?) modern platforms
// use them.
$rc = unpack('G', $bytes);
if ($rc === false) {
throw new InvalidDatabaseException(
'Could not unpack a float value from the given bytes.'
);
}
[, $float] = $rc;
return $float;
}
private function decodeInt32(string $bytes, int $size): int
{
switch ($size) {
case 0:
return 0;
case 1:
case 2:
case 3:
$bytes = str_pad($bytes, 4, "\x00", \STR_PAD_LEFT);
break;
case 4:
break;
default:
throw new InvalidDatabaseException(
"The MaxMind DB file's data section contains bad data (unknown data type or corrupt data)"
);
}
$rc = unpack('l', $this->maybeSwitchByteOrder($bytes));
if ($rc === false) {
throw new InvalidDatabaseException(
'Could not unpack a 32bit integer value from the given bytes.'
);
}
[, $int] = $rc;
return $int;
}
/**
* @return array{0:array<string, mixed>, 1:int}
*/
private function decodeMap(int $size, int $offset): array
{
$map = [];
for ($i = 0; $i < $size; ++$i) {
[$key, $offset] = $this->decode($offset);
[$value, $offset] = $this->decode($offset);
$map[$key] = $value;
}
return [$map, $offset];
}
/**
* @return array{0:int, 1:int}
*/
private function decodePointer(int $ctrlByte, int $offset): array
{
$pointerSize = (($ctrlByte >> 3) & 0x3) + 1;
$buffer = Util::read($this->fileStream, $offset, $pointerSize);
$offset += $pointerSize;
switch ($pointerSize) {
case 1:
$packed = \chr($ctrlByte & 0x7) . $buffer;
$rc = unpack('n', $packed);
if ($rc === false) {
throw new InvalidDatabaseException(
'Could not unpack an unsigned short value from the given bytes (pointerSize is 1).'
);
}
[, $pointer] = $rc;
$pointer += $this->pointerBase;
break;
case 2:
$packed = "\x00" . \chr($ctrlByte & 0x7) . $buffer;
$rc = unpack('N', $packed);
if ($rc === false) {
throw new InvalidDatabaseException(
'Could not unpack an unsigned long value from the given bytes (pointerSize is 2).'
);
}
[, $pointer] = $rc;
$pointer += $this->pointerBase + 2048;
break;
case 3:
$packed = \chr($ctrlByte & 0x7) . $buffer;
// It is safe to use 'N' here, even on 32 bit machines as the
// first bit is 0.
$rc = unpack('N', $packed);
if ($rc === false) {
throw new InvalidDatabaseException(
'Could not unpack an unsigned long value from the given bytes (pointerSize is 3).'
);
}
[, $pointer] = $rc;
$pointer += $this->pointerBase + 526336;
break;
case 4:
// We cannot use unpack here as we might overflow on 32 bit
// machines
$pointerOffset = $this->decodeUint($buffer, $pointerSize);
$pointerBase = $this->pointerBase;
if (\PHP_INT_MAX - $pointerBase >= $pointerOffset) {
$pointer = $pointerOffset + $pointerBase;
} else {
throw new \RuntimeException(
'The database offset is too large to be represented on your platform.'
);
}
break;
default:
throw new InvalidDatabaseException(
'Unexpected pointer size ' . $pointerSize
);
}
return [$pointer, $offset];
}
// @phpstan-ignore-next-line
private function decodeUint(string $bytes, int $byteLength)
{
if ($byteLength === 0) {
return 0;
}
// PHP integers are signed. PHP_INT_SIZE - 1 is the number of
// complete bytes that can be converted to an integer. However,
// we can convert another byte if the leading bit is zero.
$useRealInts = $byteLength <= \PHP_INT_SIZE - 1
|| ($byteLength === \PHP_INT_SIZE && (\ord($bytes[0]) & 0x80) === 0);
if ($useRealInts) {
$integer = 0;
for ($i = 0; $i < $byteLength; ++$i) {
$part = \ord($bytes[$i]);
$integer = ($integer << 8) + $part;
}
return $integer;
}
// We only use gmp or bcmath if the final value is too big
$integerAsString = '0';
for ($i = 0; $i < $byteLength; ++$i) {
$part = \ord($bytes[$i]);
if (\extension_loaded('gmp')) {
$integerAsString = gmp_strval(gmp_add(gmp_mul($integerAsString, '256'), $part));
} elseif (\extension_loaded('bcmath')) {
$integerAsString = bcadd(bcmul($integerAsString, '256'), (string) $part);
} else {
throw new \RuntimeException(
'The gmp or bcmath extension must be installed to read this database.'
);
}
}
return $integerAsString;
}
/**
* @return array{0:int, 1:int}
*/
private function sizeFromCtrlByte(int $ctrlByte, int $offset): array
{
$size = $ctrlByte & 0x1F;
if ($size < 29) {
return [$size, $offset];
}
$bytesToRead = $size - 28;
$bytes = Util::read($this->fileStream, $offset, $bytesToRead);
if ($size === 29) {
$size = 29 + \ord($bytes);
} elseif ($size === 30) {
$rc = unpack('n', $bytes);
if ($rc === false) {
throw new InvalidDatabaseException(
'Could not unpack an unsigned short value from the given bytes.'
);
}
[, $adjust] = $rc;
$size = 285 + $adjust;
} else {
$rc = unpack('N', "\x00" . $bytes);
if ($rc === false) {
throw new InvalidDatabaseException(
'Could not unpack an unsigned long value from the given bytes.'
);
}
[, $adjust] = $rc;
$size = $adjust + 65821;
}
return [$size, $offset + $bytesToRead];
}
private function maybeSwitchByteOrder(string $bytes): string
{
return $this->switchByteOrder ? strrev($bytes) : $bytes;
}
private function isPlatformLittleEndian(): bool
{
$testint = 0x00FF;
$packed = pack('S', $testint);
$rc = unpack('v', $packed);
if ($rc === false) {
throw new InvalidDatabaseException(
'Could not unpack an unsigned short value from the given bytes.'
);
}
return $testint === current($rc);
}
}
@@ -1,11 +0,0 @@
<?php
declare(strict_types=1);
namespace MaxMind\Db\Reader;
/**
* This class should be thrown when unexpected data is found in the database.
*/
// phpcs:disable
class InvalidDatabaseException extends \Exception {}
@@ -1,123 +0,0 @@
<?php
declare(strict_types=1);
namespace MaxMind\Db\Reader;
/**
* This class provides the metadata for the MaxMind DB file.
*/
class Metadata
{
/**
* This is an unsigned 16-bit integer indicating the major version number
* for the database's binary format.
*
* @var int
*/
public $binaryFormatMajorVersion;
/**
* This is an unsigned 16-bit integer indicating the minor version number
* for the database's binary format.
*
* @var int
*/
public $binaryFormatMinorVersion;
/**
* This is an unsigned 64-bit integer that contains the database build
* timestamp as a Unix epoch value.
*
* @var int
*/
public $buildEpoch;
/**
* This is a string that indicates the structure of each data record
* associated with an IP address. The actual definition of these
* structures is left up to the database creator.
*
* @var string
*/
public $databaseType;
/**
* This key will always point to a map (associative array). The keys of
* that map will be language codes, and the values will be a description
* in that language as a UTF-8 string. May be undefined for some
* databases.
*
* @var array<string, string>
*/
public $description;
/**
* This is an unsigned 16-bit integer which is always 4 or 6. It indicates
* whether the database contains IPv4 or IPv6 address data.
*
* @var int
*/
public $ipVersion;
/**
* An array of strings, each of which is a language code. A given record
* may contain data items that have been localized to some or all of
* these languages. This may be undefined.
*
* @var array<string>
*/
public $languages;
/**
* @var int
*/
public $nodeByteSize;
/**
* This is an unsigned 32-bit integer indicating the number of nodes in
* the search tree.
*
* @var int
*/
public $nodeCount;
/**
* This is an unsigned 16-bit integer. It indicates the number of bits in a
* record in the search tree. Note that each node consists of two records.
*
* @var int
*/
public $recordSize;
/**
* @var int
*/
public $searchTreeSize;
/**
* @param array<string, mixed> $metadata
*/
public function __construct(array $metadata)
{
if (\func_num_args() !== 1) {
throw new \ArgumentCountError(
\sprintf('%s() expects exactly 1 parameter, %d given', __METHOD__, \func_num_args())
);
}
$this->binaryFormatMajorVersion
= $metadata['binary_format_major_version'];
$this->binaryFormatMinorVersion
= $metadata['binary_format_minor_version'];
$this->buildEpoch = $metadata['build_epoch'];
$this->databaseType = $metadata['database_type'];
$this->languages = $metadata['languages'];
$this->description = $metadata['description'];
$this->ipVersion = $metadata['ip_version'];
$this->nodeCount = $metadata['node_count'];
$this->recordSize = $metadata['record_size'];
$this->nodeByteSize = $this->recordSize / 4;
$this->searchTreeSize = $this->nodeCount * $this->nodeByteSize;
}
}
@@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace MaxMind\Db\Reader;
class Util
{
/**
* @param resource $stream
* @param int<0, max> $numberOfBytes
*/
public static function read($stream, int $offset, int $numberOfBytes): string
{
if ($numberOfBytes === 0) {
return '';
}
if (fseek($stream, $offset) === 0) {
$value = fread($stream, $numberOfBytes);
// We check that the number of bytes read is equal to the number
// asked for. We use ftell as getting the length of $value is
// much slower.
if ($value !== false && ftell($stream) - $offset === $numberOfBytes) {
return $value;
}
}
throw new InvalidDatabaseException(
'The MaxMind DB file contains bad data'
);
}
}
@@ -1,75 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuiteClient DB-IP</name>
<element>mokosuiteclient_dbip</element>
<author>Moko Consulting</author>
<creationDate>2026-06-07</creationDate>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.83-dev</version>
<description>PLG_SYSTEM_MOKOSUITE_DBIP_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoSuiteClientDBIP</namespace>
<files>
<folder>src</folder>
<folder>services</folder>
<folder>language</folder>
<folder>lib</folder>
<folder>data</folder>
</files>
<languages folder="language">
<language tag="en-GB">en-GB/plg_system_mokosuiteclient_dbip.ini</language>
<language tag="en-GB">en-GB/plg_system_mokosuiteclient_dbip.sys.ini</language>
</languages>
<config>
<fields name="params">
<fieldset name="basic"
label="PLG_SYSTEM_MOKOSUITE_DBIP_FIELDSET_BASIC"
description="PLG_SYSTEM_MOKOSUITE_DBIP_FIELDSET_BASIC_DESC">
<field name="database_source" type="list" default="cdn"
label="PLG_SYSTEM_MOKOSUITE_DBIP_SOURCE_LABEL"
description="PLG_SYSTEM_MOKOSUITE_DBIP_SOURCE_DESC">
<option value="cdn">PLG_SYSTEM_MOKOSUITE_DBIP_SOURCE_CDN</option>
<option value="local">PLG_SYSTEM_MOKOSUITE_DBIP_SOURCE_LOCAL</option>
</field>
<field name="database_level" type="list" default="country"
label="PLG_SYSTEM_MOKOSUITE_DBIP_DATABASE_LEVEL_LABEL"
description="PLG_SYSTEM_MOKOSUITE_DBIP_DATABASE_LEVEL_DESC">
<option value="country">PLG_SYSTEM_MOKOSUITE_DBIP_DATABASE_COUNTRY</option>
<option value="city">PLG_SYSTEM_MOKOSUITE_DBIP_DATABASE_CITY</option>
</field>
<field name="auto_update" type="radio" default="1"
label="PLG_SYSTEM_MOKOSUITE_DBIP_AUTO_UPDATE_LABEL"
description="PLG_SYSTEM_MOKOSUITE_DBIP_AUTO_UPDATE_DESC"
class="btn-group btn-group-yesno"
showon="database_source:cdn">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="cdn_url" type="url"
default="https://git.mokoconsulting.tech/MokoConsulting/geoip-data/releases/download/latest/dbip-city-lite.mmdb"
label="PLG_SYSTEM_MOKOSUITE_DBIP_CDN_URL_LABEL"
description="PLG_SYSTEM_MOKOSUITE_DBIP_CDN_URL_DESC"
filter="url"
showon="database_source:cdn" />
<field name="local_path" type="text"
default=""
label="PLG_SYSTEM_MOKOSUITE_DBIP_LOCAL_PATH_LABEL"
description="PLG_SYSTEM_MOKOSUITE_DBIP_LOCAL_PATH_DESC"
filter="path"
showon="database_source:local" />
<field name="last_updated" type="hidden" default="" filter="raw" />
</fieldset>
</fields>
</config>
</extension>
@@ -1,33 +0,0 @@
<?php
/**
* @package Moko.Plugin.System.MokoSuiteClientDBIP
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GPL-3.0-or-later
*/
defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Moko\Plugin\System\MokoSuiteClientDBIP\Extension\DBIP;
return new class implements ServiceProviderInterface
{
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
function (Container $container) {
$dispatcher = $container->get(DispatcherInterface::class);
$plugin = new DBIP($dispatcher, (array) PluginHelper::getPlugin('system', 'mokosuiteclient_dbip'));
$plugin->setApplication(Factory::getApplication());
return $plugin;
}
);
}
};
@@ -1,83 +0,0 @@
<?php
/**
* @package Moko.Plugin.System.MokoSuiteClientDBIP
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GPL-3.0-or-later
*
* IP Geolocation by DB-IP — https://db-ip.com
*/
namespace Moko\Plugin\System\MokoSuiteClientDBIP\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Event\SubscriberInterface;
use Moko\Plugin\System\MokoSuiteClientDBIP\Helper\DBIPHelper;
class DBIP extends CMSPlugin implements SubscriberInterface
{
protected $autoloadLanguage = true;
public static function getSubscribedEvents(): array
{
return [
'onAfterInitialise' => 'onAfterInitialise',
];
}
/**
* Initialize DB-IP: set local path if configured, auto-download city DB if needed.
*/
public function onAfterInitialise(): void
{
$source = $this->params->get('database_source', 'cdn');
$level = $this->params->get('database_level', 'country');
// If using a local MMDB file, configure the helper
if ($source === 'local')
{
$localPath = $this->params->get('local_path', '');
if ($localPath !== '')
{
DBIPHelper::setLocalPath($localPath);
}
return;
}
// CDN mode: auto-download city DB if selected and needed
if ($level !== 'city' || !$this->params->get('auto_update', 1))
{
return;
}
$cityPath = DBIPHelper::getCityDbPath();
if (file_exists($cityPath))
{
$age = time() - filemtime($cityPath);
if ($age < 86400 * 30)
{
return;
}
}
// Only download during admin page loads
$app = $this->getApplication();
if (!$app->isClient('administrator'))
{
return;
}
$url = $this->params->get(
'cdn_url',
'https://git.mokoconsulting.tech/MokoConsulting/geoip-data/releases/download/latest/dbip-city-lite.mmdb'
);
DBIPHelper::downloadCityDb($url);
}
}
@@ -1,269 +0,0 @@
<?php
/**
* @package Moko.Plugin.System.MokoSuiteClientDBIP
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GPL-3.0-or-later
*
* IP Geolocation by DB-IP — https://db-ip.com
*/
namespace Moko\Plugin\System\MokoSuiteClientDBIP\Helper;
defined('_JEXEC') or die;
use MaxMind\Db\Reader;
class DBIPHelper
{
private static ?Reader $countryReader = null;
private static ?Reader $cityReader = null;
private static bool $libLoaded = false;
private static string $customLocalPath = '';
/**
* Set a custom local path for the city database.
*/
public static function setLocalPath(string $path): void
{
self::$customLocalPath = $path;
}
/**
* Get the path to the bundled country database.
*/
public static function getCountryDbPath(): string
{
return JPATH_PLUGINS . '/system/mokosuiteclient_dbip/data/dbip-country-lite.mmdb';
}
/**
* Get the path to the city database.
* Uses custom local path if set, otherwise the CDN cache location.
*/
public static function getCityDbPath(): string
{
if (self::$customLocalPath !== '' && file_exists(self::$customLocalPath))
{
return self::$customLocalPath;
}
return JPATH_ADMINISTRATOR . '/cache/mokosuiteclient_dbip/dbip-city-lite.mmdb';
}
/**
* Load the MaxMind DB Reader library.
*/
private static function loadLib(): void
{
if (self::$libLoaded)
{
return;
}
$libPath = JPATH_PLUGINS . '/system/mokosuiteclient_dbip/lib';
require_once $libPath . '/MaxMind/Db/Reader.php';
require_once $libPath . '/MaxMind/Db/Reader/Decoder.php';
require_once $libPath . '/MaxMind/Db/Reader/InvalidDatabaseException.php';
require_once $libPath . '/MaxMind/Db/Reader/Metadata.php';
require_once $libPath . '/MaxMind/Db/Reader/Util.php';
self::$libLoaded = true;
}
/**
* Look up an IP address and return geolocation data.
*
* @param string $ip The IP address to look up.
*
* @return array|null Geolocation data or null if not found.
*
* Result keys (country DB): country_code, country_name, continent_code, continent_name
* Result keys (city DB): + region, city, latitude, longitude, timezone
*/
public static function lookup(string $ip): ?array
{
try
{
self::loadLib();
// Try city database first
$cityPath = self::getCityDbPath();
if (file_exists($cityPath))
{
if (self::$cityReader === null)
{
self::$cityReader = new Reader($cityPath);
}
$record = self::$cityReader->get($ip);
if ($record !== null)
{
return self::normalizeCityRecord($record);
}
}
// Fall back to bundled country database
$countryPath = self::getCountryDbPath();
if (file_exists($countryPath))
{
if (self::$countryReader === null)
{
self::$countryReader = new Reader($countryPath);
}
$record = self::$countryReader->get($ip);
if ($record !== null)
{
return self::normalizeCountryRecord($record);
}
}
}
catch (\Throwable $e)
{
// Silent — don't break the site if DB-IP fails
}
return null;
}
/**
* Look up country only (uses bundled DB, always available).
*/
public static function lookupCountry(string $ip): ?string
{
$result = self::lookup($ip);
return $result['country_code'] ?? null;
}
/**
* Check if the city database is installed.
*/
public static function hasCityDb(): bool
{
return file_exists(self::getCityDbPath());
}
/**
* Download the city database from the configured URL.
*
* @param string $url The download URL for the city MMDB file.
*
* @return bool True on success.
*/
public static function downloadCityDb(string $url): bool
{
$destPath = JPATH_ADMINISTRATOR . '/cache/mokosuiteclient_dbip/dbip-city-lite.mmdb';
$destDir = \dirname($destPath);
if (!is_dir($destDir))
{
mkdir($destDir, 0755, true);
}
$tmpFile = $destPath . '.tmp';
try
{
$ch = curl_init($url);
$fp = fopen($tmpFile, 'wb');
curl_setopt_array($ch, [
\CURLOPT_FILE => $fp,
\CURLOPT_FOLLOWLOCATION => true,
\CURLOPT_TIMEOUT => 300,
\CURLOPT_CONNECTTIMEOUT => 30,
\CURLOPT_USERAGENT => 'MokoSuiteClient-DBIP/1.0',
]);
$success = curl_exec($ch);
$code = curl_getinfo($ch, \CURLINFO_HTTP_CODE);
curl_close($ch);
fclose($fp);
if ($success && $code === 200 && filesize($tmpFile) > 1024)
{
if (self::$cityReader !== null)
{
self::$cityReader->close();
self::$cityReader = null;
}
rename($tmpFile, $destPath);
return true;
}
@unlink($tmpFile);
}
catch (\Throwable $e)
{
@unlink($tmpFile);
}
return false;
}
/**
* Normalize a DB-IP city record into a flat array.
*/
private static function normalizeCityRecord(array $record): array
{
return [
'country_code' => $record['country']['iso_code'] ?? '',
'country_name' => $record['country']['names']['en'] ?? '',
'continent_code' => $record['continent']['code'] ?? '',
'continent_name' => $record['continent']['names']['en'] ?? '',
'region' => $record['subdivisions'][0]['names']['en'] ?? '',
'city' => $record['city']['names']['en'] ?? '',
'latitude' => $record['location']['latitude'] ?? null,
'longitude' => $record['location']['longitude'] ?? null,
'timezone' => $record['location']['time_zone'] ?? '',
];
}
/**
* Normalize a DB-IP country record into a flat array.
*/
private static function normalizeCountryRecord(array $record): array
{
return [
'country_code' => $record['country']['iso_code'] ?? '',
'country_name' => $record['country']['names']['en'] ?? '',
'continent_code' => $record['continent']['code'] ?? '',
'continent_name' => $record['continent']['names']['en'] ?? '',
'region' => '',
'city' => '',
'latitude' => null,
'longitude' => null,
'timezone' => '',
];
}
/**
* Shut down readers.
*/
public static function close(): void
{
if (self::$countryReader !== null)
{
self::$countryReader->close();
self::$countryReader = null;
}
if (self::$cityReader !== null)
{
self::$cityReader->close();
self::$cityReader = null;
}
}
}
@@ -8,8 +8,8 @@
<license>GPL-3.0-or-later</license> <license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl> <authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.83-dev</version> <version>02.34.50-dev</version>
<description>PLG_SYSTEM_MOKOSUITE_DEVTOOLS_DESC</description> <description>PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoSuiteClientDevTools</namespace> <namespace path="src">Moko\Plugin\System\MokoSuiteClientDevTools</namespace>
<files> <files>
@@ -26,36 +26,36 @@
<config> <config>
<fields name="params"> <fields name="params">
<fieldset name="basic" <fieldset name="basic"
label="PLG_SYSTEM_MOKOSUITE_DEVTOOLS_FIELDSET_BASIC" label="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_FIELDSET_BASIC"
description="PLG_SYSTEM_MOKOSUITE_DEVTOOLS_FIELDSET_BASIC_DESC"> description="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_FIELDSET_BASIC_DESC">
<field name="dev_mode" type="radio" default="0" <field name="dev_mode" type="radio" default="0"
label="PLG_SYSTEM_MOKOSUITE_DEVTOOLS_DEV_MODE_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEV_MODE_LABEL"
description="PLG_SYSTEM_MOKOSUITE_DEVTOOLS_DEV_MODE_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DEV_MODE_DESC"
class="btn-group btn-group-yesno"> class="btn-group btn-group-yesno">
<option value="1">JYES</option> <option value="1">JYES</option>
<option value="0">JNO</option> <option value="0">JNO</option>
</field> </field>
<field name="reset_hits" type="radio" default="0" <field name="reset_hits" type="radio" default="0"
label="PLG_SYSTEM_MOKOSUITE_DEVTOOLS_RESET_HITS_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_RESET_HITS_LABEL"
description="PLG_SYSTEM_MOKOSUITE_DEVTOOLS_RESET_HITS_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_RESET_HITS_DESC"
class="btn-group btn-group-yesno"> class="btn-group btn-group-yesno">
<option value="1">JYES</option> <option value="1">JYES</option>
<option value="0">JNO</option> <option value="0">JNO</option>
</field> </field>
<field name="delete_versions" type="radio" default="0" <field name="delete_versions" type="radio" default="0"
label="PLG_SYSTEM_MOKOSUITE_DEVTOOLS_DELETE_VERSIONS_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DELETE_VERSIONS_LABEL"
description="PLG_SYSTEM_MOKOSUITE_DEVTOOLS_DELETE_VERSIONS_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DELETE_VERSIONS_DESC"
class="btn-group btn-group-yesno"> class="btn-group btn-group-yesno">
<option value="1">JYES</option> <option value="1">JYES</option>
<option value="0">JNO</option> <option value="0">JNO</option>
</field> </field>
<field name="reset_download_keys" type="radio" default="0" <field name="reset_download_keys" type="radio" default="0"
label="PLG_SYSTEM_MOKOSUITE_DEVTOOLS_RESET_DLKEYS_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_RESET_DLKEYS_LABEL"
description="PLG_SYSTEM_MOKOSUITE_DEVTOOLS_RESET_DLKEYS_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_RESET_DLKEYS_DESC"
class="btn-group btn-group-yesno"> class="btn-group btn-group-yesno">
<option value="1">JYES</option> <option value="1">JYES</option>
<option value="0">JNO</option> <option value="0">JNO</option>
@@ -8,8 +8,8 @@
<license>GPL-3.0-or-later</license> <license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl> <authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.83-dev</version> <version>02.34.50-dev</version>
<description>PLG_SYSTEM_MOKOSUITE_FIREWALL_DESC</description> <description>PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoSuiteClientFirewall</namespace> <namespace path="src">Moko\Plugin\System\MokoSuiteClientFirewall</namespace>
<files> <files>
@@ -36,25 +36,25 @@
<fields name="params"> <fields name="params">
<!-- Network & Session --> <!-- Network & Session -->
<fieldset name="basic" <fieldset name="basic"
label="PLG_SYSTEM_MOKOSUITE_FIREWALL_FIELDSET_BASIC" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_FIELDSET_BASIC"
description="PLG_SYSTEM_MOKOSUITE_FIREWALL_FIELDSET_BASIC_DESC"> description="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_FIELDSET_BASIC_DESC">
<field name="force_https" type="radio" default="1" <field name="force_https" type="radio" default="1"
label="PLG_SYSTEM_MOKOSUITE_FIREWALL_FORCE_HTTPS_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_FORCE_HTTPS_LABEL"
description="PLG_SYSTEM_MOKOSUITE_FIREWALL_FORCE_HTTPS_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_FORCE_HTTPS_DESC"
class="btn-group btn-group-yesno"> class="btn-group btn-group-yesno">
<option value="1">JYES</option> <option value="1">JYES</option>
<option value="0">JNO</option> <option value="0">JNO</option>
</field> </field>
<field name="admin_session_timeout" type="number" <field name="admin_session_timeout" type="number"
label="PLG_SYSTEM_MOKOSUITE_FIREWALL_SESSION_TIMEOUT_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_SESSION_TIMEOUT_LABEL"
description="PLG_SYSTEM_MOKOSUITE_FIREWALL_SESSION_TIMEOUT_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_SESSION_TIMEOUT_DESC"
default="60" hint="Minutes (0 = Joomla default)" /> default="60" hint="Minutes (0 = Joomla default)" />
<field name="trusted_ips" type="subform" <field name="trusted_ips" type="subform"
label="PLG_SYSTEM_MOKOSUITE_FIREWALL_TRUSTED_IPS_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_TRUSTED_IPS_LABEL"
description="PLG_SYSTEM_MOKOSUITE_FIREWALL_TRUSTED_IPS_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_TRUSTED_IPS_DESC"
formsource="plugins/system/mokosuiteclient_firewall/forms/trusted_ip_entry.xml" formsource="plugins/system/mokosuiteclient_firewall/forms/trusted_ip_entry.xml"
multiple="true" multiple="true"
layout="joomla.form.field.subform.repeatable-table" layout="joomla.form.field.subform.repeatable-table"
@@ -64,20 +64,20 @@
<!-- WAF Shields --> <!-- WAF Shields -->
<fieldset name="waf" <fieldset name="waf"
label="PLG_SYSTEM_MOKOSUITE_FIREWALL_FIELDSET_WAF" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_FIELDSET_WAF"
description="PLG_SYSTEM_MOKOSUITE_FIREWALL_FIELDSET_WAF_DESC"> description="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_FIELDSET_WAF_DESC">
<field name="waf_enabled" type="radio" default="1" <field name="waf_enabled" type="radio" default="1"
label="PLG_SYSTEM_MOKOSUITE_FIREWALL_WAF_ENABLED_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_WAF_ENABLED_LABEL"
description="PLG_SYSTEM_MOKOSUITE_FIREWALL_WAF_ENABLED_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_WAF_ENABLED_DESC"
class="btn-group btn-group-yesno"> class="btn-group btn-group-yesno">
<option value="1">JYES</option> <option value="1">JYES</option>
<option value="0">JNO</option> <option value="0">JNO</option>
</field> </field>
<field name="waf_sqli" type="radio" default="1" <field name="waf_sqli" type="radio" default="1"
label="PLG_SYSTEM_MOKOSUITE_FIREWALL_WAF_SQLI_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_WAF_SQLI_LABEL"
description="PLG_SYSTEM_MOKOSUITE_FIREWALL_WAF_SQLI_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_WAF_SQLI_DESC"
class="btn-group btn-group-yesno" class="btn-group btn-group-yesno"
showon="waf_enabled:1"> showon="waf_enabled:1">
<option value="1">JYES</option> <option value="1">JYES</option>
@@ -85,8 +85,8 @@
</field> </field>
<field name="waf_xss" type="radio" default="1" <field name="waf_xss" type="radio" default="1"
label="PLG_SYSTEM_MOKOSUITE_FIREWALL_WAF_XSS_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_WAF_XSS_LABEL"
description="PLG_SYSTEM_MOKOSUITE_FIREWALL_WAF_XSS_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_WAF_XSS_DESC"
class="btn-group btn-group-yesno" class="btn-group btn-group-yesno"
showon="waf_enabled:1"> showon="waf_enabled:1">
<option value="1">JYES</option> <option value="1">JYES</option>
@@ -94,8 +94,8 @@
</field> </field>
<field name="waf_mua" type="radio" default="1" <field name="waf_mua" type="radio" default="1"
label="PLG_SYSTEM_MOKOSUITE_FIREWALL_WAF_MUA_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_WAF_MUA_LABEL"
description="PLG_SYSTEM_MOKOSUITE_FIREWALL_WAF_MUA_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_WAF_MUA_DESC"
class="btn-group btn-group-yesno" class="btn-group btn-group-yesno"
showon="waf_enabled:1"> showon="waf_enabled:1">
<option value="1">JYES</option> <option value="1">JYES</option>
@@ -103,15 +103,15 @@
</field> </field>
<field name="waf_mua_blocklist" type="textarea" <field name="waf_mua_blocklist" type="textarea"
label="PLG_SYSTEM_MOKOSUITE_FIREWALL_WAF_MUA_LIST_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_WAF_MUA_LIST_LABEL"
description="PLG_SYSTEM_MOKOSUITE_FIREWALL_WAF_MUA_LIST_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_WAF_MUA_LIST_DESC"
rows="4" filter="raw" rows="4" filter="raw"
default="sqlmap,nikto,nmap,havij,w3af,acunetix,nessus,openvas,masscan,gobuster,dirbuster,wpscan,joomscan" default="sqlmap,nikto,nmap,havij,w3af,acunetix,nessus,openvas,masscan,gobuster,dirbuster,wpscan,joomscan"
showon="waf_enabled:1[AND]waf_mua:1" /> showon="waf_enabled:1[AND]waf_mua:1" />
<field name="waf_rfi" type="radio" default="1" <field name="waf_rfi" type="radio" default="1"
label="PLG_SYSTEM_MOKOSUITE_FIREWALL_WAF_RFI_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_WAF_RFI_LABEL"
description="PLG_SYSTEM_MOKOSUITE_FIREWALL_WAF_RFI_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_WAF_RFI_DESC"
class="btn-group btn-group-yesno" class="btn-group btn-group-yesno"
showon="waf_enabled:1"> showon="waf_enabled:1">
<option value="1">JYES</option> <option value="1">JYES</option>
@@ -119,8 +119,8 @@
</field> </field>
<field name="waf_dfi" type="radio" default="1" <field name="waf_dfi" type="radio" default="1"
label="PLG_SYSTEM_MOKOSUITE_FIREWALL_WAF_DFI_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_WAF_DFI_LABEL"
description="PLG_SYSTEM_MOKOSUITE_FIREWALL_WAF_DFI_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_WAF_DFI_DESC"
class="btn-group btn-group-yesno" class="btn-group btn-group-yesno"
showon="waf_enabled:1"> showon="waf_enabled:1">
<option value="1">JYES</option> <option value="1">JYES</option>
@@ -130,8 +130,8 @@
<!-- Security Headers --> <!-- Security Headers -->
<fieldset name="headers" <fieldset name="headers"
label="PLG_SYSTEM_MOKOSUITE_FIREWALL_FIELDSET_HEADERS" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_FIELDSET_HEADERS"
description="PLG_SYSTEM_MOKOSUITE_FIREWALL_FIELDSET_HEADERS_DESC"> description="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_FIELDSET_HEADERS_DESC">
<field name="header_xframe" type="radio" default="1" <field name="header_xframe" type="radio" default="1"
label="X-Frame-Options" description="Clickjacking protection (SAMEORIGIN)" label="X-Frame-Options" description="Clickjacking protection (SAMEORIGIN)"
@@ -177,12 +177,12 @@
<!-- Access Control --> <!-- Access Control -->
<fieldset name="access_control" <fieldset name="access_control"
label="PLG_SYSTEM_MOKOSUITE_FIREWALL_FIELDSET_ACCESS" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_FIELDSET_ACCESS"
description="PLG_SYSTEM_MOKOSUITE_FIREWALL_FIELDSET_ACCESS_DESC"> description="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_FIELDSET_ACCESS_DESC">
<field name="ip_blocklist" type="subform" <field name="ip_blocklist" type="subform"
label="PLG_SYSTEM_MOKOSUITE_FIREWALL_IP_BLOCKLIST_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_IP_BLOCKLIST_LABEL"
description="PLG_SYSTEM_MOKOSUITE_FIREWALL_IP_BLOCKLIST_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_IP_BLOCKLIST_DESC"
formsource="plugins/system/mokosuiteclient_firewall/forms/trusted_ip_entry.xml" formsource="plugins/system/mokosuiteclient_firewall/forms/trusted_ip_entry.xml"
multiple="true" multiple="true"
layout="joomla.form.field.subform.repeatable-table" layout="joomla.form.field.subform.repeatable-table"
@@ -190,13 +190,13 @@
buttons="add,remove,move" /> buttons="add,remove,move" />
<field name="admin_secret" type="text" <field name="admin_secret" type="text"
label="PLG_SYSTEM_MOKOSUITE_FIREWALL_ADMIN_SECRET_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_ADMIN_SECRET_LABEL"
description="PLG_SYSTEM_MOKOSUITE_FIREWALL_ADMIN_SECRET_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_ADMIN_SECRET_DESC"
default="" filter="raw" hint="Leave empty to disable" /> default="" filter="raw" hint="Leave empty to disable" />
<field name="admin_secret_redirect" type="text" <field name="admin_secret_redirect" type="text"
label="PLG_SYSTEM_MOKOSUITE_FIREWALL_ADMIN_SECRET_REDIRECT_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_ADMIN_SECRET_REDIRECT_LABEL"
description="PLG_SYSTEM_MOKOSUITE_FIREWALL_ADMIN_SECRET_REDIRECT_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_ADMIN_SECRET_REDIRECT_DESC"
default="" filter="url" hint="Empty = 403 Forbidden" default="" filter="url" hint="Empty = 403 Forbidden"
showon="admin_secret!:" /> showon="admin_secret!:" />
@@ -211,8 +211,8 @@
showon="autoban_threshold!:0" /> showon="autoban_threshold!:0" />
<field name="block_frontend_superuser" type="radio" default="0" <field name="block_frontend_superuser" type="radio" default="0"
label="PLG_SYSTEM_MOKOSUITE_FIREWALL_BLOCK_FE_SU_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_BLOCK_FE_SU_LABEL"
description="PLG_SYSTEM_MOKOSUITE_FIREWALL_BLOCK_FE_SU_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_BLOCK_FE_SU_DESC"
class="btn-group btn-group-yesno"> class="btn-group btn-group-yesno">
<option value="1">JYES</option> <option value="1">JYES</option>
<option value="0">JNO</option> <option value="0">JNO</option>
@@ -221,28 +221,28 @@
<!-- File & Template Protection --> <!-- File & Template Protection -->
<fieldset name="protection" <fieldset name="protection"
label="PLG_SYSTEM_MOKOSUITE_FIREWALL_FIELDSET_PROTECTION" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_FIELDSET_PROTECTION"
description="PLG_SYSTEM_MOKOSUITE_FIREWALL_FIELDSET_PROTECTION_DESC"> description="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_FIELDSET_PROTECTION_DESC">
<field name="block_sensitive_files" type="radio" default="1" <field name="block_sensitive_files" type="radio" default="1"
label="PLG_SYSTEM_MOKOSUITE_FIREWALL_BLOCK_FILES_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_BLOCK_FILES_LABEL"
description="PLG_SYSTEM_MOKOSUITE_FIREWALL_BLOCK_FILES_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_BLOCK_FILES_DESC"
class="btn-group btn-group-yesno"> class="btn-group btn-group-yesno">
<option value="1">JYES</option> <option value="1">JYES</option>
<option value="0">JNO</option> <option value="0">JNO</option>
</field> </field>
<field name="block_direct_php" type="radio" default="1" <field name="block_direct_php" type="radio" default="1"
label="PLG_SYSTEM_MOKOSUITE_FIREWALL_BLOCK_PHP_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_BLOCK_PHP_LABEL"
description="PLG_SYSTEM_MOKOSUITE_FIREWALL_BLOCK_PHP_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_BLOCK_PHP_DESC"
class="btn-group btn-group-yesno"> class="btn-group btn-group-yesno">
<option value="1">JYES</option> <option value="1">JYES</option>
<option value="0">JNO</option> <option value="0">JNO</option>
</field> </field>
<field name="block_template_switch" type="radio" default="1" <field name="block_template_switch" type="radio" default="1"
label="PLG_SYSTEM_MOKOSUITE_FIREWALL_BLOCK_TMPL_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_BLOCK_TMPL_LABEL"
description="PLG_SYSTEM_MOKOSUITE_FIREWALL_BLOCK_TMPL_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_BLOCK_TMPL_DESC"
class="btn-group btn-group-yesno"> class="btn-group btn-group-yesno">
<option value="1">JYES</option> <option value="1">JYES</option>
<option value="0">JNO</option> <option value="0">JNO</option>
@@ -251,29 +251,29 @@
<!-- Password Policy --> <!-- Password Policy -->
<fieldset name="password_policy" <fieldset name="password_policy"
label="PLG_SYSTEM_MOKOSUITE_FIREWALL_FIELDSET_PASSWORD" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_FIELDSET_PASSWORD"
description="PLG_SYSTEM_MOKOSUITE_FIREWALL_FIELDSET_PASSWORD_DESC"> description="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_FIELDSET_PASSWORD_DESC">
<field name="password_min_length" type="number" default="12" <field name="password_min_length" type="number" default="12"
label="PLG_SYSTEM_MOKOSUITE_FIREWALL_PASSWORD_LENGTH_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_PASSWORD_LENGTH_LABEL"
description="PLG_SYSTEM_MOKOSUITE_FIREWALL_PASSWORD_LENGTH_DESC" /> description="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_PASSWORD_LENGTH_DESC" />
<field name="password_require_uppercase" type="radio" default="1" <field name="password_require_uppercase" type="radio" default="1"
label="PLG_SYSTEM_MOKOSUITE_FIREWALL_PASSWORD_UPPER_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_PASSWORD_UPPER_LABEL"
class="btn-group btn-group-yesno"> class="btn-group btn-group-yesno">
<option value="1">JYES</option> <option value="1">JYES</option>
<option value="0">JNO</option> <option value="0">JNO</option>
</field> </field>
<field name="password_require_number" type="radio" default="1" <field name="password_require_number" type="radio" default="1"
label="PLG_SYSTEM_MOKOSUITE_FIREWALL_PASSWORD_NUMBER_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_PASSWORD_NUMBER_LABEL"
class="btn-group btn-group-yesno"> class="btn-group btn-group-yesno">
<option value="1">JYES</option> <option value="1">JYES</option>
<option value="0">JNO</option> <option value="0">JNO</option>
</field> </field>
<field name="password_require_special" type="radio" default="1" <field name="password_require_special" type="radio" default="1"
label="PLG_SYSTEM_MOKOSUITE_FIREWALL_PASSWORD_SPECIAL_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_PASSWORD_SPECIAL_LABEL"
class="btn-group btn-group-yesno"> class="btn-group btn-group-yesno">
<option value="1">JYES</option> <option value="1">JYES</option>
<option value="0">JNO</option> <option value="0">JNO</option>
@@ -282,17 +282,17 @@
<!-- Upload Restrictions --> <!-- Upload Restrictions -->
<fieldset name="uploads" <fieldset name="uploads"
label="PLG_SYSTEM_MOKOSUITE_FIREWALL_FIELDSET_UPLOADS" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_FIELDSET_UPLOADS"
description="PLG_SYSTEM_MOKOSUITE_FIREWALL_FIELDSET_UPLOADS_DESC"> description="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_FIELDSET_UPLOADS_DESC">
<field name="upload_allowed_types" type="text" <field name="upload_allowed_types" type="text"
label="PLG_SYSTEM_MOKOSUITE_FIREWALL_UPLOAD_TYPES_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_UPLOAD_TYPES_LABEL"
description="PLG_SYSTEM_MOKOSUITE_FIREWALL_UPLOAD_TYPES_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_UPLOAD_TYPES_DESC"
default="jpg,jpeg,png,gif,webp,svg,pdf,doc,docx,xls,xlsx" /> default="jpg,jpeg,png,gif,webp,svg,pdf,doc,docx,xls,xlsx" />
<field name="upload_max_size_mb" type="number" <field name="upload_max_size_mb" type="number"
label="PLG_SYSTEM_MOKOSUITE_FIREWALL_UPLOAD_SIZE_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_UPLOAD_SIZE_LABEL"
description="PLG_SYSTEM_MOKOSUITE_FIREWALL_UPLOAD_SIZE_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_UPLOAD_SIZE_DESC"
default="100" /> default="100" />
</fieldset> </fieldset>
</fields> </fields>
@@ -8,8 +8,8 @@
<license>GPL-3.0-or-later</license> <license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl> <authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.83-dev</version> <version>02.34.50-dev</version>
<description>PLG_SYSTEM_MOKOSUITE_MONITOR_DESC</description> <description>PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoSuiteClientMonitor</namespace> <namespace path="src">Moko\Plugin\System\MokoSuiteClientMonitor</namespace>
<files> <files>
@@ -26,12 +26,12 @@
<config> <config>
<fields name="params"> <fields name="params">
<fieldset name="basic" <fieldset name="basic"
label="PLG_SYSTEM_MOKOSUITE_MONITOR_FIELDSET_BASIC" label="PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_FIELDSET_BASIC"
description="PLG_SYSTEM_MOKOSUITE_MONITOR_FIELDSET_BASIC_DESC"> description="PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_FIELDSET_BASIC_DESC">
<field name="heartbeat_enabled" type="radio" default="1" <field name="heartbeat_enabled" type="radio" default="1"
label="PLG_SYSTEM_MOKOSUITE_MONITOR_HEARTBEAT_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_HEARTBEAT_LABEL"
description="PLG_SYSTEM_MOKOSUITE_MONITOR_HEARTBEAT_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_HEARTBEAT_DESC"
class="btn-group btn-group-yesno"> class="btn-group btn-group-yesno">
<option value="1">JYES</option> <option value="1">JYES</option>
<option value="0">JNO</option> <option value="0">JNO</option>
@@ -39,8 +39,8 @@
<field name="base_url" type="url" <field name="base_url" type="url"
default="https://waas.dev.mokoconsulting.tech" default="https://waas.dev.mokoconsulting.tech"
label="PLG_SYSTEM_MOKOSUITE_MONITOR_BASE_URL_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_BASE_URL_LABEL"
description="PLG_SYSTEM_MOKOSUITE_MONITOR_BASE_URL_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_BASE_URL_DESC"
filter="url" /> filter="url" />
<field name="signing_key" type="hidden" <field name="signing_key" type="hidden"
@@ -35,57 +35,9 @@ class Monitor extends CMSPlugin implements SubscriberInterface
{ {
return [ return [
'onExtensionAfterSave' => 'onExtensionAfterSave', 'onExtensionAfterSave' => 'onExtensionAfterSave',
'onAfterInitialise' => 'onAfterInitialise',
'onExtensionAfterInstall' => 'onExtensionAfterInstall',
]; ];
} }
/**
* Send heartbeat on first admin page load after install/update.
*/
public function onAfterInitialise(): void
{
$app = $this->getApplication();
if (!$app->isClient('administrator')) return;
if (!$this->params->get('heartbeat_enabled', 1)) return;
$session = \Joomla\CMS\Factory::getSession();
if ($session->get('mokosuiteclient.heartbeat_sent', false)) return;
// Check if version changed since last heartbeat
$lastVersion = $this->params->get('_last_heartbeat_version', '');
$currentVersion = $this->getMokoSuiteClientVersion();
if ($lastVersion !== $currentVersion)
{
$session->set('mokosuiteclient.heartbeat_sent', true);
$this->sendHeartbeat();
// Store version so we don't re-send every session
try
{
$this->params->set('_last_heartbeat_version', $currentVersion);
$extension = new \Joomla\CMS\Table\Extension(Factory::getDbo());
$extension->load(['element' => 'mokosuiteclient_monitor', 'folder' => 'system', 'type' => 'plugin']);
$extension->params = $this->params->toString();
$extension->store();
}
catch (\Throwable $e) {}
}
}
/**
* Send heartbeat immediately after package install/update.
*/
public function onExtensionAfterInstall($installer, $eid): void
{
if (!$this->params->get('heartbeat_enabled', 1)) return;
try { $this->sendHeartbeat(); }
catch (\Throwable $e) {}
}
/** /**
* After saving this plugin or the core plugin, send heartbeat. * After saving this plugin or the core plugin, send heartbeat.
*/ */
@@ -194,26 +146,29 @@ class Monitor extends CMSPlugin implements SubscriberInterface
$endpoint = $baseUrl . '/api/index.php/v1/mokosuiteclienthq/heartbeat'; $endpoint = $baseUrl . '/api/index.php/v1/mokosuiteclienthq/heartbeat';
$json = json_encode($payload, JSON_UNESCAPED_SLASHES); $json = json_encode($payload, JSON_UNESCAPED_SLASHES);
try $ch = curl_init($endpoint);
{ curl_setopt_array($ch, [
$http = \Joomla\CMS\Http\HttpFactory::getHttp( CURLOPT_POST => true,
new \Joomla\Registry\Registry(['follow_location' => true, 'transport.curl' => ['certpath' => false]]), CURLOPT_HTTPHEADER => $headers,
['curl', 'stream'] CURLOPT_POSTFIELDS => $json,
); CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_SSL_VERIFYPEER => false,
]);
$headerMap = []; $response = curl_exec($ch);
foreach ($headers as $h) $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error)
{ {
[$key, $val] = explode(': ', $h, 2); Log::add('Monitor heartbeat failed: ' . $error, Log::WARNING, 'mokosuiteclient');
$headerMap[$key] = $val;
} }
elseif ($code >= 200 && $code < 300)
$response = $http->post($endpoint, $json, $headerMap, 15);
$code = $response->code;
$body = json_decode($response->body, true);
if ($code >= 200 && $code < 300)
{ {
$body = json_decode($response, true);
$app->enqueueMessage( $app->enqueueMessage(
'MokoSuiteClientHQ heartbeat: ' . ($body['status'] ?? 'ok'), 'MokoSuiteClientHQ heartbeat: ' . ($body['status'] ?? 'ok'),
'message' 'message'
@@ -221,6 +176,7 @@ class Monitor extends CMSPlugin implements SubscriberInterface
} }
else else
{ {
$body = json_decode($response, true);
Log::add( Log::add(
\sprintf('Monitor heartbeat HTTP %d: %s', $code, $body['error'] ?? 'Unknown'), \sprintf('Monitor heartbeat HTTP %d: %s', $code, $body['error'] ?? 'Unknown'),
Log::WARNING, Log::WARNING,
@@ -232,11 +188,6 @@ class Monitor extends CMSPlugin implements SubscriberInterface
); );
} }
} }
catch (\Throwable $e)
{
Log::add('Monitor heartbeat failed: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
}
}
/** /**
* RSA-sign the request message. * RSA-sign the request message.
@@ -306,30 +257,30 @@ class Monitor extends CMSPlugin implements SubscriberInterface
*/ */
private function fetchLocalHealth(string $siteUrl, string $healthToken): ?array private function fetchLocalHealth(string $siteUrl, string $healthToken): ?array
{ {
try $url = $siteUrl . '/?mokosuiteclient=health';
{
$http = \Joomla\CMS\Http\HttpFactory::getHttp(
new \Joomla\Registry\Registry(['follow_location' => true, 'transport.curl' => ['certpath' => false]]),
['curl', 'stream']
);
$response = $http->get( $ch = curl_init($url);
$siteUrl . '/?mokosuiteclient=health', curl_setopt_array($ch, [
['Authorization' => 'Bearer ' . $healthToken, 'Accept' => 'application/json'], CURLOPT_RETURNTRANSFER => true,
10 CURLOPT_TIMEOUT => 10,
); CURLOPT_FOLLOWLOCATION => true,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $healthToken,
'Accept: application/json',
],
]);
if ($response->code !== 200 || empty($response->body)) $response = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200 || empty($response))
{ {
return null; return null;
} }
return json_decode($response->body, true) ?: null; return json_decode($response, true) ?: null;
}
catch (\Throwable $e)
{
return null;
}
} }
/** /**
@@ -339,11 +290,17 @@ class Monitor extends CMSPlugin implements SubscriberInterface
{ {
try try
{ {
$extension = new \Joomla\CMS\Table\Extension(Factory::getDbo()); $db = Factory::getDbo();
$extension->load(['element' => 'pkg_mokosuiteclient', 'type' => 'package']); $db->setQuery(
$manifest = json_decode($extension->manifest_cache ?? '{}'); $db->getQuery(true)
->select($db->quoteName('manifest_cache'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('pkg_mokosuiteclient'))
->where($db->quoteName('type') . ' = ' . $db->quote('package'))
);
$mc = json_decode($db->loadResult() ?? '{}');
return $manifest->version ?? ''; return $mc->version ?? '';
} }
catch (\Throwable $e) catch (\Throwable $e)
{ {
@@ -8,8 +8,8 @@
<license>GPL-3.0-or-later</license> <license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl> <authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.83-dev</version> <version>02.34.50-dev</version>
<description>PLG_SYSTEM_MOKOSUITE_OFFLINE_DESC</description> <description>PLG_SYSTEM_MOKOSUITECLIENT_OFFLINE_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoSuiteClientOffline</namespace> <namespace path="src">Moko\Plugin\System\MokoSuiteClientOffline</namespace>
<files> <files>
@@ -25,15 +25,15 @@
<config> <config>
<fields name="params" addfieldprefix="Moko\Plugin\System\MokoSuiteClientOffline\Field"> <fields name="params" addfieldprefix="Moko\Plugin\System\MokoSuiteClientOffline\Field">
<fieldset name="basic" label="PLG_SYSTEM_MOKOSUITE_OFFLINE_FIELDSET_BASIC"> <fieldset name="basic" label="PLG_SYSTEM_MOKOSUITECLIENT_OFFLINE_FIELDSET_BASIC">
<field name="tos_slug" type="menuslug" <field name="tos_slug" type="menuslug"
label="PLG_SYSTEM_MOKOSUITE_OFFLINE_SLUG_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_OFFLINE_SLUG_LABEL"
description="PLG_SYSTEM_MOKOSUITE_OFFLINE_SLUG_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_OFFLINE_SLUG_DESC"
multiple="true" /> multiple="true" />
<field name="include_children" type="radio" default="1" <field name="include_children" type="radio" default="1"
label="PLG_SYSTEM_MOKOSUITE_OFFLINE_CHILDREN_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_OFFLINE_CHILDREN_LABEL"
description="PLG_SYSTEM_MOKOSUITE_OFFLINE_CHILDREN_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_OFFLINE_CHILDREN_DESC"
class="btn-group btn-group-yesno"> class="btn-group btn-group-yesno">
<option value="1">JYES</option> <option value="1">JYES</option>
<option value="0">JNO</option> <option value="0">JNO</option>
@@ -8,8 +8,8 @@
<license>GPL-3.0-or-later</license> <license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl> <authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.83-dev</version> <version>02.34.50-dev</version>
<description>PLG_SYSTEM_MOKOSUITE_TENANT_DESC</description> <description>PLG_SYSTEM_MOKOSUITECLIENT_TENANT_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoSuiteClientTenant</namespace> <namespace path="src">Moko\Plugin\System\MokoSuiteClientTenant</namespace>
<files> <files>
@@ -26,20 +26,20 @@
<config> <config>
<fields name="params"> <fields name="params">
<fieldset name="basic" <fieldset name="basic"
label="PLG_SYSTEM_MOKOSUITE_TENANT_FIELDSET_BASIC" label="PLG_SYSTEM_MOKOSUITECLIENT_TENANT_FIELDSET_BASIC"
description="PLG_SYSTEM_MOKOSUITE_TENANT_FIELDSET_BASIC_DESC"> description="PLG_SYSTEM_MOKOSUITECLIENT_TENANT_FIELDSET_BASIC_DESC">
<field name="restrict_installer" type="radio" default="1" <field name="restrict_installer" type="radio" default="1"
label="PLG_SYSTEM_MOKOSUITE_TENANT_RESTRICT_INSTALLER_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_TENANT_RESTRICT_INSTALLER_LABEL"
description="PLG_SYSTEM_MOKOSUITE_TENANT_RESTRICT_INSTALLER_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_TENANT_RESTRICT_INSTALLER_DESC"
class="btn-group btn-group-yesno"> class="btn-group btn-group-yesno">
<option value="1">JYES</option> <option value="1">JYES</option>
<option value="0">JNO</option> <option value="0">JNO</option>
</field> </field>
<field name="allow_extension_updates" type="radio" default="1" <field name="allow_extension_updates" type="radio" default="1"
label="PLG_SYSTEM_MOKOSUITE_TENANT_ALLOW_UPDATES_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_TENANT_ALLOW_UPDATES_LABEL"
description="PLG_SYSTEM_MOKOSUITE_TENANT_ALLOW_UPDATES_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_TENANT_ALLOW_UPDATES_DESC"
class="btn-group btn-group-yesno" class="btn-group btn-group-yesno"
showon="restrict_installer:1"> showon="restrict_installer:1">
<option value="1">JYES</option> <option value="1">JYES</option>
@@ -47,40 +47,40 @@
</field> </field>
<field name="hide_sysinfo" type="radio" default="1" <field name="hide_sysinfo" type="radio" default="1"
label="PLG_SYSTEM_MOKOSUITE_TENANT_HIDE_SYSINFO_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_TENANT_HIDE_SYSINFO_LABEL"
description="PLG_SYSTEM_MOKOSUITE_TENANT_HIDE_SYSINFO_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_TENANT_HIDE_SYSINFO_DESC"
class="btn-group btn-group-yesno"> class="btn-group btn-group-yesno">
<option value="1">JYES</option> <option value="1">JYES</option>
<option value="0">JNO</option> <option value="0">JNO</option>
</field> </field>
<field name="restrict_global_config" type="radio" default="1" <field name="restrict_global_config" type="radio" default="1"
label="PLG_SYSTEM_MOKOSUITE_TENANT_RESTRICT_CONFIG_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_TENANT_RESTRICT_CONFIG_LABEL"
description="PLG_SYSTEM_MOKOSUITE_TENANT_RESTRICT_CONFIG_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_TENANT_RESTRICT_CONFIG_DESC"
class="btn-group btn-group-yesno"> class="btn-group btn-group-yesno">
<option value="1">JYES</option> <option value="1">JYES</option>
<option value="0">JNO</option> <option value="0">JNO</option>
</field> </field>
<field name="restrict_template_editing" type="radio" default="1" <field name="restrict_template_editing" type="radio" default="1"
label="PLG_SYSTEM_MOKOSUITE_TENANT_RESTRICT_TEMPLATE_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_TENANT_RESTRICT_TEMPLATE_LABEL"
description="PLG_SYSTEM_MOKOSUITE_TENANT_RESTRICT_TEMPLATE_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_TENANT_RESTRICT_TEMPLATE_DESC"
class="btn-group btn-group-yesno"> class="btn-group btn-group-yesno">
<option value="1">JYES</option> <option value="1">JYES</option>
<option value="0">JNO</option> <option value="0">JNO</option>
</field> </field>
<field name="disable_install_url" type="radio" default="1" <field name="disable_install_url" type="radio" default="1"
label="PLG_SYSTEM_MOKOSUITE_TENANT_DISABLE_INSTALL_URL_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_TENANT_DISABLE_INSTALL_URL_LABEL"
description="PLG_SYSTEM_MOKOSUITE_TENANT_DISABLE_INSTALL_URL_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_TENANT_DISABLE_INSTALL_URL_DESC"
class="btn-group btn-group-yesno"> class="btn-group btn-group-yesno">
<option value="1">JYES</option> <option value="1">JYES</option>
<option value="0">JNO</option> <option value="0">JNO</option>
</field> </field>
<field name="hidden_menu_items" type="textarea" <field name="hidden_menu_items" type="textarea"
label="PLG_SYSTEM_MOKOSUITE_TENANT_HIDDEN_MENUS_LABEL" label="PLG_SYSTEM_MOKOSUITECLIENT_TENANT_HIDDEN_MENUS_LABEL"
description="PLG_SYSTEM_MOKOSUITE_TENANT_HIDDEN_MENUS_DESC" description="PLG_SYSTEM_MOKOSUITECLIENT_TENANT_HIDDEN_MENUS_DESC"
rows="5" filter="raw" /> rows="5" filter="raw" />
</fieldset> </fieldset>
</fields> </fields>
@@ -8,7 +8,7 @@
<license>GPL-3.0-or-later</license> <license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl> <authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.83-dev</version> <version>02.34.50-dev</version>
<description>Runs scheduled helpdesk automation rules — auto-close resolved tickets, SLA breach escalation, and time-based actions.</description> <description>Runs scheduled helpdesk automation rules — auto-close resolved tickets, SLA breach escalation, and time-based actions.</description>
<namespace path="src">Moko\Plugin\Task\MokoSuiteClientTickets</namespace> <namespace path="src">Moko\Plugin\Task\MokoSuiteClientTickets</namespace>
@@ -10,16 +10,12 @@ namespace Moko\Plugin\Task\MokoSuiteClientTickets\Extension;
defined('_JEXEC') or die; defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Plugin\CMSPlugin; use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent; use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent;
use Joomla\Component\Scheduler\Administrator\Task\Status; use Joomla\Component\Scheduler\Administrator\Task\Status;
use Joomla\Component\Scheduler\Administrator\Traits\TaskPluginTrait; use Joomla\Component\Scheduler\Administrator\Traits\TaskPluginTrait;
use Joomla\Event\SubscriberInterface; use Joomla\Event\SubscriberInterface;
use Moko\Component\MokoSuiteClient\Administrator\Model\TicketsModel; use Moko\Component\MokoSuiteClient\Administrator\Model\TicketsModel;
use Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService;
use Moko\Component\MokoSuiteClient\Administrator\Service\NotificationService;
class TicketAutomation extends CMSPlugin implements SubscriberInterface class TicketAutomation extends CMSPlugin implements SubscriberInterface
{ {
@@ -27,17 +23,9 @@ class TicketAutomation extends CMSPlugin implements SubscriberInterface
protected const TASKS_MAP = [ protected const TASKS_MAP = [
'mokosuiteclient.ticket.automation' => [ 'mokosuiteclient.ticket.automation' => [
'langConstPrefix' => 'PLG_TASK_MOKOSUITE_TICKETS_AUTOMATION', 'langConstPrefix' => 'PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOMATION',
'method' => 'runAutomation', 'method' => 'runAutomation',
], ],
'mokosuiteclient.ticket.imap_poll' => [
'langConstPrefix' => 'PLG_TASK_MOKOSUITE_TICKETS_IMAP_POLL',
'method' => 'runImapPoll',
],
'mokosuiteclient.ticket.autoclose' => [
'langConstPrefix' => 'PLG_TASK_MOKOSUITE_TICKETS_AUTOCLOSE',
'method' => 'runAutoClose',
],
]; ];
protected $autoloadLanguage = true; protected $autoloadLanguage = true;
@@ -74,239 +62,4 @@ class TicketAutomation extends CMSPlugin implements SubscriberInterface
return Status::KNOCKOUT; return Status::KNOCKOUT;
} }
} }
/**
* Poll IMAP inbox and create tickets from unread emails (#136).
*/
private function runImapPoll(ExecuteTaskEvent $event): int
{
$config = $this->getComponentConfig();
$host = $config['imap_host'] ?? '';
$port = (int) ($config['imap_port'] ?? 993);
$user = $config['imap_user'] ?? '';
$pass = $config['imap_password'] ?? '';
$ssl = ($config['imap_ssl'] ?? '1') === '1';
$folder = $config['imap_folder'] ?? 'INBOX';
$processed = $config['imap_processed_folder'] ?? 'INBOX.Processed';
$defaultCat = (int) ($config['default_category'] ?? 0) ?: null;
if (empty($host) || empty($user) || empty($pass))
{
$this->logTask('IMAP not configured — skipping', 'warning');
return Status::OK;
}
if (!function_exists('imap_open'))
{
$this->logTask('php-imap extension not available', 'error');
return Status::KNOCKOUT;
}
$mailbox = '{' . $host . ':' . $port . '/imap' . ($ssl ? '/ssl' : '') . '/novalidate-cert}' . $folder;
$mbox = @imap_open($mailbox, $user, $pass);
if (!$mbox)
{
$this->logTask('IMAP connection failed: ' . imap_last_error(), 'error');
return Status::KNOCKOUT;
}
$db = Factory::getDbo();
$created = 0;
$replied = 0;
$emails = imap_search($mbox, 'UNSEEN');
if ($emails === false)
{
imap_close($mbox);
$this->logTask('No new emails');
return Status::OK;
}
foreach ($emails as $msgNum)
{
try
{
$header = imap_headerinfo($mbox, $msgNum);
$subject = isset($header->subject) ? imap_utf8($header->subject) : '(no subject)';
$fromAddr = $header->from[0]->mailbox . '@' . $header->from[0]->host;
$body = $this->getImapBody($mbox, $msgNum);
// Match sender to Joomla user
$userId = $this->findUserByEmail($fromAddr);
// Check if this is a reply (subject contains [#123])
$ticketId = 0;
if (preg_match('/\[#(\d+)\]/', $subject, $m))
{
$ticketId = (int) $m[1];
}
if ($ticketId > 0)
{
// Add as reply to existing ticket
$reply = (object) [
'ticket_id' => $ticketId,
'user_id' => $userId,
'body' => $body,
'is_internal' => 0,
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuiteclient_ticket_replies', $reply, 'id');
$replied++;
// Notify
$db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuiteclient_tickets')->where('id = ' . $ticketId));
$ticket = $db->loadObject();
if ($ticket) {
NotificationService::notify('ticket_replied', $ticket, ['reply_body' => $body]);
}
}
else
{
// Create new ticket
$ticket = (object) [
'subject' => $subject,
'body' => $body,
'status' => 'open',
'priority' => 'normal',
'category_id' => $defaultCat,
'created_by' => $userId,
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuiteclient_tickets', $ticket, 'id');
$created++;
NotificationService::notify('ticket_created', $ticket);
}
// Mark as seen / move to processed folder
imap_setflag_full($mbox, (string) $msgNum, '\\Seen');
if ($processed && $processed !== $folder)
{
@imap_mail_move($mbox, (string) $msgNum, $processed);
}
}
catch (\Throwable $e)
{
Log::add('IMAP message processing error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
}
}
imap_expunge($mbox);
imap_close($mbox);
$this->logTask("IMAP poll: {$created} tickets created, {$replied} replies added");
return Status::OK;
}
/**
* Auto-close resolved tickets after configured days.
*/
private function runAutoClose(ExecuteTaskEvent $event): int
{
$config = $this->getComponentConfig();
$days = (int) ($config['autoclose_days'] ?? 7);
if ($days <= 0)
{
$this->logTask('Auto-close disabled (days = 0)');
return Status::OK;
}
$db = Factory::getDbo();
$cutoff = Factory::getDate('-' . $days . ' days')->toSql();
$db->setQuery(
"UPDATE {$db->quoteName('#__mokosuiteclient_tickets')}"
. " SET status = 'closed', closed = {$db->quote(Factory::getDate()->toSql())}"
. " WHERE status = 'resolved'"
. " AND resolved IS NOT NULL"
. " AND resolved < {$db->quote($cutoff)}"
);
$db->execute();
$closed = $db->getAffectedRows();
$this->logTask("Auto-close: {$closed} tickets closed (resolved > {$days} days ago)");
return Status::OK;
}
// ── Helpers ──────────────────────────────────────────────────
private function getComponentConfig(): array
{
try
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select('params')
->from('#__extensions')
->where('element = ' . $db->quote('com_mokosuiteclient'))
->where('type = ' . $db->quote('component'))
);
return json_decode($db->loadResult() ?? '{}', true) ?: [];
}
catch (\Throwable $e)
{
return [];
}
}
private function findUserByEmail(string $email): int
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select('id')
->from('#__users')
->where('email = ' . $db->quote($email))
->setLimit(1)
);
return (int) $db->loadResult();
}
private function getImapBody($mbox, int $msgNum): string
{
$structure = imap_fetchstructure($mbox, $msgNum);
// Simple single-part message
if (empty($structure->parts))
{
$body = imap_fetchbody($mbox, $msgNum, '1');
if ($structure->encoding === 3) $body = base64_decode($body);
if ($structure->encoding === 4) $body = quoted_printable_decode($body);
return trim(strip_tags($body));
}
// Multipart — find text/plain or text/html
$textBody = '';
foreach ($structure->parts as $i => $part)
{
$partNum = (string) ($i + 1);
if ($part->type === 0) // text
{
$content = imap_fetchbody($mbox, $msgNum, $partNum);
if ($part->encoding === 3) $content = base64_decode($content);
if ($part->encoding === 4) $content = quoted_printable_decode($content);
$subtype = strtolower($part->subtype ?? '');
if ($subtype === 'plain' && empty($textBody))
{
$textBody = $content;
}
elseif ($subtype === 'html' && empty($textBody))
{
$textBody = strip_tags($content);
}
}
}
return trim($textBody);
}
} }
@@ -4,20 +4,20 @@
SPDX-License-Identifier: GPL-3.0-or-later SPDX-License-Identifier: GPL-3.0-or-later
--> -->
<extension type="plugin" group="task" method="upgrade"> <extension type="plugin" group="task" method="upgrade">
<name>Task - MokoSuiteClient Content Sync</name> <name>Task - MokoSuiteClient Demo Reset</name>
<element>mokosuiteclientsync</element> <element>mokosuiteclientdemo</element>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<creationDate>2026-05-30</creationDate> <creationDate>2026-05-30</creationDate>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright> <copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GNU General Public License version 3 or later; see LICENSE</license> <license>GNU General Public License version 3 or later; see LICENSE</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl> <authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.83-dev</version> <version>02.34.50-dev</version>
<description>PLG_TASK_MOKOSUITESYNC_DESC</description> <description>PLG_TASK_MOKOSUITECLIENTDEMO_DESC</description>
<namespace path="src">Moko\Plugin\Task\MokoSuiteClientSync</namespace> <namespace path="src">Moko\Plugin\Task\MokoSuiteClientDemo</namespace>
<files> <files>
<filename plugin="mokosuiteclientsync">mokosuiteclientsync.xml</filename> <filename plugin="mokosuiteclientdemo">mokosuiteclientdemo.xml</filename>
<folder>src</folder> <folder>src</folder>
<folder>services</folder> <folder>services</folder>
<folder>forms</folder> <folder>forms</folder>
@@ -25,7 +25,7 @@
</files> </files>
<languages folder="language"> <languages folder="language">
<language tag="en-GB">en-GB/plg_task_mokosuiteclientsync.ini</language> <language tag="en-GB">en-GB/plg_task_mokosuiteclientdemo.ini</language>
<language tag="en-GB">en-GB/plg_task_mokosuiteclientsync.sys.ini</language> <language tag="en-GB">en-GB/plg_task_mokosuiteclientdemo.sys.ini</language>
</languages> </languages>
</extension> </extension>
@@ -10,7 +10,7 @@
* INGROUP: MokoSuiteClient * INGROUP: MokoSuiteClient
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient
* PATH: /src/packages/plg_system_mokosuiteclient/Service/DemoResetService.php * PATH: /src/packages/plg_system_mokosuiteclient/Service/DemoResetService.php
* VERSION: 02.34.83 * VERSION: 02.34.50
* BRIEF: Content-only snapshot/restore for demo site reset * BRIEF: Content-only snapshot/restore for demo site reset
*/ */
@@ -4,20 +4,20 @@
SPDX-License-Identifier: GPL-3.0-or-later SPDX-License-Identifier: GPL-3.0-or-later
--> -->
<extension type="plugin" group="task" method="upgrade"> <extension type="plugin" group="task" method="upgrade">
<name>Task - MokoSuiteClient Demo Reset</name> <name>Task - MokoSuiteClient Content Sync</name>
<element>mokosuiteclientdemo</element> <element>mokosuiteclientsync</element>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<creationDate>2026-05-30</creationDate> <creationDate>2026-05-30</creationDate>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright> <copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GNU General Public License version 3 or later; see LICENSE</license> <license>GNU General Public License version 3 or later; see LICENSE</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl> <authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.83-dev</version> <version>02.34.50-dev</version>
<description>PLG_TASK_MOKOSUITEDEMO_DESC</description> <description>PLG_TASK_MOKOSUITECLIENTSYNC_DESC</description>
<namespace path="src">Moko\Plugin\Task\MokoSuiteClientDemo</namespace> <namespace path="src">Moko\Plugin\Task\MokoSuiteClientSync</namespace>
<files> <files>
<filename plugin="mokosuiteclientdemo">mokosuiteclientdemo.xml</filename> <filename plugin="mokosuiteclientsync">mokosuiteclientsync.xml</filename>
<folder>src</folder> <folder>src</folder>
<folder>services</folder> <folder>services</folder>
<folder>forms</folder> <folder>forms</folder>
@@ -25,7 +25,7 @@
</files> </files>
<languages folder="language"> <languages folder="language">
<language tag="en-GB">en-GB/plg_task_mokosuiteclientdemo.ini</language> <language tag="en-GB">en-GB/plg_task_mokosuiteclientsync.ini</language>
<language tag="en-GB">en-GB/plg_task_mokosuiteclientdemo.sys.ini</language> <language tag="en-GB">en-GB/plg_task_mokosuiteclientsync.sys.ini</language>
</languages> </languages>
</extension> </extension>
@@ -10,7 +10,7 @@
* INGROUP: MokoSuiteClient * INGROUP: MokoSuiteClient
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient
* PATH: /src/packages/plg_system_mokosuiteclient/Service/ContentSyncReceiver.php * PATH: /src/packages/plg_system_mokosuiteclient/Service/ContentSyncReceiver.php
* VERSION: 02.34.83 * VERSION: 02.34.50
* BRIEF: Receiver-side content sync — applies incoming payload to local DB * BRIEF: Receiver-side content sync — applies incoming payload to local DB
*/ */
@@ -10,7 +10,7 @@
* INGROUP: MokoSuiteClient * INGROUP: MokoSuiteClient
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient
* PATH: /src/packages/plg_system_mokosuiteclient/Service/ContentSyncService.php * PATH: /src/packages/plg_system_mokosuiteclient/Service/ContentSyncService.php
* VERSION: 02.34.83 * VERSION: 02.34.50
* BRIEF: Sender-side content sync — builds payload and pushes to remote sites * BRIEF: Sender-side content sync — builds payload and pushes to remote sites
*/ */
@@ -7,7 +7,7 @@
<license>GPL-3.0-or-later</license> <license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl> <authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.83-dev</version> <version>02.34.50-dev</version>
<description>Joomla Web Services API routes for MokoSuiteClient site management — health checks, cache, updates, backups, and site info.</description> <description>Joomla Web Services API routes for MokoSuiteClient site management — health checks, cache, updates, backups, and site info.</description>
<namespace path="src">Moko\Plugin\WebServices\MokoSuiteClient</namespace> <namespace path="src">Moko\Plugin\WebServices\MokoSuiteClient</namespace>
<files> <files>
@@ -124,53 +124,5 @@ final class MokoSuiteClientApi extends CMSPlugin implements SubscriberInterface
'provision', 'provision',
['component' => 'com_mokosuiteclient'] ['component' => 'com_mokosuiteclient']
); );
// User management API (#31)
$router->createCRUDRoutes(
'v1/mokosuiteclient/users',
'users',
['component' => 'com_mokosuiteclient']
);
foreach (['reset-passwords' => 'resetPasswords', 'reset-2fa' => 'reset2fa', 'disable-all' => 'disableAll', 'enable-all' => 'enableAll', 'force-logout' => 'forceLogout'] as $slug => $task)
{
$router->addRoute(
new \Joomla\Router\Route(
['POST'],
'v1/mokosuiteclient/users/' . $slug,
'users.' . $task,
[],
['component' => 'com_mokosuiteclient']
)
);
}
$router->addRoute(
new \Joomla\Router\Route(
['GET'],
'v1/mokosuiteclient/users/export',
'users.export',
[],
['component' => 'com_mokosuiteclient']
)
);
// Helpdesk Tickets API (#142)
$router->createCRUDRoutes(
'v1/mokosuiteclient/tickets',
'tickets',
['component' => 'com_mokosuiteclient']
);
// Ticket reply (custom route — POST only)
$router->addRoute(
new \Joomla\Router\Route(
['POST'],
'v1/mokosuiteclient/tickets/:id/reply',
'tickets.reply',
['id' => '(\d+)'],
['component' => 'com_mokosuiteclient']
)
);
} }
} }
+157 -51
View File
@@ -77,7 +77,6 @@ class Pkg_MokosuiteInstallerScript
$this->enablePlugin('system', 'mokosuiteclient_tenant'); $this->enablePlugin('system', 'mokosuiteclient_tenant');
$this->enablePlugin('system', 'mokosuiteclient_devtools'); $this->enablePlugin('system', 'mokosuiteclient_devtools');
$this->enablePlugin('system', 'mokosuiteclient_offline'); $this->enablePlugin('system', 'mokosuiteclient_offline');
$this->enablePlugin('system', 'mokosuiteclient_dbip');
$this->enablePlugin('webservices', 'mokosuiteclient'); $this->enablePlugin('webservices', 'mokosuiteclient');
$this->enablePlugin('task', 'mokosuiteclientdemo'); $this->enablePlugin('task', 'mokosuiteclientdemo');
$this->enablePlugin('task', 'mokosuiteclientsync'); $this->enablePlugin('task', 'mokosuiteclientsync');
@@ -944,87 +943,194 @@ class Pkg_MokosuiteInstallerScript
*/ */
private function setupCpanelModule(): void private function setupCpanelModule(): void
{ {
$this->ensureAdminModule('mod_mokosuiteclient_cpanel', 'MokoSuiteClient', 'top', 6, 0, '{"show_health":"1","show_plugins":"1"}'); try
{
$db = Factory::getDbo();
// Enable the module
$query = $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_mokosuiteclient_cpanel'));
$db->setQuery($query);
$db->execute();
// Check if a module instance already exists in #__modules
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__modules'))
->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokosuiteclient_cpanel'));
$db->setQuery($query);
if ((int) $db->loadResult() > 0)
{
return;
} }
private function setupAdminMenuModule(): void // Create the module instance on the cpanel position
{ $module = (object) [
$this->ensureAdminModule('mod_mokosuiteclient_menu', 'MokoSuiteClient Menu', 'menu', 3, 0); 'title' => 'MokoSuiteClient',
} 'note' => '',
'content' => '',
'ordering' => 0,
'position' => 'top',
'checked_out' => null,
'checked_out_time' => null,
'publish_up' => null,
'publish_down' => null,
'published' => 1,
'module' => 'mod_mokosuiteclient_cpanel',
'access' => 6, // Super Users only
'showtitle' => 0,
'params' => '{"show_health":"1","show_plugins":"1"}',
'client_id' => 1, // Administrator
'language' => '*',
];
private function setupCacheModule(): void $db->insertObject('#__modules', $module, 'id');
$moduleId = (int) $module->id;
if ($moduleId)
{ {
$this->ensureAdminModule('mod_mokosuiteclient_cache', 'MokoSuiteClient Cache Cleaner', 'status', 3, 8); // Assign to all admin pages
$map = (object) [
'moduleid' => $moduleId,
'menuid' => 0, // 0 = all pages
];
$db->insertObject('#__modules_menu', $map);
}
}
catch (\Throwable $e)
{
Log::add('CPanel module setup error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
}
} }
/** /**
* Ensure an admin module is published at the correct position using Joomla's ModuleModel. * Set up the MokoSuiteClient admin sidebar menu module at position 0.
*
* Uses the Joomla MVC save pipeline so that #__modules_menu mappings,
* checked_out, and all internal bookkeeping are handled correctly.
*/ */
private function ensureAdminModule(string $element, string $title, string $position, int $access = 3, int $ordering = 0, string $params = '{}'): void private function setupAdminMenuModule(): void
{ {
try try
{ {
$db = Factory::getDbo(); $db = Factory::getDbo();
// Enable the extension entry // Enable the module extension
$db->setQuery( $db->setQuery(
$db->getQuery(true) $db->getQuery(true)
->update('#__extensions') ->update($db->quoteName('#__extensions'))
->set('enabled = 1') ->set($db->quoteName('enabled') . ' = 1')
->where('type = ' . $db->quote('module')) ->where($db->quoteName('type') . ' = ' . $db->quote('module'))
->where('element = ' . $db->quote($element)) ->where($db->quoteName('element') . ' = ' . $db->quote('mod_mokosuiteclient_menu'))
)->execute(); )->execute();
// Find existing module instance // Check if module instance exists
$db->setQuery( $db->setQuery(
$db->getQuery(true) $db->getQuery(true)
->select('id') ->select('COUNT(*)')
->from('#__modules') ->from($db->quoteName('#__modules'))
->where('module = ' . $db->quote($element)) ->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokosuiteclient_menu'))
->setLimit(1)
); );
$moduleId = (int) $db->loadResult();
// Build save data — Joomla's ModuleModel expects this format if ((int) $db->loadResult() > 0)
$data = [
'title' => $title,
'module' => $element,
'position' => $position,
'published' => 1,
'access' => $access,
'ordering' => $ordering,
'showtitle' => 0,
'client_id' => 1,
'language' => '*',
'params' => $params,
'assignment' => 0, // 0 = all pages
];
if ($moduleId > 0)
{ {
$data['id'] = $moduleId; return;
} }
// Use Joomla's ModuleModel to handle save + menu assignment $module = (object) [
\Joomla\CMS\MVC\Factory\MVCFactory::class; 'title' => 'MokoSuiteClient Menu',
$app = Factory::getApplication(); 'note' => '',
'content' => '',
'ordering' => 0,
'position' => 'menu',
'checked_out' => null,
'checked_out_time' => null,
'publish_up' => null,
'publish_down' => null,
'published' => 1,
'module' => 'mod_mokosuiteclient_menu',
'access' => 3,
'showtitle' => 0,
'params' => '{}',
'client_id' => 1,
'language' => '*',
];
/** @var \Joomla\Component\Modules\Administrator\Model\ModuleModel $model */ $db->insertObject('#__modules', $module, 'id');
$model = $app->bootComponent('com_modules')
->getMVCFactory()
->createModel('Module', 'Administrator', ['ignore_request' => true]);
if (!$model->save($data)) if ((int) $module->id)
{ {
Log::add("Module setup ({$element}): " . $model->getError(), Log::WARNING, 'mokosuiteclient'); $db->insertObject('#__modules_menu', (object) ['moduleid' => (int) $module->id, 'menuid' => 0]);
} }
} }
catch (\Throwable $e) catch (\Throwable $e)
{ {
Log::add("Module setup ({$element}): " . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); Log::add('Admin menu module setup error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
}
}
/**
* 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_mokosuiteclient_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_mokosuiteclient_cache'))
);
if ((int) $db->loadResult() > 0)
{
return;
}
$module = (object) [
'title' => 'MokoSuiteClient 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_mokosuiteclient_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, 'mokosuiteclient');
} }
} }