Compare commits

...

6 Commits

Author SHA1 Message Date
gitea-actions[bot] d2ba5d7123 chore(version): pre-release bump to 02.34.53-dev [skip ci] 2026-06-08 09:29:21 +00:00
Jonathan Miller f52df1912d ci: add rc-revert workflow for release candidate rollbacks
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 12s
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 22s
Universal: PR Check / Validate PR (pull_request) Failing after 33s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 39s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 42s
2026-06-08 04:26:57 -05:00
Jonathan Miller 4e797a5f74 feat(dbip): add IP geolocation plugin using DB-IP
New system plugin plg_system_mokosuite_dbip provides IP geolocation
via DB-IP MMDB databases. Supports CDN auto-download of city DB,
local MMDB file mode, and bundled MaxMind DB reader library.
Registered in package manifest.
2026-06-08 04:26:56 -05:00
Jonathan Miller 6aee7353b9 feat(menu): restructure sidebar — each component gets own section
Each installed Moko component now renders as its own top-level
collapsible section instead of being nested under a single MokoSuite
parent. com_mokosuitehq is pinned first, com_mokosuite uses static
views as children, all others auto-discover from #__menu.
2026-06-08 04:26:55 -05:00
gitea-actions[bot] 82c3e96759 chore(version): pre-release bump to 02.34.52-dev [skip ci] 2026-06-07 18:04:48 +00:00
gitea-actions[bot] 6f84af130d chore(version): pre-release bump to 02.34.51-dev [skip ci] 2026-06-07 17:39:01 +00:00
57 changed files with 1731 additions and 136 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
<display-name>Package - MokoSuite</display-name>
<org>MokoConsulting</org>
<description>White-label identity, security hardening, and tenant restriction layer for Suite-managed Joomla environments</description>
<version>02.34.50</version>
<version>02.34.53</version>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity>
<governance>
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation
# VERSION: 02.34.50
# VERSION: 02.34.53
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
+66
View File
@@ -0,0 +1,66 @@
# 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
@@ -14,7 +14,7 @@
INGROUP: MokoSuite.Documentation
REPO: https://github.com/mokoconsulting-tech/mokosuite
PATH: ./CHANGELOG.md
VERSION: 02.34.50
VERSION: 02.34.53
BRIEF: Version history using `Keep a Changelog`
-->
+1 -1
View File
@@ -14,7 +14,7 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoSuite.Documentation
REPO: https://github.com/mokoconsulting-tech/mokosuite
VERSION: 02.34.50
VERSION: 02.34.53
PATH: ./CODE_OF_CONDUCT.md
BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default
-->
+1 -1
View File
@@ -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.53
PATH: /GOVERNANCE.md
BRIEF: Project governance rules, roles, and decision process for MokoSuiteBrand
-->
+1 -1
View File
@@ -15,7 +15,7 @@
INGROUP: MokoSuite.Documentation
REPO: https://github.com/mokoconsulting-tech/mokosuite
PATH: ./LICENSE.md
VERSION: 02.34.50
VERSION: 02.34.53
BRIEF: Project license (GPL-3.0-or-later)
-->
GNU GENERAL PUBLIC LICENSE
+1 -1
View File
@@ -9,7 +9,7 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoSuite
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuite
VERSION: 02.34.50
VERSION: 02.34.53
PATH: /README.md
BRIEF: MokoSuite platform plugin for Joomla
-->
+1 -1
View File
@@ -23,7 +23,7 @@ DEFGROUP: [PROJECT_NAME]
INGROUP: [PROJECT_NAME].Documentation
REPO: [REPOSITORY_URL]
PATH: /SECURITY.md
VERSION: 02.34.50
VERSION: 02.34.53
BRIEF: Security vulnerability reporting and handling policy
-->
+2 -2
View File
@@ -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.53
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.53)
## 1. Purpose
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoSuite.Guides
REPO: https://github.com/mokoconsulting-tech/mokosuite
VERSION: 02.34.50
VERSION: 02.34.53
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.53)
## 1. Objective
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoSuite.Guides
REPO: https://github.com/mokoconsulting-tech/mokosuite
VERSION: 02.34.50
VERSION: 02.34.53
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.53)
## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoSuite.Guides
REPO: https://github.com/mokoconsulting-tech/mokosuite
VERSION: 02.34.50
VERSION: 02.34.53
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.53)
## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoSuite.Guides
REPO: https://github.com/mokoconsulting-tech/mokosuite
VERSION: 02.34.50
VERSION: 02.34.53
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.53)
## Introduction
+2 -2
View File
@@ -7,13 +7,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoSuite.Guides
REPO: https://github.com/mokoconsulting-tech/mokosuite
VERSION: 02.34.50
VERSION: 02.34.53
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.53)
## 1. Prerequisites
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoSuite.Guides
REPO: https://github.com/mokoconsulting-tech/mokosuite
VERSION: 02.34.50
VERSION: 02.34.53
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.53)
## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoSuite.Guides
REPO: https://github.com/mokoconsulting-tech/mokosuite
VERSION: 02.34.50
VERSION: 02.34.53
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.53)
## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoSuite.Documentation
REPO: https://github.com/mokoconsulting-tech/mokosuite
VERSION: 02.34.50
VERSION: 02.34.53
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.53)
## Introduction
+2 -2
View File
@@ -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.53
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.53)
## Introduction
+1 -1
View File
@@ -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.53
BRIEF: How this extension's Joomla update server file (update.xml) is managed
-->
+1 -1
View File
@@ -20,7 +20,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.50-dev</version>
<version>02.34.53-dev</version>
<description>MokoSuite admin dashboard and REST API. Provides a control panel for managing MokoSuite feature plugins, site health monitoring, and remote management endpoints.</description>
<namespace path="src">Moko\Component\MokoSuite</namespace>
@@ -7,7 +7,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.50-dev</version>
<version>02.34.53-dev</version>
<description>MOD_MOKOSUITE_CACHE_DESC</description>
<namespace path="src">Moko\Module\MokoSuiteCache</namespace>
@@ -7,7 +7,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.50-dev</version>
<version>02.34.53-dev</version>
<description>MOD_MOKOSUITE_CATEGORIES_DESC</description>
<namespace path="src">Moko\Module\MokoSuiteCategories</namespace>
@@ -7,7 +7,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.50-dev</version>
<version>02.34.53-dev</version>
<description>MOD_MOKOSUITE_CPANEL_DESC</description>
<namespace path="src">Moko\Module\MokoSuiteCpanel</namespace>
@@ -7,7 +7,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.50-dev</version>
<version>02.34.53-dev</version>
<description>MokoSuite admin sidebar menu — renders a dedicated MokoSuite section in the admin menu before Joomla's default menu.</description>
<namespace path="src">Moko\Module\MokoSuiteMenu</namespace>
@@ -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;
}
?>
<style>
.sidebar-wrapper .item-level-1 > a { padding-inline-start: 1.5rem; }
.sidebar-wrapper .mokosuite-menu-item > a { padding-inline-start: 2rem; }
.sidebar-wrapper .mokosuite-menu-child > a { padding-inline-start: 2.5rem; }
.sidebar-wrapper .mokosuite-ext-item > a { padding-inline-start: 1.5rem; }
.sidebar-wrapper .mokosuite-ext-child > a { padding-inline-start: 2.5rem; }
</style>
<ul class="nav flex-column main-nav">
<li class="<?php echo $topClass; ?>">
<a class="has-arrow" href="#" aria-label="MokoSuite">
<span class="icon-shield-alt" aria-hidden="true"></span>
<span class="sidebar-item-title">MokoSuite</span>
<?php foreach ($sorted as $comp): ?>
<?php
$compParsed = [];
parse_str(parse_url($comp['link'], PHP_URL_QUERY) ?? '', $compParsed);
$compOption = $compParsed['option'] ?? '';
$compActive = ($compOption === $currentOption);
// For com_mokosuite static children, also check the plugins filter link
if (!$compActive && $comp['element'] === 'com_mokosuite' && $currentOption === 'com_plugins')
{
$compActive = true;
}
$hasChildren = !empty($comp['children']);
$liClass = 'item mokosuite-ext-item' . ($hasChildren ? ' parent item-level-1' : '') . ($compActive ? ' mm-active' : '');
$aClass = ($hasChildren ? 'has-arrow' : 'no-dropdown') . ($compActive ? ' mm-active' : '');
$childCollapse = 'collapse-level-1 mm-collapse' . ($compActive ? ' mm-show' : '');
?>
<li class="<?php echo $liClass; ?>">
<a class="<?php echo $aClass; ?>" 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="sidebar-item-title"><?php echo $comp['title']; ?></span>
</a>
<ul class="<?php echo $topCollapse; ?>" style="padding-inline-start:0.5rem;">
<?php // ── MokoSuite static items ── ?>
<?php foreach ($mokosuiteItems as $item): ?>
<?php if ($hasChildren): ?>
<ul class="<?php echo $childCollapse; ?>" style="padding-inline-start:0.5rem;">
<?php foreach ($comp['children'] as $child): ?>
<?php
$active = false;
$parsed = [];
parse_str(parse_url($item['link'], PHP_URL_QUERY) ?? '', $parsed);
if (($parsed['option'] ?? '') === $currentOption)
$childParsed = [];
parse_str(parse_url($child['link'], PHP_URL_QUERY) ?? '', $childParsed);
$childOption = $childParsed['option'] ?? '';
$childView = $childParsed['view'] ?? '';
$childActive = false;
if ($childOption === $currentOption)
{
$active = empty($parsed['view'])
$childActive = empty($childView)
? ($currentView === '' || $currentView === 'dashboard')
: ($currentView === ($parsed['view'] ?? ''));
: ($currentView === $childView);
}
$liClass = 'item mokosuite-menu-item' . ($active ? ' mm-active' : '');
$aClass = 'no-dropdown' . ($active ? ' mm-active' : '');
$childLiClass = 'item mokosuite-ext-child' . ($childActive ? ' mm-active' : '');
$childAClass = 'no-dropdown' . ($childActive ? ' 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>
<li class="<?php echo $childLiClass; ?>">
<a class="<?php echo $childAClass; ?>" href="<?php echo Route::_($child['link']); ?>"<?php echo $childActive ? ' aria-current="page"' : ''; ?>>
<span class="<?php echo $child['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 $child['title']; ?></span>
</a>
</li>
<?php endforeach; ?>
<?php // ── Auto-discovered Moko components with submenus ── ?>
<?php foreach ($mokoComponents as $comp): ?>
<?php
$compParsed = [];
parse_str(parse_url($comp['link'], PHP_URL_QUERY) ?? '', $compParsed);
$compActive = ($compParsed['option'] ?? '') === $currentOption;
$hasChildren = !empty($comp['children']);
$compLiClass = 'item mokosuite-menu-item' . ($hasChildren ? ' parent' : '') . ($compActive ? ' mm-active' : '');
$compAClass = ($hasChildren ? 'has-arrow' : 'no-dropdown') . ($compActive ? ' mm-active' : '');
$childCollapse = 'collapse-level-2 mm-collapse' . ($compActive ? ' mm-show' : '');
?>
<li class="<?php echo $compLiClass; ?>">
<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="sidebar-item-title"><?php echo $comp['title']; ?></span>
</a>
<?php if ($hasChildren): ?>
<ul class="<?php echo $childCollapse; ?>" style="padding-inline-start:0.75rem;">
<?php foreach ($comp['children'] as $child): ?>
<?php
$childParsed = [];
parse_str(parse_url($child['link'], PHP_URL_QUERY) ?? '', $childParsed);
$childActive = ($childParsed['option'] ?? '') === $currentOption
&& ($childParsed['view'] ?? '') === $currentView;
$childLiClass = 'item mokosuite-menu-child' . ($childActive ? ' mm-active' : '');
$childAClass = 'no-dropdown' . ($childActive ? ' mm-active' : '');
?>
<li class="<?php echo $childLiClass; ?>">
<a class="<?php echo $childAClass; ?>" href="<?php echo Route::_($child['link']); ?>"<?php echo $childActive ? ' aria-current="page"' : ''; ?>>
<span class="<?php echo $child['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 $child['title']; ?></span>
</a>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
@@ -22,7 +22,7 @@
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoSuite
* REPO: https://github.com/mokoconsulting-tech/mokosuite
* VERSION: 02.34.50
* VERSION: 02.34.53
* PATH: /src/Extension/MokoSuite.php
* NOTE: Core system plugin for MokoSuite admin tools suite
*/
@@ -8,7 +8,7 @@
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoSuite
* VERSION: 02.34.50
* VERSION: 02.34.53
* PATH: /src/Field/CopyableTokenField.php
* 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>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.50-dev</version>
<version>02.34.53-dev</version>
<description>MokoSuite core system plugin — coordinates feature plugins, heartbeat, health checks, and admin customizations.</description>
<namespace path=".">Moko\Plugin\System\MokoSuite</namespace>
<scriptfile>script.php</scriptfile>
@@ -22,7 +22,7 @@
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoSuite
* REPO: https://github.com/mokoconsulting-tech/mokosuite
* VERSION: 02.34.50
* VERSION: 02.34.53
* PATH: /src/script.php
* BRIEF: Installation script for MokoSuite plugin
* NOTE: Handles installation, update, and uninstallation tasks including language override deployment
@@ -22,7 +22,7 @@
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoSuite
* REPO: https://github.com/mokoconsulting-tech/mokosuite
* VERSION: 02.34.50
* VERSION: 02.34.53
* PATH: /src/services/provider.php
* BRIEF: Service provider for dependency injection in Joomla 5.x
* NOTE: Registers the plugin with Joomla's DI container
@@ -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)."
@@ -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."
@@ -0,0 +1,404 @@
<?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);
}
}
@@ -0,0 +1,452 @@
<?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);
}
}
@@ -0,0 +1,11 @@
<?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 {}
@@ -0,0 +1,123 @@
<?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;
}
}
@@ -0,0 +1,33 @@
<?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'
);
}
}
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuite DB-IP</name>
<element>mokosuite_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.53-dev</version>
<description>PLG_SYSTEM_MOKOSUITE_DBIP_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoSuiteDBIP</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_mokosuite_dbip.ini</language>
<language tag="en-GB">en-GB/plg_system_mokosuite_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>
@@ -0,0 +1,33 @@
<?php
/**
* @package Moko.Plugin.System.MokoSuiteDBIP
* @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\MokoSuiteDBIP\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', 'mokosuite_dbip'));
$plugin->setApplication(Factory::getApplication());
return $plugin;
}
);
}
};
@@ -0,0 +1,83 @@
<?php
/**
* @package Moko.Plugin.System.MokoSuiteDBIP
* @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\MokoSuiteDBIP\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Event\SubscriberInterface;
use Moko\Plugin\System\MokoSuiteDBIP\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);
}
}
@@ -0,0 +1,269 @@
<?php
/**
* @package Moko.Plugin.System.MokoSuiteDBIP
* @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\MokoSuiteDBIP\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/mokosuite_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/mokosuite_dbip/dbip-city-lite.mmdb';
}
/**
* Load the MaxMind DB Reader library.
*/
private static function loadLib(): void
{
if (self::$libLoaded)
{
return;
}
$libPath = JPATH_PLUGINS . '/system/mokosuite_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/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;
}
}
}
@@ -8,7 +8,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.50-dev</version>
<version>02.34.53-dev</version>
<description>PLG_SYSTEM_MOKOSUITE_DEVTOOLS_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoSuiteDevTools</namespace>
@@ -8,7 +8,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.50-dev</version>
<version>02.34.53-dev</version>
<description>PLG_SYSTEM_MOKOSUITE_FIREWALL_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoSuiteFirewall</namespace>
@@ -8,7 +8,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.50-dev</version>
<version>02.34.53-dev</version>
<description>PLG_SYSTEM_MOKOSUITE_LICENSE_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoSuiteLicense</namespace>
<files><folder>src</folder><folder>services</folder><folder>language</folder></files>
@@ -8,7 +8,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.50-dev</version>
<version>02.34.53-dev</version>
<description>PLG_SYSTEM_MOKOSUITE_MONITOR_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoSuiteMonitor</namespace>
@@ -8,7 +8,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.50-dev</version>
<version>02.34.53-dev</version>
<description>PLG_SYSTEM_MOKOSUITE_OFFLINE_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoSuiteOffline</namespace>
@@ -8,7 +8,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.50-dev</version>
<version>02.34.53-dev</version>
<description>PLG_SYSTEM_MOKOSUITE_TENANT_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoSuiteTenant</namespace>
@@ -8,7 +8,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.50-dev</version>
<version>02.34.53-dev</version>
<description>Runs scheduled helpdesk automation rules — auto-close resolved tickets, SLA breach escalation, and time-based actions.</description>
<namespace path="src">Moko\Plugin\Task\MokoSuiteTickets</namespace>
@@ -12,7 +12,7 @@
<license>GNU General Public License version 3 or later; see LICENSE</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.50-dev</version>
<version>02.34.53-dev</version>
<description>PLG_TASK_MOKOSUITEDEMO_DESC</description>
<namespace path="src">Moko\Plugin\Task\MokoSuiteDemo</namespace>
@@ -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.53
* BRIEF: Content-only snapshot/restore for demo site reset
*/
@@ -12,7 +12,7 @@
<license>GNU General Public License version 3 or later; see LICENSE</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.50-dev</version>
<version>02.34.53-dev</version>
<description>PLG_TASK_MOKOSUITESYNC_DESC</description>
<namespace path="src">Moko\Plugin\Task\MokoSuiteSync</namespace>
@@ -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.53
* BRIEF: Receiver-side content sync — applies incoming payload to local DB
*/
@@ -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.53
* BRIEF: Sender-side content sync — builds payload and pushes to remote sites
*/
@@ -7,7 +7,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.50-dev</version>
<version>02.34.53-dev</version>
<description>Joomla Web Services API routes for MokoSuite site management — health checks, cache, updates, backups, and site info.</description>
<namespace path="src">Moko\Plugin\WebServices\MokoSuite</namespace>
<files>
+2 -1
View File
@@ -2,7 +2,7 @@
<extension type="package" method="upgrade">
<name>Package - MokoSuite</name>
<packagename>mokosuite</packagename>
<version>02.34.50-dev</version>
<version>02.34.53-dev</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -20,6 +20,7 @@
<file type="plugin" id="plg_system_mokosuite_tenant" group="system">plg_system_mokosuite_tenant.zip</file>
<file type="plugin" id="plg_system_mokosuite_devtools" group="system">plg_system_mokosuite_devtools.zip</file>
<file type="plugin" id="plg_system_mokosuite_offline" group="system">plg_system_mokosuite_offline.zip</file>
<file type="plugin" id="plg_system_mokosuite_dbip" group="system">plg_system_mokosuite_dbip.zip</file>
<file type="component" id="com_mokosuite">com_mokosuite.zip</file>
<file type="module" id="mod_mokosuite_cpanel" client="administrator">mod_mokosuite_cpanel.zip</file>
<file type="module" id="mod_mokosuite_menu" client="administrator">mod_mokosuite_menu.zip</file>