diff --git a/.mokogitea/manifest.xml b/.mokogitea/manifest.xml
index b1bcc1ee..5b16dc77 100644
--- a/.mokogitea/manifest.xml
+++ b/.mokogitea/manifest.xml
@@ -9,7 +9,7 @@
Package - MokoSuite
MokoConsulting
White-label identity, security hardening, and tenant restriction layer for Suite-managed Joomla environments
- 02.34.50
+ 02.34.55
GNU General Public License v3
diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml
index 92d94ac3..b4f6301d 100644
--- a/.mokogitea/workflows/issue-branch.yml
+++ b/.mokogitea/workflows/issue-branch.yml
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation
-# VERSION: 02.34.50
+# VERSION: 02.34.55
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
diff --git a/.mokogitea/workflows/rc-revert.yml b/.mokogitea/workflows/rc-revert.yml
new file mode 100644
index 00000000..f54b1840
--- /dev/null
+++ b/.mokogitea/workflows/rc-revert.yml
@@ -0,0 +1,66 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# 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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 79c396ba..9155f2f1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,7 +14,7 @@
INGROUP: MokoSuite.Documentation
REPO: https://github.com/mokoconsulting-tech/mokosuite
PATH: ./CHANGELOG.md
- VERSION: 02.34.50
+ VERSION: 02.34.55
BRIEF: Version history using `Keep a Changelog`
-->
@@ -23,6 +23,9 @@
## [Unreleased]
### Added
+- plg_system_mokosuite_dbip — IP geolocation plugin using DB-IP MMDB databases (CDN auto-download, local file mode, bundled MaxMind reader)
+- Admin sidebar menu restructure — each Moko component gets its own collapsible section, com_mokosuitehq pinned first
+- rc-revert workflow for release candidate rollbacks
- RSA-signed heartbeat authentication — private key in monitor plugin manifest, public key on MokoSuiteHQ
- Monitor plugin base_url set via manifest (hidden from admin UI), propagated via update server
- Send Heartbeat button on health token field for manual heartbeat testing
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index ecad5cb3..fa824126 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -14,7 +14,7 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoSuite.Documentation
REPO: https://github.com/mokoconsulting-tech/mokosuite
- VERSION: 02.34.50
+ VERSION: 02.34.55
PATH: ./CODE_OF_CONDUCT.md
BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default
-->
diff --git a/GOVERNANCE.md b/GOVERNANCE.md
index 2696ef49..c866f439 100644
--- a/GOVERNANCE.md
+++ b/GOVERNANCE.md
@@ -19,7 +19,7 @@
DEFGROUP: mokoconsulting-tech.MokoSuiteBrand
INGROUP: MokoStandards.Governance
REPO: https://github.com/mokoconsulting-tech/MokoSuiteBrand
- VERSION: 02.34.50
+ VERSION: 02.34.55
PATH: /GOVERNANCE.md
BRIEF: Project governance rules, roles, and decision process for MokoSuiteBrand
-->
diff --git a/LICENSE.md b/LICENSE.md
index 23cc6208..3f83f007 100644
--- a/LICENSE.md
+++ b/LICENSE.md
@@ -15,7 +15,7 @@
INGROUP: MokoSuite.Documentation
REPO: https://github.com/mokoconsulting-tech/mokosuite
PATH: ./LICENSE.md
- VERSION: 02.34.50
+ VERSION: 02.34.55
BRIEF: Project license (GPL-3.0-or-later)
-->
GNU GENERAL PUBLIC LICENSE
diff --git a/README.md b/README.md
index 1a9620cd..4c463de0 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoSuite
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuite
- VERSION: 02.34.50
+ VERSION: 02.34.55
PATH: /README.md
BRIEF: MokoSuite platform plugin for Joomla
-->
diff --git a/SECURITY.md b/SECURITY.md
index bce7fda6..5c0a29d3 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -23,7 +23,7 @@ DEFGROUP: [PROJECT_NAME]
INGROUP: [PROJECT_NAME].Documentation
REPO: [REPOSITORY_URL]
PATH: /SECURITY.md
-VERSION: 02.34.50
+VERSION: 02.34.55
BRIEF: Security vulnerability reporting and handling policy
-->
diff --git a/docs/guides/build-guide.md b/docs/guides/build-guide.md
index 96b47b06..5bf2dad0 100644
--- a/docs/guides/build-guide.md
+++ b/docs/guides/build-guide.md
@@ -11,13 +11,13 @@
INGROUP: MokoSuite.Build
REPO: https://github.com/mokoconsulting-tech/mokosuite
FILE: build-guide.md
- VERSION: 02.34.50
+ VERSION: 02.34.55
PATH: /docs/guides/
BRIEF: Build and packaging guide for the MokoSuite system plugin
NOTE: Defines environment setup, repository layout, packaging rules, and release preparation
-->
-# MokoSuite Build Guide (VERSION: 02.34.50)
+# MokoSuite Build Guide (VERSION: 02.34.55)
## 1. Purpose
diff --git a/docs/guides/configuration-guide.md b/docs/guides/configuration-guide.md
index 43c04cb9..60bc803f 100644
--- a/docs/guides/configuration-guide.md
+++ b/docs/guides/configuration-guide.md
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoSuite.Guides
REPO: https://github.com/mokoconsulting-tech/mokosuite
- VERSION: 02.34.50
+ VERSION: 02.34.55
PATH: /docs/guides/configuration-guide.md
BRIEF: Configuration guide for the MokoSuite system plugin
NOTE: Defines plugin parameters, expected behaviors, and recommended defaults
-->
-# MokoSuite Configuration Guide (VERSION: 02.34.50)
+# MokoSuite Configuration Guide (VERSION: 02.34.55)
## 1. Objective
diff --git a/docs/guides/installation-guide.md b/docs/guides/installation-guide.md
index 866f006f..bc2c38f6 100644
--- a/docs/guides/installation-guide.md
+++ b/docs/guides/installation-guide.md
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoSuite.Guides
REPO: https://github.com/mokoconsulting-tech/mokosuite
- VERSION: 02.34.50
+ VERSION: 02.34.55
PATH: /docs/guides/installation-guide.md
BRIEF: Installation guide for the MokoSuite system plugin
NOTE: First document in the guide set
-->
-# MokoSuite Installation Guide (VERSION: 02.34.50)
+# MokoSuite Installation Guide (VERSION: 02.34.55)
## Introduction
diff --git a/docs/guides/operations-guide.md b/docs/guides/operations-guide.md
index 6bcdcc18..6fe432be 100644
--- a/docs/guides/operations-guide.md
+++ b/docs/guides/operations-guide.md
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoSuite.Guides
REPO: https://github.com/mokoconsulting-tech/mokosuite
- VERSION: 02.34.50
+ VERSION: 02.34.55
PATH: /docs/guides/operations-guide.md
BRIEF: Operational guide for administering and managing the MokoSuite system plugin
NOTE: Defines lifecycle, responsibilities, and operational behaviors
-->
-# MokoSuite Operations Guide (VERSION: 02.34.50)
+# MokoSuite Operations Guide (VERSION: 02.34.55)
## Introduction
diff --git a/docs/guides/rollback-and-recovery-guide.md b/docs/guides/rollback-and-recovery-guide.md
index 6ec87d21..b38b8cae 100644
--- a/docs/guides/rollback-and-recovery-guide.md
+++ b/docs/guides/rollback-and-recovery-guide.md
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoSuite.Guides
REPO: https://github.com/mokoconsulting-tech/mokosuite
- VERSION: 02.34.50
+ VERSION: 02.34.55
PATH: /docs/guides/rollback-and-recovery-guide.md
BRIEF: Rollback and recovery guide for restoring stable operation after plugin related incidents
NOTE: Completes the core guide set for Suite plugin governance
-->
-# MokoSuite Rollback and Recovery Guide (VERSION: 02.34.50)
+# MokoSuite Rollback and Recovery Guide (VERSION: 02.34.55)
## Introduction
diff --git a/docs/guides/testing-guide.md b/docs/guides/testing-guide.md
index f257623d..07cf2afc 100644
--- a/docs/guides/testing-guide.md
+++ b/docs/guides/testing-guide.md
@@ -7,13 +7,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoSuite.Guides
REPO: https://github.com/mokoconsulting-tech/mokosuite
- VERSION: 02.34.50
+ VERSION: 02.34.55
PATH: /docs/guides/testing-guide.md
BRIEF: Testing guide for MokoSuite v02.01.08
NOTE: Covers manual test procedures for language overrides, install/uninstall, and configuration
-->
-# MokoSuite Testing Guide (VERSION: 02.34.50)
+# MokoSuite Testing Guide (VERSION: 02.34.55)
## 1. Prerequisites
diff --git a/docs/guides/troubleshooting-guide.md b/docs/guides/troubleshooting-guide.md
index 29297cf6..aa93d45b 100644
--- a/docs/guides/troubleshooting-guide.md
+++ b/docs/guides/troubleshooting-guide.md
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoSuite.Guides
REPO: https://github.com/mokoconsulting-tech/mokosuite
- VERSION: 02.34.50
+ VERSION: 02.34.55
PATH: /docs/guides/troubleshooting-guide.md
BRIEF: Troubleshooting guide for diagnosing and resolving issues related to the MokoSuite plugin
NOTE: Designed for administrators and Suite operations teams
-->
-# MokoSuite Troubleshooting Guide (VERSION: 02.34.50)
+# MokoSuite Troubleshooting Guide (VERSION: 02.34.55)
## Introduction
diff --git a/docs/guides/upgrade-and-versioning-guide.md b/docs/guides/upgrade-and-versioning-guide.md
index b6975d4e..4b21b6a5 100644
--- a/docs/guides/upgrade-and-versioning-guide.md
+++ b/docs/guides/upgrade-and-versioning-guide.md
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoSuite.Guides
REPO: https://github.com/mokoconsulting-tech/mokosuite
- VERSION: 02.34.50
+ VERSION: 02.34.55
PATH: /docs/guides/upgrade-and-versioning-guide.md
BRIEF: Guide for updating, versioning, and maintaining the MokoSuite plugin
NOTE: Defines release flow, version rules, and upgrade validation
-->
-# MokoSuite Upgrade and Versioning Guide (VERSION: 02.34.50)
+# MokoSuite Upgrade and Versioning Guide (VERSION: 02.34.55)
## Introduction
diff --git a/docs/index.md b/docs/index.md
index 45f8e2ee..6fb8d48f 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoSuite.Documentation
REPO: https://github.com/mokoconsulting-tech/mokosuite
- VERSION: 02.34.50
+ VERSION: 02.34.55
PATH: /docs/index.md
BRIEF: Master index of all documentation for the MokoSuite plugin
NOTE: Automatically maintained index for all guide canvases
-->
-# MokoSuite Documentation Index (VERSION: 02.34.50)
+# MokoSuite Documentation Index (VERSION: 02.34.55)
## Introduction
diff --git a/docs/plugin-basic.md b/docs/plugin-basic.md
index 0f559a1a..4518d197 100644
--- a/docs/plugin-basic.md
+++ b/docs/plugin-basic.md
@@ -11,12 +11,12 @@
INGROUP: MokoSuite
REPO: https://github.com/mokoconsulting-tech/mokosuite
PATH: /docs/plugin-basic.md
- VERSION: 02.34.50
+ VERSION: 02.34.55
BRIEF: Baseline documentation for the MokoSuite system plugin
NOTE: Foundational reference for internal and external stakeholders
-->
-# MokoSuite Plugin Overview (VERSION: 02.34.50)
+# MokoSuite Plugin Overview (VERSION: 02.34.55)
## Introduction
diff --git a/docs/update-server.md b/docs/update-server.md
index 4fd5d6b5..0c271bb9 100644
--- a/docs/update-server.md
+++ b/docs/update-server.md
@@ -10,7 +10,7 @@ DEFGROUP: MokoSuite.Documentation
INGROUP: MokoStandards.Templates
REPO: https://github.com/mokoconsulting-tech/MokoSuite
PATH: /docs/update-server.md
-VERSION: 02.34.50
+VERSION: 02.34.55
BRIEF: How this extension's Joomla update server file (update.xml) is managed
-->
diff --git a/source/packages/com_mokosuite/admin/src/Service/NotificationService.php b/source/packages/com_mokosuite/admin/src/Service/NotificationService.php
index f0b94f77..27bf3c5b 100644
--- a/source/packages/com_mokosuite/admin/src/Service/NotificationService.php
+++ b/source/packages/com_mokosuite/admin/src/Service/NotificationService.php
@@ -70,6 +70,9 @@ class NotificationService
Log::add('Notification send failed to ' . $email . ': ' . $e->getMessage(), Log::WARNING, 'mokosuite');
}
}
+
+ // Push notification via ntfy
+ self::pushNtfy($event, $ticket, $subject);
}
catch (\Throwable $e)
{
@@ -332,6 +335,159 @@ 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'] ?? 'mokosuite-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_mokosuite&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, 'mokosuite');
+ }
+ }
+ catch (\Throwable $e)
+ {
+ Log::add('Ntfy push error: ' . $e->getMessage(), Log::WARNING, 'mokosuite');
+ }
+ }
+
+ /**
+ * 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'] ?? 'mokosuite-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, 'mokosuite');
+ }
+ }
+
// ==================================================================
// Security Event Notifications (#131)
// ==================================================================
@@ -407,6 +563,9 @@ class NotificationService
Log::add('Security alert send failed: ' . $e->getMessage(), Log::WARNING, 'mokosuite');
}
}
+
+ // Also push via ntfy
+ self::pushNtfySecurity($event, $subject, $body);
}
catch (\Throwable $e)
{
diff --git a/source/packages/com_mokosuite/mokosuite.xml b/source/packages/com_mokosuite/mokosuite.xml
index 645ca47d..5559dfc7 100644
--- a/source/packages/com_mokosuite/mokosuite.xml
+++ b/source/packages/com_mokosuite/mokosuite.xml
@@ -20,7 +20,7 @@
GPL-3.0-or-later
hello@mokoconsulting.tech
https://mokoconsulting.tech
- 02.34.50-dev
+ 02.34.55-dev
MokoSuite admin dashboard and REST API. Provides a control panel for managing MokoSuite feature plugins, site health monitoring, and remote management endpoints.
Moko\Component\MokoSuite
diff --git a/source/packages/mod_mokosuite_cache/mod_mokosuite_cache.xml b/source/packages/mod_mokosuite_cache/mod_mokosuite_cache.xml
index 7a89d358..6460d428 100644
--- a/source/packages/mod_mokosuite_cache/mod_mokosuite_cache.xml
+++ b/source/packages/mod_mokosuite_cache/mod_mokosuite_cache.xml
@@ -7,7 +7,7 @@
GPL-3.0-or-later
hello@mokoconsulting.tech
https://mokoconsulting.tech
- 02.34.50-dev
+ 02.34.55-dev
MOD_MOKOSUITE_CACHE_DESC
Moko\Module\MokoSuiteCache
diff --git a/source/packages/mod_mokosuite_categories/mod_mokosuite_categories.xml b/source/packages/mod_mokosuite_categories/mod_mokosuite_categories.xml
index 6b9cda2b..4b8b7c72 100644
--- a/source/packages/mod_mokosuite_categories/mod_mokosuite_categories.xml
+++ b/source/packages/mod_mokosuite_categories/mod_mokosuite_categories.xml
@@ -7,7 +7,7 @@
GPL-3.0-or-later
hello@mokoconsulting.tech
https://mokoconsulting.tech
- 02.34.50-dev
+ 02.34.55-dev
MOD_MOKOSUITE_CATEGORIES_DESC
Moko\Module\MokoSuiteCategories
diff --git a/source/packages/mod_mokosuite_cpanel/mod_mokosuite_cpanel.xml b/source/packages/mod_mokosuite_cpanel/mod_mokosuite_cpanel.xml
index e4c07e69..51814dec 100644
--- a/source/packages/mod_mokosuite_cpanel/mod_mokosuite_cpanel.xml
+++ b/source/packages/mod_mokosuite_cpanel/mod_mokosuite_cpanel.xml
@@ -7,7 +7,7 @@
GPL-3.0-or-later
hello@mokoconsulting.tech
https://mokoconsulting.tech
- 02.34.50-dev
+ 02.34.55-dev
MOD_MOKOSUITE_CPANEL_DESC
Moko\Module\MokoSuiteCpanel
diff --git a/source/packages/mod_mokosuite_menu/mod_mokosuite_menu.xml b/source/packages/mod_mokosuite_menu/mod_mokosuite_menu.xml
index 3cb6de06..6b8498bd 100644
--- a/source/packages/mod_mokosuite_menu/mod_mokosuite_menu.xml
+++ b/source/packages/mod_mokosuite_menu/mod_mokosuite_menu.xml
@@ -7,7 +7,7 @@
GPL-3.0-or-later
hello@mokoconsulting.tech
https://mokoconsulting.tech
- 02.34.50-dev
+ 02.34.55-dev
MokoSuite admin sidebar menu — renders a dedicated MokoSuite section in the admin menu before Joomla's default menu.
Moko\Module\MokoSuiteMenu
diff --git a/source/packages/mod_mokosuite_menu/tmpl/default.php b/source/packages/mod_mokosuite_menu/tmpl/default.php
index 63d658f9..86447f21 100644
--- a/source/packages/mod_mokosuite_menu/tmpl/default.php
+++ b/source/packages/mod_mokosuite_menu/tmpl/default.php
@@ -2,9 +2,9 @@
/**
* MokoSuite Admin Sidebar Menu
*
- * Renders MokoSuite static views first, then auto-discovers installed
- * Moko components from #__menu and renders their submenu items as
- * nested MetisMenu collapsible sections.
+ * Each installed Moko component gets its own top-level collapsible section.
+ * com_mokosuitehq is always pinned first. com_mokosuite uses static views
+ * as children. All other components auto-discover their submenu items.
*/
defined('_JEXEC') or die;
@@ -17,8 +17,8 @@ $app = Factory::getApplication();
$currentOption = $app->getInput()->get('option', '');
$currentView = $app->getInput()->get('view', '');
-// ── Static MokoSuite views ────────────────────────────────────────────
-$mokosuiteItems = [
+// ── Static views for com_mokosuite ──────────────────────────────────
+$mokosuiteStaticViews = [
['icon' => 'icon-cogs', 'title' => 'Dashboard', 'link' => 'index.php?option=com_mokosuite'],
['icon' => 'fa-solid fa-handshake-angle', 'title' => 'Helpdesk', 'link' => 'index.php?option=com_mokosuite&view=tickets'],
['icon' => 'icon-puzzle-piece', 'title' => 'Extensions', 'link' => 'index.php?option=com_mokosuite&view=extensions'],
@@ -30,27 +30,25 @@ $mokosuiteItems = [
['icon' => 'icon-power-off', 'title' => 'Feature Plugins', 'link' => 'index.php?option=com_plugins&filter[folder]=system&filter[search]=mokosuite'],
];
-// ── Auto-discover Moko component menus from #__menu ──────────────────
+// ── Auto-discover all Moko components from #__menu ──────────────────
$mokoComponents = [];
try
{
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
- // Find all Moko component menu items (exclude com_mokosuite — handled above)
$db->setQuery(
"SELECT m.id, m.title, m.link, m.level, m.parent_id, m.img, e.element"
. " FROM " . $db->quoteName('#__menu') . " m"
. " 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"
. " AND e.element LIKE 'com_moko%'"
- . " AND e.element != 'com_mokosuite'"
. " AND e.enabled = 1"
. " ORDER BY e.element, m.level, m.lft"
);
$menuItems = $db->loadObjectList() ?: [];
- // Load sys.ini language files for discovered components
+ // Load language files for discovered components
$lang = Factory::getLanguage();
$loadedLangs = [];
foreach ($menuItems as $m)
@@ -92,100 +90,112 @@ catch (\Throwable $e)
// Silent — menu works without auto-discovered components
}
-// ── Determine active state ───────────────────────────────────────────
-$mokosuiteActive = ($currentOption === 'com_mokosuite');
-$anyMokoActive = $mokosuiteActive;
-
-foreach ($mokoComponents as $comp)
+// Override com_mokosuite children with static views
+if (isset($mokoComponents['com_mokosuite']))
{
- $parsed = [];
- parse_str(parse_url($comp['link'], PHP_URL_QUERY) ?? '', $parsed);
- if (($parsed['option'] ?? '') === $currentOption)
+ $mokoComponents['com_mokosuite']['children'] = $mokosuiteStaticViews;
+ $mokoComponents['com_mokosuite']['icon'] = 'icon-shield-alt';
+}
+else
+{
+ // com_mokosuite not in admin menu — add it manually
+ $mokoComponents['com_mokosuite'] = [
+ 'id' => 0,
+ 'title' => 'MokoSuite',
+ 'link' => 'index.php?option=com_mokosuite',
+ 'icon' => 'icon-shield-alt',
+ 'element' => 'com_mokosuite',
+ 'children' => $mokosuiteStaticViews,
+ ];
+}
+
+// ── Sort: com_mokosuitehq first, then alphabetical by title ─────────
+$hq = null;
+$rest = [];
+
+foreach ($mokoComponents as $key => $comp)
+{
+ if ($key === 'com_mokosuitehq')
{
- $anyMokoActive = true;
+ $hq = $comp;
+ }
+ else
+ {
+ $rest[$key] = $comp;
}
}
-$topClass = 'item parent item-level-1' . ($anyMokoActive ? ' mm-active' : '');
-$topCollapse = 'collapse-level-1 mm-collapse' . ($anyMokoActive ? ' mm-show' : '');
+usort($rest, fn($a, $b) => strcasecmp($a['title'], $b['title']));
+
+$sorted = [];
+if ($hq !== null)
+{
+ $sorted[] = $hq;
+}
+foreach ($rest as $comp)
+{
+ $sorted[] = $comp;
+}
?>
diff --git a/source/packages/plg_system_mokosuite/Extension/MokoSuite.php b/source/packages/plg_system_mokosuite/Extension/MokoSuite.php
index 451f3644..18decbce 100644
--- a/source/packages/plg_system_mokosuite/Extension/MokoSuite.php
+++ b/source/packages/plg_system_mokosuite/Extension/MokoSuite.php
@@ -22,7 +22,7 @@
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoSuite
* REPO: https://github.com/mokoconsulting-tech/mokosuite
- * VERSION: 02.34.50
+ * VERSION: 02.34.55
* PATH: /src/Extension/MokoSuite.php
* NOTE: Core system plugin for MokoSuite admin tools suite
*/
diff --git a/source/packages/plg_system_mokosuite/Field/CopyableTokenField.php b/source/packages/plg_system_mokosuite/Field/CopyableTokenField.php
index ec6c8474..fcf4103f 100644
--- a/source/packages/plg_system_mokosuite/Field/CopyableTokenField.php
+++ b/source/packages/plg_system_mokosuite/Field/CopyableTokenField.php
@@ -8,7 +8,7 @@
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoSuite
- * VERSION: 02.34.50
+ * VERSION: 02.34.55
* PATH: /src/Field/CopyableTokenField.php
* BRIEF: Read-only token field with a copy-to-clipboard button
*/
diff --git a/source/packages/plg_system_mokosuite/mokosuite.xml b/source/packages/plg_system_mokosuite/mokosuite.xml
index e63160c9..69fc65a3 100644
--- a/source/packages/plg_system_mokosuite/mokosuite.xml
+++ b/source/packages/plg_system_mokosuite/mokosuite.xml
@@ -30,7 +30,7 @@
GNU General Public License version 3 or later; see LICENSE.md
hello@mokoconsulting.tech
https://mokoconsulting.tech
- 02.34.50-dev
+ 02.34.55-dev
MokoSuite core system plugin — coordinates feature plugins, heartbeat, health checks, and admin customizations.
Moko\Plugin\System\MokoSuite
script.php
diff --git a/source/packages/plg_system_mokosuite/script.php b/source/packages/plg_system_mokosuite/script.php
index d5acfd60..684d5786 100644
--- a/source/packages/plg_system_mokosuite/script.php
+++ b/source/packages/plg_system_mokosuite/script.php
@@ -22,7 +22,7 @@
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoSuite
* REPO: https://github.com/mokoconsulting-tech/mokosuite
- * VERSION: 02.34.50
+ * VERSION: 02.34.55
* PATH: /src/script.php
* BRIEF: Installation script for MokoSuite plugin
* NOTE: Handles installation, update, and uninstallation tasks including language override deployment
diff --git a/source/packages/plg_system_mokosuite/services/provider.php b/source/packages/plg_system_mokosuite/services/provider.php
index 9c8af3a5..08c7a5ea 100644
--- a/source/packages/plg_system_mokosuite/services/provider.php
+++ b/source/packages/plg_system_mokosuite/services/provider.php
@@ -22,7 +22,7 @@
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoSuite
* REPO: https://github.com/mokoconsulting-tech/mokosuite
- * VERSION: 02.34.50
+ * VERSION: 02.34.55
* PATH: /src/services/provider.php
* BRIEF: Service provider for dependency injection in Joomla 5.x
* NOTE: Registers the plugin with Joomla's DI container
diff --git a/source/packages/plg_system_mokosuite_dbip/data/dbip-country-lite.mmdb b/source/packages/plg_system_mokosuite_dbip/data/dbip-country-lite.mmdb
new file mode 100644
index 00000000..df09a565
Binary files /dev/null and b/source/packages/plg_system_mokosuite_dbip/data/dbip-country-lite.mmdb differ
diff --git a/source/packages/plg_system_mokosuite_dbip/language/en-GB/plg_system_mokosuite_dbip.ini b/source/packages/plg_system_mokosuite_dbip/language/en-GB/plg_system_mokosuite_dbip.ini
new file mode 100644
index 00000000..a41c3269
--- /dev/null
+++ b/source/packages/plg_system_mokosuite_dbip/language/en-GB/plg_system_mokosuite_dbip.ini
@@ -0,0 +1,29 @@
+; MokoSuite 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 - MokoSuite DB-IP"
+PLG_SYSTEM_MOKOSUITE_DBIP_DESC="IP geolocation for MokoSuite 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)."
diff --git a/source/packages/plg_system_mokosuite_dbip/language/en-GB/plg_system_mokosuite_dbip.sys.ini b/source/packages/plg_system_mokosuite_dbip/language/en-GB/plg_system_mokosuite_dbip.sys.ini
new file mode 100644
index 00000000..4d22a997
--- /dev/null
+++ b/source/packages/plg_system_mokosuite_dbip/language/en-GB/plg_system_mokosuite_dbip.sys.ini
@@ -0,0 +1,6 @@
+; MokoSuite DB-IP Plugin (system strings)
+; Copyright (C) 2026 Moko Consulting. All rights reserved.
+; License: GPL-3.0-or-later
+
+PLG_SYSTEM_MOKOSUITE_DBIP="System - MokoSuite DB-IP"
+PLG_SYSTEM_MOKOSUITE_DBIP_DESC="IP geolocation for MokoSuite using DB-IP Lite databases."
diff --git a/source/packages/plg_system_mokosuite_dbip/lib/MaxMind/Db/Reader.php b/source/packages/plg_system_mokosuite_dbip/lib/MaxMind/Db/Reader.php
new file mode 100644
index 00000000..542563c6
--- /dev/null
+++ b/source/packages/plg_system_mokosuite_dbip/lib/MaxMind/Db/Reader.php
@@ -0,0 +1,404 @@
+
+ */
+ 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);
+ }
+}
diff --git a/source/packages/plg_system_mokosuite_dbip/lib/MaxMind/Db/Reader/Decoder.php b/source/packages/plg_system_mokosuite_dbip/lib/MaxMind/Db/Reader/Decoder.php
new file mode 100644
index 00000000..1bb67316
--- /dev/null
+++ b/source/packages/plg_system_mokosuite_dbip/lib/MaxMind/Db/Reader/Decoder.php
@@ -0,0 +1,452 @@
+fileStream = $fileStream;
+ $this->pointerBase = $pointerBase;
+
+ $this->pointerTestHack = $pointerTestHack;
+
+ $this->switchByteOrder = $this->isPlatformLittleEndian();
+ }
+
+ /**
+ * @return array
+ */
+ 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, 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, 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);
+ }
+}
diff --git a/source/packages/plg_system_mokosuite_dbip/lib/MaxMind/Db/Reader/InvalidDatabaseException.php b/source/packages/plg_system_mokosuite_dbip/lib/MaxMind/Db/Reader/InvalidDatabaseException.php
new file mode 100644
index 00000000..b1da1ed2
--- /dev/null
+++ b/source/packages/plg_system_mokosuite_dbip/lib/MaxMind/Db/Reader/InvalidDatabaseException.php
@@ -0,0 +1,11 @@
+
+ */
+ 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
+ */
+ 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 $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;
+ }
+}
diff --git a/source/packages/plg_system_mokosuite_dbip/lib/MaxMind/Db/Reader/Util.php b/source/packages/plg_system_mokosuite_dbip/lib/MaxMind/Db/Reader/Util.php
new file mode 100644
index 00000000..c2c3212d
--- /dev/null
+++ b/source/packages/plg_system_mokosuite_dbip/lib/MaxMind/Db/Reader/Util.php
@@ -0,0 +1,33 @@
+ $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'
+ );
+ }
+}
diff --git a/source/packages/plg_system_mokosuite_dbip/mokosuite_dbip.xml b/source/packages/plg_system_mokosuite_dbip/mokosuite_dbip.xml
new file mode 100644
index 00000000..2767e624
--- /dev/null
+++ b/source/packages/plg_system_mokosuite_dbip/mokosuite_dbip.xml
@@ -0,0 +1,75 @@
+
+
+ System - MokoSuite DB-IP
+ mokosuite_dbip
+ Moko Consulting
+ 2026-06-07
+ Copyright (C) 2026 Moko Consulting. All rights reserved.
+ GPL-3.0-or-later
+ hello@mokoconsulting.tech
+ https://mokoconsulting.tech
+ 02.34.55-dev
+ PLG_SYSTEM_MOKOSUITE_DBIP_DESC
+ Moko\Plugin\System\MokoSuiteDBIP
+
+
+ src
+ services
+ language
+ lib
+ data
+
+
+
+ en-GB/plg_system_mokosuite_dbip.ini
+ en-GB/plg_system_mokosuite_dbip.sys.ini
+
+
+
+
+
+
+
+
diff --git a/source/packages/plg_system_mokosuite_dbip/services/provider.php b/source/packages/plg_system_mokosuite_dbip/services/provider.php
new file mode 100644
index 00000000..07320a6c
--- /dev/null
+++ b/source/packages/plg_system_mokosuite_dbip/services/provider.php
@@ -0,0 +1,33 @@
+set(
+ PluginInterface::class,
+ function (Container $container) {
+ $dispatcher = $container->get(DispatcherInterface::class);
+ $plugin = new DBIP($dispatcher, (array) PluginHelper::getPlugin('system', 'mokosuite_dbip'));
+ $plugin->setApplication(Factory::getApplication());
+
+ return $plugin;
+ }
+ );
+ }
+};
diff --git a/source/packages/plg_system_mokosuite_dbip/src/Extension/DBIP.php b/source/packages/plg_system_mokosuite_dbip/src/Extension/DBIP.php
new file mode 100644
index 00000000..658efe2f
--- /dev/null
+++ b/source/packages/plg_system_mokosuite_dbip/src/Extension/DBIP.php
@@ -0,0 +1,83 @@
+ '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);
+ }
+}
diff --git a/source/packages/plg_system_mokosuite_dbip/src/Helper/DBIPHelper.php b/source/packages/plg_system_mokosuite_dbip/src/Helper/DBIPHelper.php
new file mode 100644
index 00000000..f751101c
--- /dev/null
+++ b/source/packages/plg_system_mokosuite_dbip/src/Helper/DBIPHelper.php
@@ -0,0 +1,269 @@
+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/mokosuite_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 => 'MokoSuite-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;
+ }
+ }
+}
diff --git a/source/packages/plg_system_mokosuite_devtools/mokosuite_devtools.xml b/source/packages/plg_system_mokosuite_devtools/mokosuite_devtools.xml
index 01f4cebe..118a4e66 100644
--- a/source/packages/plg_system_mokosuite_devtools/mokosuite_devtools.xml
+++ b/source/packages/plg_system_mokosuite_devtools/mokosuite_devtools.xml
@@ -8,7 +8,7 @@
GPL-3.0-or-later
hello@mokoconsulting.tech
https://mokoconsulting.tech
- 02.34.50-dev
+ 02.34.55-dev
PLG_SYSTEM_MOKOSUITE_DEVTOOLS_DESC
Moko\Plugin\System\MokoSuiteDevTools
diff --git a/source/packages/plg_system_mokosuite_firewall/mokosuite_firewall.xml b/source/packages/plg_system_mokosuite_firewall/mokosuite_firewall.xml
index ff4f5134..431a7d07 100644
--- a/source/packages/plg_system_mokosuite_firewall/mokosuite_firewall.xml
+++ b/source/packages/plg_system_mokosuite_firewall/mokosuite_firewall.xml
@@ -8,7 +8,7 @@
GPL-3.0-or-later
hello@mokoconsulting.tech
https://mokoconsulting.tech
- 02.34.50-dev
+ 02.34.55-dev
PLG_SYSTEM_MOKOSUITE_FIREWALL_DESC
Moko\Plugin\System\MokoSuiteFirewall
diff --git a/source/packages/plg_system_mokosuite_license/mokosuite_license.xml b/source/packages/plg_system_mokosuite_license/mokosuite_license.xml
index fa9ae1c9..5acb4e28 100644
--- a/source/packages/plg_system_mokosuite_license/mokosuite_license.xml
+++ b/source/packages/plg_system_mokosuite_license/mokosuite_license.xml
@@ -8,7 +8,7 @@
GPL-3.0-or-later
hello@mokoconsulting.tech
https://mokoconsulting.tech
- 02.34.50-dev
+ 02.34.55-dev
PLG_SYSTEM_MOKOSUITE_LICENSE_DESC
Moko\Plugin\System\MokoSuiteLicense
srcserviceslanguage
diff --git a/source/packages/plg_system_mokosuite_monitor/mokosuite_monitor.xml b/source/packages/plg_system_mokosuite_monitor/mokosuite_monitor.xml
index 1b245ab5..e810d8cb 100644
--- a/source/packages/plg_system_mokosuite_monitor/mokosuite_monitor.xml
+++ b/source/packages/plg_system_mokosuite_monitor/mokosuite_monitor.xml
@@ -8,7 +8,7 @@
GPL-3.0-or-later
hello@mokoconsulting.tech
https://mokoconsulting.tech
- 02.34.50-dev
+ 02.34.55-dev
PLG_SYSTEM_MOKOSUITE_MONITOR_DESC
Moko\Plugin\System\MokoSuiteMonitor
diff --git a/source/packages/plg_system_mokosuite_offline/mokosuite_offline.xml b/source/packages/plg_system_mokosuite_offline/mokosuite_offline.xml
index 08bfa328..1561e9bf 100644
--- a/source/packages/plg_system_mokosuite_offline/mokosuite_offline.xml
+++ b/source/packages/plg_system_mokosuite_offline/mokosuite_offline.xml
@@ -8,7 +8,7 @@
GPL-3.0-or-later
hello@mokoconsulting.tech
https://mokoconsulting.tech
- 02.34.50-dev
+ 02.34.55-dev
PLG_SYSTEM_MOKOSUITE_OFFLINE_DESC
Moko\Plugin\System\MokoSuiteOffline
diff --git a/source/packages/plg_system_mokosuite_tenant/mokosuite_tenant.xml b/source/packages/plg_system_mokosuite_tenant/mokosuite_tenant.xml
index 5fa4eac0..d20110d8 100644
--- a/source/packages/plg_system_mokosuite_tenant/mokosuite_tenant.xml
+++ b/source/packages/plg_system_mokosuite_tenant/mokosuite_tenant.xml
@@ -8,7 +8,7 @@
GPL-3.0-or-later
hello@mokoconsulting.tech
https://mokoconsulting.tech
- 02.34.50-dev
+ 02.34.55-dev
PLG_SYSTEM_MOKOSUITE_TENANT_DESC
Moko\Plugin\System\MokoSuiteTenant
diff --git a/source/packages/plg_task_mokosuite_tickets/mokosuite_tickets.xml b/source/packages/plg_task_mokosuite_tickets/mokosuite_tickets.xml
index 198c0b26..e0a3fda4 100644
--- a/source/packages/plg_task_mokosuite_tickets/mokosuite_tickets.xml
+++ b/source/packages/plg_task_mokosuite_tickets/mokosuite_tickets.xml
@@ -8,7 +8,7 @@
GPL-3.0-or-later
hello@mokoconsulting.tech
https://mokoconsulting.tech
- 02.34.50-dev
+ 02.34.55-dev
Runs scheduled helpdesk automation rules — auto-close resolved tickets, SLA breach escalation, and time-based actions.
Moko\Plugin\Task\MokoSuiteTickets
diff --git a/source/packages/plg_task_mokosuitedemo/mokosuitedemo.xml b/source/packages/plg_task_mokosuitedemo/mokosuitedemo.xml
index 0f98524f..dae8f3ad 100644
--- a/source/packages/plg_task_mokosuitedemo/mokosuitedemo.xml
+++ b/source/packages/plg_task_mokosuitedemo/mokosuitedemo.xml
@@ -12,7 +12,7 @@
GNU General Public License version 3 or later; see LICENSE
hello@mokoconsulting.tech
https://mokoconsulting.tech
- 02.34.50-dev
+ 02.34.55-dev
PLG_TASK_MOKOSUITEDEMO_DESC
Moko\Plugin\Task\MokoSuiteDemo
diff --git a/source/packages/plg_task_mokosuitedemo/src/Service/DemoResetService.php b/source/packages/plg_task_mokosuitedemo/src/Service/DemoResetService.php
index c4f232e8..4b698e00 100644
--- a/source/packages/plg_task_mokosuitedemo/src/Service/DemoResetService.php
+++ b/source/packages/plg_task_mokosuitedemo/src/Service/DemoResetService.php
@@ -10,7 +10,7 @@
* INGROUP: MokoSuite
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuite
* PATH: /src/packages/plg_system_mokosuite/Service/DemoResetService.php
- * VERSION: 02.34.50
+ * VERSION: 02.34.55
* BRIEF: Content-only snapshot/restore for demo site reset
*/
diff --git a/source/packages/plg_task_mokosuitesync/mokosuitesync.xml b/source/packages/plg_task_mokosuitesync/mokosuitesync.xml
index 2bb35774..1a4079e3 100644
--- a/source/packages/plg_task_mokosuitesync/mokosuitesync.xml
+++ b/source/packages/plg_task_mokosuitesync/mokosuitesync.xml
@@ -12,7 +12,7 @@
GNU General Public License version 3 or later; see LICENSE
hello@mokoconsulting.tech
https://mokoconsulting.tech
- 02.34.50-dev
+ 02.34.55-dev
PLG_TASK_MOKOSUITESYNC_DESC
Moko\Plugin\Task\MokoSuiteSync
diff --git a/source/packages/plg_task_mokosuitesync/src/Service/ContentSyncReceiver.php b/source/packages/plg_task_mokosuitesync/src/Service/ContentSyncReceiver.php
index 7ec211a5..79e3158e 100644
--- a/source/packages/plg_task_mokosuitesync/src/Service/ContentSyncReceiver.php
+++ b/source/packages/plg_task_mokosuitesync/src/Service/ContentSyncReceiver.php
@@ -10,7 +10,7 @@
* INGROUP: MokoSuite
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuite
* PATH: /src/packages/plg_system_mokosuite/Service/ContentSyncReceiver.php
- * VERSION: 02.34.50
+ * VERSION: 02.34.55
* BRIEF: Receiver-side content sync — applies incoming payload to local DB
*/
diff --git a/source/packages/plg_task_mokosuitesync/src/Service/ContentSyncService.php b/source/packages/plg_task_mokosuitesync/src/Service/ContentSyncService.php
index 94bd4f14..cc236a02 100644
--- a/source/packages/plg_task_mokosuitesync/src/Service/ContentSyncService.php
+++ b/source/packages/plg_task_mokosuitesync/src/Service/ContentSyncService.php
@@ -10,7 +10,7 @@
* INGROUP: MokoSuite
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuite
* PATH: /src/packages/plg_system_mokosuite/Service/ContentSyncService.php
- * VERSION: 02.34.50
+ * VERSION: 02.34.55
* BRIEF: Sender-side content sync — builds payload and pushes to remote sites
*/
diff --git a/source/packages/plg_webservices_mokosuite/mokosuite.xml b/source/packages/plg_webservices_mokosuite/mokosuite.xml
index a7a7a938..f455a49f 100644
--- a/source/packages/plg_webservices_mokosuite/mokosuite.xml
+++ b/source/packages/plg_webservices_mokosuite/mokosuite.xml
@@ -7,7 +7,7 @@
GPL-3.0-or-later
hello@mokoconsulting.tech
https://mokoconsulting.tech
- 02.34.50-dev
+ 02.34.55-dev
Joomla Web Services API routes for MokoSuite site management — health checks, cache, updates, backups, and site info.
Moko\Plugin\WebServices\MokoSuite
diff --git a/source/pkg_mokosuite.xml b/source/pkg_mokosuite.xml
index cc8d0d6c..c7c4f16e 100644
--- a/source/pkg_mokosuite.xml
+++ b/source/pkg_mokosuite.xml
@@ -2,7 +2,7 @@
Package - MokoSuite
mokosuite
- 02.34.50-dev
+ 02.34.55-dev
2026-06-02
Moko Consulting
hello@mokoconsulting.tech
@@ -20,6 +20,7 @@
plg_system_mokosuite_tenant.zip
plg_system_mokosuite_devtools.zip
plg_system_mokosuite_offline.zip
+ plg_system_mokosuite_dbip.zip
com_mokosuite.zip
mod_mokosuite_cpanel.zip