diff --git a/.mokogitea/workflows/auto-bump.yml b/.mokogitea/workflows/auto-bump.yml index cb078c61..022148e0 100644 --- a/.mokogitea/workflows/auto-bump.yml +++ b/.mokogitea/workflows/auto-bump.yml @@ -1,66 +1,66 @@ -# Copyright (C) 2026 Moko Consulting -# -# SPDX-License-Identifier: GPL-3.0-or-later -# -# FILE INFORMATION -# DEFGROUP: Gitea.Workflow -# INGROUP: mokocli.Release -# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli -# PATH: /.mokogitea/workflows/auto-bump.yml -# VERSION: 09.02.00 -# BRIEF: Auto patch-bump version on every push to dev (skips merge commits) - -name: "Universal: Auto Version Bump" - -on: - push: - branches: - - dev - - rc - - 'feature/**' - - 'patch/**' - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} - -permissions: - contents: write - -jobs: - bump: - name: Version Bump - runs-on: release - if: >- - !contains(github.event.head_commit.message, '[skip ci]') && - !contains(github.event.head_commit.message, '[skip bump]') && - !startsWith(github.event.head_commit.message, 'Merge pull request') - - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - token: ${{ secrets.MOKOGITEA_TOKEN }} - fetch-depth: 1 - - - name: Setup mokocli tools - run: | - if ! command -v composer &> /dev/null; then - sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1 - fi - if [ -d "/opt/mokocli/cli" ]; then - echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV" - else - git clone --depth 1 --branch main --quiet \ - "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \ - /tmp/mokocli - cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet - echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV" - fi - - - name: Bump version - run: | - php ${MOKO_CLI}/version_auto_bump.php \ - --path . --branch "${GITHUB_REF_NAME}" \ - --token "${{ secrets.MOKOGITEA_TOKEN }}" \ - --repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: mokoplatform.Release +# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform +# PATH: /.mokogitea/workflows/auto-bump.yml +# VERSION: 09.02.00 +# BRIEF: Auto patch-bump version on every push to dev (skips merge commits) + +name: "Universal: Auto Version Bump" + +on: + push: + branches: + - dev + - rc + - 'feature/**' + - 'patch/**' + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + +permissions: + contents: write + +jobs: + bump: + name: Version Bump + runs-on: release + if: >- + !contains(github.event.head_commit.message, '[skip ci]') && + !contains(github.event.head_commit.message, '[skip bump]') && + !startsWith(github.event.head_commit.message, 'Merge pull request') + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + token: ${{ secrets.MOKOGITEA_TOKEN }} + fetch-depth: 1 + + - name: Setup mokoplatform tools + run: | + if ! command -v composer &> /dev/null; then + sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1 + fi + if [ -d "/opt/mokoplatform/cli" ]; then + echo "MOKO_CLI=/opt/mokoplatform/cli" >> "$GITHUB_ENV" + else + git clone --depth 1 --branch main --quiet \ + "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokoplatform.git" \ + /tmp/mokoplatform-api + cd /tmp/mokoplatform-api && composer install --no-dev --no-interaction --quiet + echo "MOKO_CLI=/tmp/mokoplatform-api/cli" >> "$GITHUB_ENV" + fi + + - name: Bump version + run: | + php ${MOKO_CLI}/version_auto_bump.php \ + --path . --branch "${GITHUB_REF_NAME}" \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" \ + --repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" diff --git a/.mokogitea/workflows/auto-release.yml b/.mokogitea/workflows/auto-release.yml index 29ce950d..622626fb 100644 --- a/.mokogitea/workflows/auto-release.yml +++ b/.mokogitea/workflows/auto-release.yml @@ -112,19 +112,16 @@ jobs: - name: Update RC release notes from CHANGELOG.md run: | API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" - # Extract [Unreleased] section from changelog - NOTES="" if [ -f "CHANGELOG.md" ]; then NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md) + [ -z "$NOTES" ] && NOTES="Release candidate" + else + NOTES="Release candidate" fi - [ -z "$NOTES" ] && NOTES="Release candidate" - # Find the RC release and update its body - RELEASE_ID=$(curl -sf -H "Authorization: token ${TOKEN}" \ - "${API_BASE}/releases/tags/release-candidate" \ - | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) + RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \ + "${API_BASE}/releases/tags/release-candidate" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) if [ -n "$RELEASE_ID" ]; then python3 -c " @@ -135,7 +132,7 @@ jobs: '${API_BASE}/releases/${RELEASE_ID}', data=payload, method='PATCH', headers={ - 'Authorization': 'token ${TOKEN}', + 'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}', 'Content-Type': 'application/json' }) urllib.request.urlopen(req) diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index fc6d4a94..796243b0 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.35.00 +# VERSION: 02.41.00 # 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 1200af71..c442a998 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,15 +14,35 @@ INGROUP: MokoSuiteClient.Documentation REPO: https://github.com/mokoconsulting-tech/mokosuiteclient PATH: ./CHANGELOG.md - VERSION: 02.35.00 + VERSION: 02.41.00 BRIEF: Version history using `Keep a Changelog` --> # Changelog ## [Unreleased] +## [02.41.00] --- 2026-06-20 -## [02.35.00] --- 2026-06-19 +### Fixed +- Ticket automation and offline bypass plugins not enabling on install/update — `enablePlugin()` now handles empty element columns with a fallback match by manifest name +- Heartbeat silently failing — `sendHeartbeat()` was reading config from the retired monitor plugin; now reads from core plugin params +- Cpanel module not publishing on update — `ensureAdminModule()` now does a direct DB update for existing modules (bypasses ModuleModel checked_out issues) +- Cpanel module access level changed from 6 (may not exist) to 3 (Special) +- Admin menu module ordering set to -1 to ensure it appears at the top of the menu position + +### Changed +- Retired monitor plugin config (base_url, signing_key, heartbeat_enabled) consolidated into core plugin params with one-time migration +- Runtime heartbeat moved from retired `plg_system_mokosuiteclient_monitor` into core plugin (`checkHeartbeat` on admin page load after version change) +- `DisplayController::sendHeartbeat()` reads from core plugin instead of retired monitor plugin +- Removed monitor plugin from cpanel dashboard plugin grid + +### Added +- Missing language strings for IMAP poll and auto-close ticket automation task routines +- Monitor fieldset in core plugin XML with heartbeat_enabled, monitor_base_url, and monitor_signing_key fields +- Language strings for monitor fieldset (PLG_SYSTEM_MOKOSUITECLIENT_FIELDSET_MONITOR_*) + + +## [02.35.00] --- 2026-06-18 ### Changed - **Full rename: MokoSuite → MokoSuiteClient** — repo, all Joomla element names (com_mokosuiteclient, plg_system_mokosuiteclient, mod_mokosuiteclient_*, etc.), PHP classes, language files, folder structure, and manifest references. This is the client tracker for the MokoSuite platform. @@ -144,37 +164,3 @@ ### Removed - License key validation (licensing system not ready — will return in future release) - Dynamic MokoGitea update feed dependency (replaced with static updates.xml) - -## [02.31] - 2026-06-01 - -### Added -- License key support via Joomla's native Update Sites download key system (dlid) -- Update server URL migrated from static XML to MokoGitea's dynamic update feed endpoint -- Legacy static update site URLs auto-migrated to dynamic endpoint on install/update -- Persistent admin warning when no license key is configured in Update Sites -- Daily heartbeat validation of license key against MokoGitea — warns if key is invalid or expired -- Stale/duplicate update site cleanup on install/update (removes old static URL entries and orphaned records) -- Content sync rewritten — bulk MokoSuiteClient API endpoints (syncclear + syncpush) replace per-item Joomla API calls -- Sync task per-instance config: target URL, health token, content type checkboxes (articles, categories, menus, modules) -- Bulk sync completes in under 5 seconds (clear + push in 2-3 HTTP requests) -- Asset table and nested set tree repair after sync push on target site -- Enhanced dev mode: disables caching, enables Joomla + MokoOnyx debug, suppresses hit recording, shows offline on primary domain -- Dev mode off: clears content versions, resets hits, disables debug, takes site online -- Hardcoded dev alias (dev.{primary_domain}) with noindex/nofollow — bypasses offline mode for development -- Primary domain auto-detected on first config save - -### Changed -- Branding, master user, support URL, and admin colors are now hardcoded (no longer configurable) -- Master user enforcement is always active (toggle removed) -- Diagnostics + maintenance merged into default config tab -- Emergency access moved to Security tab -- Content sync configuration moved from system plugin to individual scheduled task instances - -### Removed -- Static `updates.xml` — update feed is now generated dynamically by MokoGitea from git releases -- Basic branding config tab (brand name, company name, support URL) -- Visual branding config tab (colors, icon, custom CSS) -- Suite Access config tab (master user toggle, master email) -- Content Sync config tab (targets now in scheduled tasks) -- Site Aliases config tab (hardcoded to dev.{primary_domain}) -- File sync (images/, files/, media/) — sync is API/DB content only diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 3d7921ea..ff55c996 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -14,7 +14,7 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuiteClient.Documentation REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - VERSION: 02.35.00 + VERSION: 02.41.00 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 49cac71d..b732f4d8 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -19,7 +19,7 @@ DEFGROUP: mokoconsulting-tech.MokoSuiteClientBrand INGROUP: MokoStandards.Governance REPO: https://github.com/mokoconsulting-tech/MokoSuiteClientBrand - VERSION: 02.35.00 + VERSION: 02.41.00 PATH: /GOVERNANCE.md BRIEF: Project governance rules, roles, and decision process for MokoSuiteClientBrand --> diff --git a/LICENSE.md b/LICENSE.md index 67e55be0..95d17241 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -15,7 +15,7 @@ INGROUP: MokoSuiteClient.Documentation REPO: https://github.com/mokoconsulting-tech/mokosuiteclient PATH: ./LICENSE.md - VERSION: 02.35.00 + VERSION: 02.41.00 BRIEF: Project license (GPL-3.0-or-later) --> GNU GENERAL PUBLIC LICENSE diff --git a/Makefile b/Makefile index ec94cc90..61c63c36 100644 --- a/Makefile +++ b/Makefile @@ -12,10 +12,10 @@ # ============================================================================== # Extension Configuration -EXTENSION_NAME := mokoexample -EXTENSION_TYPE := module +EXTENSION_NAME := mokosuiteclient +EXTENSION_TYPE := package # Options: module, plugin, component, package, template -EXTENSION_VERSION := 1.0.0 +EXTENSION_VERSION := 02.35.00 # Module Configuration (for modules only) MODULE_TYPE := site diff --git a/README.md b/README.md index 4b2b3c77..8fd66050 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuiteClient REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient - VERSION: 02.35.00 + VERSION: 02.41.00 PATH: /README.md BRIEF: MokoSuiteClient platform plugin for Joomla --> diff --git a/SECURITY.md b/SECURITY.md index 06d302ac..77675766 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.35.00 +VERSION: 02.41.00 BRIEF: Security vulnerability reporting and handling policy --> diff --git a/docs/guides/build-guide.md b/docs/guides/build-guide.md index 2c7cb755..af67025b 100644 --- a/docs/guides/build-guide.md +++ b/docs/guides/build-guide.md @@ -11,13 +11,13 @@ INGROUP: MokoSuiteClient.Build REPO: https://github.com/mokoconsulting-tech/mokosuiteclient FILE: build-guide.md - VERSION: 02.35.00 + VERSION: 02.41.00 PATH: /docs/guides/ BRIEF: Build and packaging guide for the MokoSuiteClient system plugin NOTE: Defines environment setup, repository layout, packaging rules, and release preparation --> -# MokoSuiteClient Build Guide (VERSION: 02.35.00) +# MokoSuiteClient Build Guide (VERSION: 02.41.00) ## 1. Purpose diff --git a/docs/guides/configuration-guide.md b/docs/guides/configuration-guide.md index 8ad32852..1174e913 100644 --- a/docs/guides/configuration-guide.md +++ b/docs/guides/configuration-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuiteClient.Guides REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - VERSION: 02.35.00 + VERSION: 02.41.00 PATH: /docs/guides/configuration-guide.md BRIEF: Configuration guide for the MokoSuiteClient system plugin NOTE: Defines plugin parameters, expected behaviors, and recommended defaults --> -# MokoSuiteClient Configuration Guide (VERSION: 02.35.00) +# MokoSuiteClient Configuration Guide (VERSION: 02.41.00) ## 1. Objective diff --git a/docs/guides/installation-guide.md b/docs/guides/installation-guide.md index e23cb44f..2ea2e616 100644 --- a/docs/guides/installation-guide.md +++ b/docs/guides/installation-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuiteClient.Guides REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - VERSION: 02.35.00 + VERSION: 02.41.00 PATH: /docs/guides/installation-guide.md BRIEF: Installation guide for the MokoSuiteClient system plugin NOTE: First document in the guide set --> -# MokoSuiteClient Installation Guide (VERSION: 02.35.00) +# MokoSuiteClient Installation Guide (VERSION: 02.41.00) ## Introduction diff --git a/docs/guides/operations-guide.md b/docs/guides/operations-guide.md index ee6046d3..8b700a0a 100644 --- a/docs/guides/operations-guide.md +++ b/docs/guides/operations-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuiteClient.Guides REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - VERSION: 02.35.00 + VERSION: 02.41.00 PATH: /docs/guides/operations-guide.md BRIEF: Operational guide for administering and managing the MokoSuiteClient system plugin NOTE: Defines lifecycle, responsibilities, and operational behaviors --> -# MokoSuiteClient Operations Guide (VERSION: 02.35.00) +# MokoSuiteClient Operations Guide (VERSION: 02.41.00) ## Introduction diff --git a/docs/guides/rollback-and-recovery-guide.md b/docs/guides/rollback-and-recovery-guide.md index 03153f86..2259db6a 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: MokoSuiteClient.Guides REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - VERSION: 02.35.00 + VERSION: 02.41.00 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 --> -# MokoSuiteClient Rollback and Recovery Guide (VERSION: 02.35.00) +# MokoSuiteClient Rollback and Recovery Guide (VERSION: 02.41.00) ## Introduction diff --git a/docs/guides/testing-guide.md b/docs/guides/testing-guide.md index c5b8dc89..2fb0b909 100644 --- a/docs/guides/testing-guide.md +++ b/docs/guides/testing-guide.md @@ -7,13 +7,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuiteClient.Guides REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - VERSION: 02.35.00 + VERSION: 02.41.00 PATH: /docs/guides/testing-guide.md BRIEF: Testing guide for MokoSuiteClient v02.01.08 NOTE: Covers manual test procedures for language overrides, install/uninstall, and configuration --> -# MokoSuiteClient Testing Guide (VERSION: 02.35.00) +# MokoSuiteClient Testing Guide (VERSION: 02.41.00) ## 1. Prerequisites diff --git a/docs/guides/troubleshooting-guide.md b/docs/guides/troubleshooting-guide.md index 94e3e9e5..454fb831 100644 --- a/docs/guides/troubleshooting-guide.md +++ b/docs/guides/troubleshooting-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuiteClient.Guides REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - VERSION: 02.35.00 + VERSION: 02.41.00 PATH: /docs/guides/troubleshooting-guide.md BRIEF: Troubleshooting guide for diagnosing and resolving issues related to the MokoSuiteClient plugin NOTE: Designed for administrators and Suite operations teams --> -# MokoSuiteClient Troubleshooting Guide (VERSION: 02.35.00) +# MokoSuiteClient Troubleshooting Guide (VERSION: 02.41.00) ## Introduction diff --git a/docs/guides/upgrade-and-versioning-guide.md b/docs/guides/upgrade-and-versioning-guide.md index 3ee70545..65981d00 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: MokoSuiteClient.Guides REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - VERSION: 02.35.00 + VERSION: 02.41.00 PATH: /docs/guides/upgrade-and-versioning-guide.md BRIEF: Guide for updating, versioning, and maintaining the MokoSuiteClient plugin NOTE: Defines release flow, version rules, and upgrade validation --> -# MokoSuiteClient Upgrade and Versioning Guide (VERSION: 02.35.00) +# MokoSuiteClient Upgrade and Versioning Guide (VERSION: 02.41.00) ## Introduction diff --git a/docs/index.md b/docs/index.md index 766922a9..f52dd4cb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuiteClient.Documentation REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - VERSION: 02.35.00 + VERSION: 02.41.00 PATH: /docs/index.md BRIEF: Master index of all documentation for the MokoSuiteClient plugin NOTE: Automatically maintained index for all guide canvases --> -# MokoSuiteClient Documentation Index (VERSION: 02.35.00) +# MokoSuiteClient Documentation Index (VERSION: 02.41.00) ## Introduction diff --git a/docs/plugin-basic.md b/docs/plugin-basic.md index 765ffe1e..715d05d6 100644 --- a/docs/plugin-basic.md +++ b/docs/plugin-basic.md @@ -11,12 +11,12 @@ INGROUP: MokoSuiteClient REPO: https://github.com/mokoconsulting-tech/mokosuiteclient PATH: /docs/plugin-basic.md - VERSION: 02.35.00 + VERSION: 02.41.00 BRIEF: Baseline documentation for the MokoSuiteClient system plugin NOTE: Foundational reference for internal and external stakeholders --> -# MokoSuiteClient Plugin Overview (VERSION: 02.35.00) +# MokoSuiteClient Plugin Overview (VERSION: 02.41.00) ## Introduction diff --git a/docs/update-server.md b/docs/update-server.md index 002ec690..d36fe3d7 100644 --- a/docs/update-server.md +++ b/docs/update-server.md @@ -10,7 +10,7 @@ DEFGROUP: MokoSuiteClient.Documentation INGROUP: MokoStandards.Templates REPO: https://github.com/mokoconsulting-tech/MokoSuiteClient PATH: /docs/update-server.md -VERSION: 02.35.00 +VERSION: 02.41.00 BRIEF: How this extension's Joomla update server file (update.xml) is managed --> diff --git a/source/packages/com_mokosuiteclient/admin/catalog.xml b/source/packages/com_mokosuiteclient/admin/catalog.xml index 8b7289d6..d18e918a 100644 --- a/source/packages/com_mokosuiteclient/admin/catalog.xml +++ b/source/packages/com_mokosuiteclient/admin/catalog.xml @@ -50,14 +50,14 @@ https://git.mokoconsulting.tech/MokoConsulting/MokoJoomOpenGraph/raw/branch/dev/updates.xml - MokoJoomBackup + MokoSuiteClientBackup pkg_mokojoombackup package - Automated backup system with Borg integration, scheduled tasks, and remote storage. + Full-site backup and restore for Joomla — database, files, and configuration. icon-archive Tools -
https://mokoconsulting.tech/support/products/mokojoombackup
- https://git.mokoconsulting.tech/MokoConsulting/MokoJoomBackup/raw/branch/dev/updates.xml +
https://mokoconsulting.tech/support/products/mokosuiteclientbackup
+ https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClientBackup/raw/branch/dev/updates.xml
MokoJoomHero diff --git a/source/packages/com_mokosuiteclient/admin/config.xml b/source/packages/com_mokosuiteclient/admin/config.xml index 5bdad6ff..82ebee95 100644 --- a/source/packages/com_mokosuiteclient/admin/config.xml +++ b/source/packages/com_mokosuiteclient/admin/config.xml @@ -1,5 +1,16 @@ +
+ + +
+
JYES + + + + + + + + + +
@@ -33,6 +69,44 @@ + + + + + +
+ +
+ + + + + + + + + +
'core.admin', 'categories' => 'mokosuiteclient.tickets', 'canned' => 'mokosuiteclient.tickets', - 'automation' => 'core.admin', - 'database' => 'core.admin', - 'cleanup' => 'mokosuiteclient.cache', + 'automation' => 'core.admin', + 'database' => 'core.admin', + 'cleanup' => 'mokosuiteclient.cache', + 'ticketsettings' => 'core.admin', ]; public function display($cachable = false, $urlparams = []) @@ -89,22 +90,22 @@ class DisplayController extends BaseController try { - $monitorPlugin = \Joomla\CMS\Plugin\PluginHelper::getPlugin('system', 'mokosuiteclient_monitor'); + $corePlugin = \Joomla\CMS\Plugin\PluginHelper::getPlugin('system', 'mokosuiteclient'); - if (!$monitorPlugin) + if (!$corePlugin) { - $this->jsonResponse(['success' => false, 'message' => 'Monitor plugin not enabled.']); + $this->jsonResponse(['success' => false, 'message' => 'Core plugin not enabled.']); return; } - $params = new \Joomla\Registry\Registry($monitorPlugin->params); - $baseUrl = rtrim($params->get('base_url', ''), '/'); + $params = new \Joomla\Registry\Registry($corePlugin->params); + $baseUrl = rtrim($params->get('monitor_base_url', ''), '/'); - // Fall back to manifest XML default if not yet saved in params + // Fall back to manifest XML default if (empty($baseUrl)) { - $manifestFile = JPATH_PLUGINS . '/system/mokosuiteclient_monitor/mokosuiteclient_monitor.xml'; + $manifestFile = JPATH_PLUGINS . '/system/mokosuiteclient/mokosuiteclient.xml'; if (is_file($manifestFile)) { @@ -112,7 +113,7 @@ class DisplayController extends BaseController if ($xml) { - foreach ($xml->xpath('//field[@name="base_url"]') as $field) + foreach ($xml->xpath('//field[@name="monitor_base_url"]') as $field) { $baseUrl = rtrim((string) $field['default'], '/'); break; @@ -123,14 +124,12 @@ class DisplayController extends BaseController if (empty($baseUrl)) { - $this->jsonResponse(['success' => false, 'message' => 'MokoSuiteClientHQ URL not configured in monitor plugin.']); + $this->jsonResponse(['success' => false, 'message' => 'MokoSuiteClientHQ URL not configured.']); return; } - $corePlugin = \Joomla\CMS\Plugin\PluginHelper::getPlugin('system', 'mokosuiteclient'); - $coreParams = new \Joomla\Registry\Registry($corePlugin ? $corePlugin->params : '{}'); - $healthToken = $coreParams->get('health_api_token', ''); + $healthToken = $params->get('health_api_token', ''); if (empty($healthToken)) { @@ -155,12 +154,12 @@ class DisplayController extends BaseController // RSA sign the request $headers = ['Content-Type: application/json']; - $signingKeyB64 = $params->get('signing_key', ''); + $signingKeyB64 = $params->get('monitor_signing_key', ''); - // Fall back to manifest XML default if not yet saved in params + // Fall back to manifest XML default if (empty($signingKeyB64)) { - $manifestFile = JPATH_PLUGINS . '/system/mokosuiteclient_monitor/mokosuiteclient_monitor.xml'; + $manifestFile = JPATH_PLUGINS . '/system/mokosuiteclient/mokosuiteclient.xml'; if (is_file($manifestFile)) { @@ -168,7 +167,7 @@ class DisplayController extends BaseController if ($xml) { - foreach ($xml->xpath('//field[@name="signing_key"]') as $field) + foreach ($xml->xpath('//field[@name="monitor_signing_key"]') as $field) { $signingKeyB64 = (string) $field['default']; break; @@ -365,10 +364,14 @@ class DisplayController extends BaseController $input = Factory::getApplication()->getInput(); $this->jsonResponse($this->getModel('Tickets')->createTicket([ - 'subject' => $input->getString('subject', ''), - 'body' => $input->getRaw('body', ''), - 'priority' => $input->getString('priority', 'normal'), - 'category_id' => $input->getInt('category_id', 0), + 'subject' => $input->getString('subject', ''), + 'body' => $input->getRaw('body', ''), + 'priority' => $input->getString('priority', 'normal'), + 'category_id' => $input->getInt('category_id', 0), + 'contact_id' => $input->getInt('contact_id', 0), + 'assign_users' => $input->get('assign_users', [], 'ARRAY'), + 'assign_groups' => $input->get('assign_groups', [], 'ARRAY'), + 'custom_fields' => $input->get('custom_fields', [], 'ARRAY'), ])); } @@ -405,10 +408,85 @@ class DisplayController extends BaseController $this->jsonResponse($this->getModel('Tickets')->updateStatus( $input->getInt('ticket_id', 0), - $input->getString('status', '') + $input->getInt('status', 0) )); } + // ================================================================== + // Ticket Settings — Status/Priority CRUD + // ================================================================== + + public function saveStatus() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('core.admin')) + { + $this->jsonForbidden(); + return; + } + + $input = Factory::getApplication()->getInput(); + $this->jsonResponse($this->getModel('Tickets')->saveStatus([ + 'id' => $input->getInt('id', 0), + 'title' => $input->getString('title', ''), + 'alias' => $input->getString('alias', ''), + 'color' => $input->getString('color', 'bg-secondary'), + 'is_default' => $input->getInt('is_default', 0), + 'is_closed' => $input->getInt('is_closed', 0), + 'ordering' => $input->getInt('ordering', 0), + ])); + } + + public function deleteStatus() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('core.admin')) + { + $this->jsonForbidden(); + return; + } + + $id = Factory::getApplication()->getInput()->getInt('id', 0); + $this->jsonResponse($this->getModel('Tickets')->deleteStatus($id)); + } + + public function savePriority() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('core.admin')) + { + $this->jsonForbidden(); + return; + } + + $input = Factory::getApplication()->getInput(); + $this->jsonResponse($this->getModel('Tickets')->savePriority([ + 'id' => $input->getInt('id', 0), + 'title' => $input->getString('title', ''), + 'alias' => $input->getString('alias', ''), + 'color' => $input->getString('color', 'bg-secondary'), + 'is_default' => $input->getInt('is_default', 0), + 'ordering' => $input->getInt('ordering', 0), + ])); + } + + public function deletePriority() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('core.admin')) + { + $this->jsonForbidden(); + return; + } + + $id = Factory::getApplication()->getInput()->getInt('id', 0); + $this->jsonResponse($this->getModel('Tickets')->deletePriority($id)); + } + // ================================================================== // KB Search // ================================================================== @@ -420,6 +498,7 @@ class DisplayController extends BaseController if (strlen($query) < 3) { $this->jsonResponse(['results' => []]); + return; } try @@ -447,7 +526,8 @@ class DisplayController extends BaseController } catch (\Throwable $e) { - $this->jsonResponse(['results' => []]); + Log::add('KB search failed: ' . $e->getMessage(), Log::ERROR, 'mokosuiteclient'); + $this->jsonResponse(['results' => [], 'error' => 'Search unavailable']); } } @@ -495,7 +575,7 @@ class DisplayController extends BaseController public function saveCategory() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); - if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); } + if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; } $input = Factory::getApplication()->getInput(); $db = Factory::getDbo(); $id = $input->getInt('id', 0); @@ -520,16 +600,29 @@ class DisplayController extends BaseController public function deleteCategory() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); - if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); } + if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; } $db = Factory::getDbo(); $db->setQuery($db->getQuery(true)->delete('#__mokosuiteclient_ticket_categories')->where('id = ' . Factory::getApplication()->getInput()->getInt('id', 0)))->execute(); $this->jsonResponse(['success' => true, 'message' => 'Category deleted.']); } + public function reorderCategory() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; } + $order = json_decode(Factory::getApplication()->getInput()->getRaw('order', '[]'), true); + if (!is_array($order)) { $this->jsonResponse(['success' => false, 'message' => 'Invalid order']); return; } + $db = Factory::getDbo(); + foreach ($order as $i => $id) { + $db->setQuery('UPDATE ' . $db->quoteName('#__mokosuiteclient_ticket_categories') . ' SET ordering = ' . (int) $i . ' WHERE id = ' . (int) $id)->execute(); + } + $this->jsonResponse(['success' => true, 'message' => 'Order saved.']); + } + public function saveCanned() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); - if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); } + if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; } $input = Factory::getApplication()->getInput(); $db = Factory::getDbo(); $data = (object) [ @@ -547,16 +640,95 @@ class DisplayController extends BaseController public function deleteCanned() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); - if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); } + if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; } $db = Factory::getDbo(); $db->setQuery($db->getQuery(true)->delete('#__mokosuiteclient_ticket_canned')->where('id = ' . Factory::getApplication()->getInput()->getInt('id', 0)))->execute(); $this->jsonResponse(['success' => true, 'message' => 'Canned response deleted.']); } + public function reorderCanned() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; } + $order = json_decode(Factory::getApplication()->getInput()->getRaw('order', '[]'), true); + if (!is_array($order)) { $this->jsonResponse(['success' => false, 'message' => 'Invalid order']); return; } + $db = Factory::getDbo(); + foreach ($order as $i => $id) { + $db->setQuery('UPDATE ' . $db->quoteName('#__mokosuiteclient_ticket_canned') . ' SET ordering = ' . (int) $i . ' WHERE id = ' . (int) $id)->execute(); + } + $this->jsonResponse(['success' => true, 'message' => 'Order saved.']); + } + + public function uploadAttachment() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; } + $input = Factory::getApplication()->getInput(); + $ticketId = $input->getInt('ticket_id', 0); + $replyId = $input->getInt('reply_id', 0) ?: null; + if (!$ticketId) { $this->jsonResponse(['success' => false, 'message' => 'Missing ticket_id']); return; } + $files = $input->files->get('attachments', [], 'raw'); + if (empty($files) || empty($files['name'])) { $this->jsonResponse(['success' => false, 'message' => 'No files uploaded']); return; } + $saved = \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::upload($ticketId, $replyId, $files); + $this->jsonResponse(['success' => true, 'message' => count($saved) . ' file(s) uploaded', 'count' => count($saved)]); + } + + public function downloadAttachment() + { + if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; } + $id = Factory::getApplication()->getInput()->getInt('id', 0); + $db = Factory::getDbo(); + $db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuiteclient_ticket_attachments')->where('id = ' . $id)); + $att = $db->loadObject(); + if (!$att) { throw new \RuntimeException('Attachment not found', 404); } + $path = \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::getAbsolutePath($att); + if (!file_exists($path)) { throw new \RuntimeException('File not found', 404); } + $app = Factory::getApplication(); + $app->setHeader('Content-Type', $att->mimetype ?: 'application/octet-stream'); + $safeName = str_replace(['"', "\r", "\n"], '', $att->filename); + $app->setHeader('Content-Disposition', 'attachment; filename="' . $safeName . '"'); + $app->setHeader('Content-Length', (string) filesize($path)); + $app->sendHeaders(); + readfile($path); + $app->close(); + } + + public function deleteAttachment() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; } + $id = Factory::getApplication()->getInput()->getInt('id', 0); + $ok = \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::delete($id); + $this->jsonResponse(['success' => $ok, 'message' => $ok ? 'Attachment deleted' : 'Not found']); + } + + public function rateTicket() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; } + $input = Factory::getApplication()->getInput(); + $ticketId = $input->getInt('ticket_id', 0); + $rating = $input->getInt('rating', 0); + $feedback = $input->getString('feedback', ''); + if (!$ticketId || $rating < 1 || $rating > 5) { + $this->jsonResponse(['success' => false, 'message' => 'Invalid rating (1-5)']); + return; + } + $db = Factory::getDbo(); + $db->setQuery( + 'UPDATE ' . $db->quoteName('#__mokosuiteclient_tickets') + . ' SET satisfaction_rating = ' . $rating + . ', satisfaction_feedback = ' . $db->quote($feedback) + . ', satisfaction_rated_at = ' . $db->quote(Factory::getDate()->toSql()) + . ' WHERE id = ' . $ticketId + )->execute(); + $this->jsonResponse(['success' => true, 'message' => 'Thank you for your feedback!']); + } + public function saveAutomation() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); - if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); } + if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; } $input = Factory::getApplication()->getInput(); $db = Factory::getDbo(); $data = (object) [ @@ -564,6 +736,7 @@ class DisplayController extends BaseController 'trigger_event' => $input->getString('trigger_event', 'ticket_created'), 'conditions' => $input->getRaw('conditions', '[]'), 'actions' => $input->getRaw('actions', '[]'), + 'behavior' => $input->getString('behavior', 'append'), 'enabled' => 1, 'ordering' => 0, ]; @@ -576,7 +749,7 @@ class DisplayController extends BaseController public function deleteAutomation() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); - if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); } + if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; } $db = Factory::getDbo(); $db->setQuery($db->getQuery(true)->delete('#__mokosuiteclient_ticket_automation')->where('id = ' . Factory::getApplication()->getInput()->getInt('id', 0)))->execute(); $this->jsonResponse(['success' => true, 'message' => 'Rule deleted.']); @@ -585,7 +758,7 @@ class DisplayController extends BaseController public function toggleAutomation() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); - if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); } + if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; } $input = Factory::getApplication()->getInput(); $db = Factory::getDbo(); $db->setQuery($db->getQuery(true)->update('#__mokosuiteclient_ticket_automation') @@ -594,6 +767,19 @@ class DisplayController extends BaseController $this->jsonResponse(['success' => true, 'message' => 'Rule updated.']); } + public function reorderAutomation() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; } + $order = json_decode(Factory::getApplication()->getInput()->getRaw('order', '[]'), true); + if (!is_array($order)) { $this->jsonResponse(['success' => false, 'message' => 'Invalid order']); return; } + $db = Factory::getDbo(); + foreach ($order as $i => $id) { + $db->setQuery('UPDATE ' . $db->quoteName('#__mokosuiteclient_ticket_automation') . ' SET ordering = ' . (int) $i . ' WHERE id = ' . (int) $id)->execute(); + } + $this->jsonResponse(['success' => true, 'message' => 'Order saved.']); + } + // ================================================================== // Settings Import/Export (#132) // ================================================================== diff --git a/source/packages/com_mokosuiteclient/admin/src/Model/TicketsModel.php b/source/packages/com_mokosuiteclient/admin/src/Model/TicketsModel.php index a19019af..1d0c58fb 100644 --- a/source/packages/com_mokosuiteclient/admin/src/Model/TicketsModel.php +++ b/source/packages/com_mokosuiteclient/admin/src/Model/TicketsModel.php @@ -575,6 +575,39 @@ class TicketsModel extends BaseDatabaseModel return $db->loadObjectList() ?: []; } + /** + * Get backend users for assignee selection. + */ + public function getBackendUsers(): array + { + $db = $this->getDatabase(); + $db->setQuery( + $db->getQuery(true) + ->select(['u.id', 'u.name', 'u.username']) + ->from($db->quoteName('#__users', 'u')) + ->where($db->quoteName('u.block') . ' = 0') + ->order($db->quoteName('u.name') . ' ASC') + ); + + return $db->loadObjectList() ?: []; + } + + /** + * Get Joomla user groups for assignee selection. + */ + public function getUserGroups(): array + { + $db = $this->getDatabase(); + $db->setQuery( + $db->getQuery(true) + ->select(['id', 'title']) + ->from($db->quoteName('#__usergroups')) + ->order($db->quoteName('title') . ' ASC') + ); + + return $db->loadObjectList() ?: []; + } + /** * Get Joomla custom field groups assigned to a ticket category. */ @@ -1100,6 +1133,117 @@ class TicketsModel extends BaseDatabaseModel return $db->loadObjectList() ?: []; } + // ================================================================== + // Status/Priority CRUD + // ================================================================== + + public function saveStatus(array $data): array + { + $db = $this->getDatabase(); + $obj = (object) $data; + + if (!empty($obj->title) && empty($obj->alias)) + { + $obj->alias = strtolower(preg_replace('/[^a-z0-9]+/i', '_', $obj->title)); + } + + if (empty($obj->id)) + { + unset($obj->id); + $db->insertObject('#__mokosuiteclient_ticket_statuses', $obj, 'id'); + + return ['status' => 'ok', 'id' => (int) $obj->id, 'message' => 'Status created']; + } + + $db->updateObject('#__mokosuiteclient_ticket_statuses', $obj, 'id'); + + return ['status' => 'ok', 'id' => (int) $obj->id, 'message' => 'Status updated']; + } + + public function deleteStatus(int $id): array + { + if ($id < 1) + { + return ['status' => 'error', 'message' => 'Invalid ID']; + } + + $db = $this->getDatabase(); + + // Check no tickets use this status + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokosuiteclient_tickets')) + ->where($db->quoteName('status_id') . ' = ' . $id) + ); + + if ((int) $db->loadResult() > 0) + { + return ['status' => 'error', 'message' => 'Cannot delete — status is in use by tickets']; + } + + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__mokosuiteclient_ticket_statuses')) + ->where($db->quoteName('id') . ' = ' . $id) + )->execute(); + + return ['status' => 'ok', 'message' => 'Status deleted']; + } + + public function savePriority(array $data): array + { + $db = $this->getDatabase(); + $obj = (object) $data; + + if (!empty($obj->title) && empty($obj->alias)) + { + $obj->alias = strtolower(preg_replace('/[^a-z0-9]+/i', '_', $obj->title)); + } + + if (empty($obj->id)) + { + unset($obj->id); + $db->insertObject('#__mokosuiteclient_ticket_priorities', $obj, 'id'); + + return ['status' => 'ok', 'id' => (int) $obj->id, 'message' => 'Priority created']; + } + + $db->updateObject('#__mokosuiteclient_ticket_priorities', $obj, 'id'); + + return ['status' => 'ok', 'id' => (int) $obj->id, 'message' => 'Priority updated']; + } + + public function deletePriority(int $id): array + { + if ($id < 1) + { + return ['status' => 'error', 'message' => 'Invalid ID']; + } + + $db = $this->getDatabase(); + + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokosuiteclient_tickets')) + ->where($db->quoteName('priority_id') . ' = ' . $id) + ); + + if ((int) $db->loadResult() > 0) + { + return ['status' => 'error', 'message' => 'Cannot delete — priority is in use by tickets']; + } + + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__mokosuiteclient_ticket_priorities')) + ->where($db->quoteName('id') . ' = ' . $id) + )->execute(); + + return ['status' => 'ok', 'message' => 'Priority deleted']; + } + // ================================================================== // Akeeba Ticket System Importer // ================================================================== diff --git a/source/packages/com_mokosuiteclient/admin/src/Service/AttachmentService.php b/source/packages/com_mokosuiteclient/admin/src/Service/AttachmentService.php new file mode 100644 index 00000000..e150db3c --- /dev/null +++ b/source/packages/com_mokosuiteclient/admin/src/Service/AttachmentService.php @@ -0,0 +1,183 @@ + [$files['name']], + 'type' => [$files['type']], + 'tmp_name' => [$files['tmp_name']], + 'error' => [$files['error']], + 'size' => [$files['size']], + ]; + } + + $ticketDir = self::STORAGE_DIR . '/' . $ticketId; + + if (!is_dir($ticketDir) && !Folder::create($ticketDir)) { + Log::add("Failed to create attachment directory: {$ticketDir}", Log::ERROR, 'mokosuiteclient'); + return []; + } + + $userId = (int) Factory::getUser()->id; + $db = Factory::getDbo(); + + for ($i = 0, $count = count($files['name']); $i < $count; $i++) + { + if ($files['error'][$i] !== UPLOAD_ERR_OK) { + Log::add("Attachment upload error for '{$files['name'][$i]}': PHP error code {$files['error'][$i]}", Log::WARNING, 'mokosuiteclient'); + continue; + } + + $originalName = File::makeSafe($files['name'][$i]); + $ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION)); + + // Validate extension + if (!in_array($ext, self::ALLOWED_EXTENSIONS, true)) { + Log::add("Attachment rejected: disallowed extension .{$ext}", Log::WARNING, 'mokosuiteclient'); + continue; + } + + // Validate size + if ($files['size'][$i] > self::MAX_FILE_SIZE) { + Log::add("Attachment rejected: file too large ({$files['size'][$i]} bytes)", Log::WARNING, 'mokosuiteclient'); + continue; + } + + // Generate unique filename to prevent overwrites + $storedName = uniqid('att_', true) . '.' . $ext; + $destPath = $ticketDir . '/' . $storedName; + + if (!File::upload($files['tmp_name'][$i], $destPath)) { + Log::add("Attachment upload failed: {$originalName}", Log::ERROR, 'mokosuiteclient'); + continue; + } + + $record = (object) [ + 'ticket_id' => $ticketId, + 'reply_id' => $replyId, + 'filename' => $originalName, + 'filepath' => $ticketId . '/' . $storedName, + 'filesize' => $files['size'][$i], + 'mimetype' => mime_content_type($destPath) ?: 'application/octet-stream', + 'uploaded_by' => $userId, + 'created' => Factory::getDate()->toSql(), + ]; + + $db->insertObject('#__mokosuiteclient_ticket_attachments', $record, 'id'); + $saved[] = $record; + } + + return $saved; + } + + /** + * Get attachments for a ticket. + */ + public static function getForTicket(int $ticketId): array + { + $db = Factory::getDbo(); + $db->setQuery( + $db->getQuery(true) + ->select('a.*, u.name AS uploader_name') + ->from($db->quoteName('#__mokosuiteclient_ticket_attachments', 'a')) + ->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = a.uploaded_by') + ->where($db->quoteName('a.ticket_id') . ' = ' . $ticketId) + ->order('a.created ASC') + ); + return $db->loadObjectList() ?: []; + } + + /** + * Get the absolute filesystem path for an attachment. + */ + public static function getAbsolutePath(object $attachment): ?string + { + $path = realpath(self::STORAGE_DIR . '/' . $attachment->filepath); + if ($path === false || !str_starts_with($path, realpath(self::STORAGE_DIR))) { + return null; + } + return $path; + } + + /** + * Delete an attachment (file + DB record). + */ + public static function delete(int $attachmentId): bool + { + $db = Factory::getDbo(); + $db->setQuery( + $db->getQuery(true) + ->select('*') + ->from('#__mokosuiteclient_ticket_attachments') + ->where('id = ' . $attachmentId) + ); + $att = $db->loadObject(); + + if (!$att) { + return false; + } + + $path = self::STORAGE_DIR . '/' . $att->filepath; + + if (file_exists($path)) { + File::delete($path); + } + + $db->setQuery( + $db->getQuery(true) + ->delete('#__mokosuiteclient_ticket_attachments') + ->where('id = ' . $attachmentId) + )->execute(); + + return true; + } + + /** + * Format file size for display. + */ + public static function formatSize(int $bytes): string + { + if ($bytes < 1024) return $bytes . ' B'; + if ($bytes < 1048576) return round($bytes / 1024, 1) . ' KB'; + return round($bytes / 1048576, 1) . ' MB'; + } +} diff --git a/source/packages/com_mokosuiteclient/admin/src/Service/AutomationEngine.php b/source/packages/com_mokosuiteclient/admin/src/Service/AutomationEngine.php new file mode 100644 index 00000000..37d7773c --- /dev/null +++ b/source/packages/com_mokosuiteclient/admin/src/Service/AutomationEngine.php @@ -0,0 +1,280 @@ +conditions, true) ?: []; + $actions = json_decode($rule->actions, true) ?: []; + + if (self::evaluateConditions($conditions, $context)) + { + self::executeActions($actions, $rule, $context); + } + } + } + catch (\Throwable $e) + { + Log::add('Automation engine error: ' . $e->getMessage(), Log::ERROR, 'mokosuiteclient'); + } + } + + /** + * Get active automation rules for a trigger event. + */ + private static function getActiveRules(string $event): array + { + $db = Factory::getDbo(); + $db->setQuery( + $db->getQuery(true) + ->select('*') + ->from('#__mokosuiteclient_ticket_automation') + ->where($db->quoteName('trigger_event') . ' = ' . $db->quote($event)) + ->where($db->quoteName('enabled') . ' = 1') + ->order('ordering ASC') + ); + return $db->loadObjectList() ?: []; + } + + /** + * Evaluate all conditions (AND logic). + */ + private static function evaluateConditions(array $conditions, array $context): bool + { + foreach ($conditions as $c) + { + $field = $c['field'] ?? ''; + $op = $c['op'] ?? 'eq'; + $expected = $c['value'] ?? ''; + $actual = $context[$field] ?? ''; + + switch ($op) + { + case 'eq': if ((string) $actual !== (string) $expected) return false; break; + case 'neq': if ((string) $actual === (string) $expected) return false; break; + case 'gt': if ((float) $actual <= (float) $expected) return false; break; + case 'lt': if ((float) $actual >= (float) $expected) return false; break; + case 'in': + $values = array_map('trim', explode(',', $expected)); + if (!in_array((string) $actual, $values, true)) return false; + break; + case 'not_in': + $values = array_map('trim', explode(',', $expected)); + if (in_array((string) $actual, $values, true)) return false; + break; + } + } + return true; + } + + /** + * Execute actions for a matched rule. + */ + private static function executeActions(array $actions, object $rule, array $context): void + { + $db = Factory::getDbo(); + $ticketId = (int) ($context['ticket_id'] ?? $context['id'] ?? 0); + + foreach ($actions as $action) + { + $type = $action['type'] ?? ''; + $value = $action['value'] ?? ''; + + try + { + switch ($type) + { + case 'set_status': + if ($ticketId) { + $statusId = self::resolveStatusId($db, $value); + $sets = "status = {$db->quote($value)}, modified = {$db->quote(Factory::getDate()->toSql())}"; + if ($statusId) { $sets .= ", status_id = {$statusId}"; } + $db->setQuery("UPDATE {$db->quoteName('#__mokosuiteclient_tickets')} SET {$sets} WHERE id = {$ticketId}")->execute(); + } + break; + + case 'set_priority': + if ($ticketId) { + $priorityId = self::resolvePriorityId($db, $value); + $sets = "priority = {$db->quote($value)}, modified = {$db->quote(Factory::getDate()->toSql())}"; + if ($priorityId) { $sets .= ", priority_id = {$priorityId}"; } + $db->setQuery("UPDATE {$db->quoteName('#__mokosuiteclient_tickets')} SET {$sets} WHERE id = {$ticketId}")->execute(); + } + break; + + case 'assign': + $assignId = (int) $value; + if ($ticketId && $assignId > 0) { + $db->setQuery("UPDATE {$db->quoteName('#__mokosuiteclient_tickets')} SET assigned_to = {$assignId}, modified = {$db->quote(Factory::getDate()->toSql())} WHERE id = {$ticketId}")->execute(); + } + break; + + case 'add_note': + if ($ticketId) { + $note = (object) [ + 'ticket_id' => $ticketId, + 'user_id' => 0, + 'body' => $value ?: '[Automation: ' . ($rule->title ?? '') . ']', + 'is_internal' => 1, + 'created' => Factory::getDate()->toSql(), + ]; + $db->insertObject('#__mokosuiteclient_ticket_replies', $note); + } + break; + + case 'send_email': + NotificationService::securityAlert( + 'automation', + 'Automation: ' . ($rule->title ?? ''), + $value ?: 'Rule triggered for ticket #' . $ticketId + ); + break; + + case 'send_ntfy': + NotificationService::pushNtfySecurity( + 'automation', + 'Automation: ' . ($rule->title ?? ''), + $value ?: 'Rule triggered for ticket #' . $ticketId + ); + break; + + case 'close': + if ($ticketId) { + $closedId = self::resolveClosedStatusId($db); + $sets = "status = 'closed', closed = {$db->quote(Factory::getDate()->toSql())}, modified = {$db->quote(Factory::getDate()->toSql())}"; + if ($closedId) { $sets .= ", status_id = {$closedId}"; } + $db->setQuery("UPDATE {$db->quoteName('#__mokosuiteclient_tickets')} SET {$sets} WHERE id = {$ticketId}")->execute(); + } + break; + + case 'create_ticket': + self::createTicketFromAutomation($rule, $context, $value); + break; + } + } + catch (\Throwable $e) + { + Log::add("Automation action '{$type}' failed for rule #{$rule->id}: " . $e->getMessage(), Log::ERROR, 'mokosuiteclient'); + } + } + } + + /** + * Create a ticket from automation (with behavior: append/always_new/skip_if_open). + */ + private static function resolveStatusId($db, string $alias): int + { + return (int) $db->setQuery( + $db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_statuses') + ->where($db->quoteName('alias') . ' = ' . $db->quote($alias)), 0, 1 + )->loadResult(); + } + + private static function resolvePriorityId($db, string $alias): int + { + return (int) $db->setQuery( + $db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_priorities') + ->where($db->quoteName('alias') . ' = ' . $db->quote($alias)), 0, 1 + )->loadResult(); + } + + private static function resolveClosedStatusId($db): int + { + return (int) $db->setQuery( + $db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_statuses') + ->where($db->quoteName('is_closed') . ' = 1'), 0, 1 + )->loadResult(); + } + + private static function createTicketFromAutomation(object $rule, array $context, string $subject): void + { + $db = Factory::getDbo(); + $behavior = $rule->behavior ?? 'append'; + $userId = (int) ($context['user_id'] ?? 0); + $catId = (int) ($context['category_id'] ?? 0); + + if ($behavior !== 'always_new' && $userId > 0) + { + // Check for existing open ticket (check both status ENUM and status_id) + $query = $db->getQuery(true) + ->select('t.id') + ->from($db->quoteName('#__mokosuiteclient_tickets', 't')) + ->join('LEFT', $db->quoteName('#__mokosuiteclient_ticket_statuses', 's') . ' ON t.status_id = s.id') + ->where('t.created_by = ' . $userId) + ->where("(s.id IS NULL AND t.status NOT IN ('closed', 'resolved')) OR (s.id IS NOT NULL AND s.is_closed = 0)"); + + if ($catId > 0) { + $query->where('category_id = ' . $catId); + } + + $db->setQuery($query, 0, 1); + $existingId = (int) $db->loadResult(); + + if ($existingId > 0) + { + if ($behavior === 'skip_if_open') return; + + // append — add reply to existing ticket + $reply = (object) [ + 'ticket_id' => $existingId, + 'user_id' => 0, + 'body' => $subject ?: '[Automation: ' . ($rule->title ?? '') . ']', + 'is_internal' => 1, + 'created' => Factory::getDate()->toSql(), + ]; + $db->insertObject('#__mokosuiteclient_ticket_replies', $reply); + return; + } + } + + // Create new ticket + $openStatusId = self::resolveStatusId($db, 'open') ?: null; + $normalPriorityId = self::resolvePriorityId($db, $context['priority'] ?? 'normal') ?: null; + $ticket = (object) [ + 'subject' => $subject ?: 'Automation: ' . ($rule->title ?? ''), + 'body' => $context['body'] ?? '', + 'status' => 'open', + 'status_id' => $openStatusId, + 'priority' => $context['priority'] ?? 'normal', + 'priority_id' => $normalPriorityId, + 'category_id' => $catId ?: null, + 'created_by' => $userId, + 'created' => Factory::getDate()->toSql(), + ]; + $db->insertObject('#__mokosuiteclient_tickets', $ticket, 'id'); + } +} diff --git a/source/packages/com_mokosuiteclient/admin/src/Service/NotificationService.php b/source/packages/com_mokosuiteclient/admin/src/Service/NotificationService.php index 016cdb2c..caba2318 100644 --- a/source/packages/com_mokosuiteclient/admin/src/Service/NotificationService.php +++ b/source/packages/com_mokosuiteclient/admin/src/Service/NotificationService.php @@ -70,6 +70,9 @@ class NotificationService Log::add('Notification send failed to ' . $email . ': ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); } } + + // Push notification via ntfy + self::pushNtfy($event, $ticket, $subject); } catch (\Throwable $e) { @@ -302,6 +305,7 @@ class NotificationService } catch (\Throwable $e) { + Log::add('Failed to look up email for user ID ' . $userId . ': ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); return null; } } @@ -328,10 +332,168 @@ class NotificationService } catch (\Throwable $e) { + Log::add('Failed to load notification config: ' . $e->getMessage(), Log::ERROR, 'mokosuiteclient'); return []; } } + // ================================================================== + // Ntfy Push Notifications (#205) + // ================================================================== + + /** + * Send a push notification via ntfy for ticket events. + */ + private static function pushNtfy(string $event, object $ticket, string $title): void + { + $config = self::getNotificationConfig(); + $ntfyEnabled = $config['ntfy_enabled'] ?? '0'; + + if (!$ntfyEnabled) + { + return; + } + + $ntfyServer = rtrim($config['ntfy_server'] ?? 'https://ntfy.mokoconsulting.tech', '/'); + $ntfyTopic = $config['ntfy_topic'] ?? 'mokosuiteclient-tickets'; + $ntfyToken = $config['ntfy_token'] ?? ''; + + $tagMap = [ + 'ticket_created' => 'ticket,new', + 'ticket_replied' => 'speech_balloon', + 'status_changed' => 'arrows_counterclockwise', + 'ticket_assigned' => 'bust_in_silhouette', + ]; + + $priorityMap = [ + 'ticket_created' => '4', + 'ticket_replied' => '3', + 'status_changed' => '3', + 'ticket_assigned' => '3', + ]; + + $siteUrl = rtrim(Uri::root(), '/'); + $ticketUrl = $siteUrl . '/administrator/index.php?option=com_mokosuiteclient&view=ticket&id=' . ($ticket->id ?? 0); + + $message = self::buildNtfyMessage($event, $ticket); + + $headers = [ + 'Title: ' . $title, + 'Priority: ' . ($priorityMap[$event] ?? '3'), + 'Tags: ' . ($tagMap[$event] ?? 'ticket'), + 'Click: ' . $ticketUrl, + ]; + + if ($ntfyToken !== '') + { + $headers[] = 'Authorization: Bearer ' . $ntfyToken; + } + + $url = $ntfyServer . '/' . $ntfyTopic; + + try + { + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $message); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + $response = curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlError = curl_error($ch); + curl_close($ch); + + if ($response === false) + { + Log::add("Ntfy push connection failed for event {$event}: " . $curlError, Log::WARNING, 'mokosuiteclient'); + } + elseif ($httpCode < 200 || $httpCode >= 300) + { + Log::add("Ntfy push failed (HTTP {$httpCode}) for event {$event}", Log::WARNING, 'mokosuiteclient'); + } + } + catch (\Throwable $e) + { + Log::add('Ntfy push error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); + } + } + + /** + * Build a short ntfy message body for ticket events. + */ + private static function buildNtfyMessage(string $event, object $ticket): string + { + $subject = $ticket->subject ?? 'Ticket #' . ($ticket->id ?? '?'); + + switch ($event) + { + case 'ticket_created': + $priority = ucfirst($ticket->priority ?? 'normal'); + return "New ticket: {$subject}\nPriority: {$priority}"; + + case 'ticket_replied': + return "Reply on: {$subject}"; + + case 'status_changed': + $status = ucwords(str_replace('_', ' ', $ticket->status ?? '')); + return "Status → {$status}: {$subject}"; + + case 'ticket_assigned': + return "Assigned to you: {$subject}"; + + default: + return $subject; + } + } + + /** + * Send a push notification via ntfy for security events. + */ + public static function pushNtfySecurity(string $event, string $title, string $body): void + { + $config = self::getNotificationConfig(); + $ntfyEnabled = $config['ntfy_enabled'] ?? '0'; + + if (!$ntfyEnabled) + { + return; + } + + $ntfyServer = rtrim($config['ntfy_server'] ?? 'https://ntfy.mokoconsulting.tech', '/'); + $ntfyTopic = $config['ntfy_security_topic'] ?? $config['ntfy_topic'] ?? 'mokosuiteclient-security'; + $ntfyToken = $config['ntfy_token'] ?? ''; + + $headers = [ + 'Title: [Security] ' . $title, + 'Priority: 5', + 'Tags: warning,shield', + ]; + + if ($ntfyToken !== '') + { + $headers[] = 'Authorization: Bearer ' . $ntfyToken; + } + + $url = $ntfyServer . '/' . $ntfyTopic; + + try + { + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 5); + curl_exec($ch); + curl_close($ch); + } + catch (\Throwable $e) + { + Log::add('Ntfy security push error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); + } + } + // ================================================================== // Security Event Notifications (#131) // ================================================================== @@ -407,6 +569,9 @@ class NotificationService Log::add('Security alert send failed: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); } } + + // Also push via ntfy + self::pushNtfySecurity($event, $subject, $body); } catch (\Throwable $e) { diff --git a/source/packages/com_mokosuiteclient/admin/src/View/Ticket/HtmlView.php b/source/packages/com_mokosuiteclient/admin/src/View/Ticket/HtmlView.php index 34477a2e..6e209ffe 100644 --- a/source/packages/com_mokosuiteclient/admin/src/View/Ticket/HtmlView.php +++ b/source/packages/com_mokosuiteclient/admin/src/View/Ticket/HtmlView.php @@ -23,6 +23,7 @@ class HtmlView extends BaseHtmlView protected $priorities = []; protected $customFields = []; protected $fieldValues = []; + protected $attachments = []; public function display($tpl = null) { @@ -43,6 +44,9 @@ class HtmlView extends BaseHtmlView $this->fieldValues = $model->getFieldValues($id); } + // Load attachments + $this->attachments = \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::getForTicket($id); + if (!$this->ticket) { Factory::getApplication()->enqueueMessage('Ticket not found.', 'error'); diff --git a/source/packages/com_mokosuiteclient/admin/src/View/Tickets/HtmlView.php b/source/packages/com_mokosuiteclient/admin/src/View/Tickets/HtmlView.php index 404b48ff..16c65e31 100644 --- a/source/packages/com_mokosuiteclient/admin/src/View/Tickets/HtmlView.php +++ b/source/packages/com_mokosuiteclient/admin/src/View/Tickets/HtmlView.php @@ -25,6 +25,8 @@ class HtmlView extends BaseHtmlView protected $contacts = []; protected $statuses = []; protected $priorities = []; + protected $backendUsers = []; + protected $userGroups = []; public function display($tpl = null) { @@ -46,6 +48,8 @@ class HtmlView extends BaseHtmlView $this->overdue = $model->getOverdueTickets(); $this->atsAvailable = $model->checkAtsAvailable(); $this->contacts = $model->getContacts(); + $this->backendUsers = $model->getBackendUsers(); + $this->userGroups = $model->getUserGroups(); $this->addToolbar(); diff --git a/source/packages/com_mokosuiteclient/admin/src/View/Ticketsettings/HtmlView.php b/source/packages/com_mokosuiteclient/admin/src/View/Ticketsettings/HtmlView.php new file mode 100644 index 00000000..daac6e96 --- /dev/null +++ b/source/packages/com_mokosuiteclient/admin/src/View/Ticketsettings/HtmlView.php @@ -0,0 +1,41 @@ + + * + * SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later + * + * @package MokoSuiteClient + * @subpackage Component + */ + +namespace Moko\Component\MokoSuiteClient\Administrator\View\Ticketsettings; + +defined('_JEXEC') or die; + +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Toolbar\ToolbarHelper; + +class HtmlView extends BaseHtmlView +{ + protected $statuses = []; + protected $priorities = []; + + public function display($tpl = null) + { + $model = $this->getModel('Tickets'); + + $this->statuses = $model->getStatuses(); + $this->priorities = $model->getPriorities(); + + $this->addToolbar(); + + parent::display($tpl); + } + + protected function addToolbar(): void + { + ToolbarHelper::title(Text::_('COM_MOKOSUITECLIENT_TICKET_SETTINGS'), 'cog'); + ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient&view=tickets'); + } +} diff --git a/source/packages/com_mokosuiteclient/admin/src/View/Ticketsettings/index.html b/source/packages/com_mokosuiteclient/admin/src/View/Ticketsettings/index.html new file mode 100644 index 00000000..94906bce --- /dev/null +++ b/source/packages/com_mokosuiteclient/admin/src/View/Ticketsettings/index.html @@ -0,0 +1 @@ + diff --git a/source/packages/com_mokosuiteclient/admin/tmpl/automation/default.php b/source/packages/com_mokosuiteclient/admin/tmpl/automation/default.php index a34315df..f47260ea 100644 --- a/source/packages/com_mokosuiteclient/admin/tmpl/automation/default.php +++ b/source/packages/com_mokosuiteclient/admin/tmpl/automation/default.php @@ -9,81 +9,110 @@ $token = Session::getFormToken(); $saveUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.saveAutomation&format=json'); $deleteUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.deleteAutomation&format=json'); $toggleUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.toggleAutomation&format=json'); +$reorderUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.reorderAutomation&format=json'); -$triggerLabels = ['ticket_created' => 'On Ticket Created', 'ticket_replied' => 'On Reply', 'status_changed' => 'On Status Change', 'scheduled' => 'Scheduled (Cron)']; +$triggerLabels = [ + 'ticket_created' => 'On Ticket Created', + 'ticket_replied' => 'On Reply', + 'status_changed' => 'On Status Change', + 'ticket_assigned' => 'On Assignment', + 'user_login' => 'On User Login', + 'user_register' => 'On User Register', + 'user_login_failed' => 'On Failed Login', + 'content_save' => 'On Article Save', + 'extension_install' => 'On Extension Install', + 'scheduled' => 'Scheduled (Cron)', +]; +$conditionFields = ['status', 'priority', 'category_id', 'assigned_to', 'sla_responded', 'age_hours']; +$conditionOps = ['eq' => '=', 'neq' => '≠', 'gt' => '>', 'lt' => '<', 'in' => 'in', 'not_in' => 'not in']; +$actionTypes = ['set_status', 'set_priority', 'assign', 'add_note', 'send_email', 'send_ntfy', 'close']; ?>

Automation Rules

-
- - conditions, true) ?: []; $actions = json_decode($r->actions, true) ?: []; ?> -
-
-
-
-
-
- enabled ? 'checked' : ''; ?>> +
+ + conditions, true) ?: []; $actions = json_decode($r->actions, true) ?: []; ?> +
+
+
+
+
+ +
+ enabled ? 'checked' : ''; ?>> +
+ title); ?> + trigger_event] ?? $r->trigger_event; ?> +
+
+ + IF + $c): ?> + 0 ? ' AND ' : ''; ?> + + + THEN + + = +
- title); ?> - trigger_event] ?? $r->trigger_event; ?> -
-
- IF - $c): ?> - 0 ? ' AND ' : ''; ?> - - THEN - - = -
+
-
-
- + - -
No automation rules. Click "Add Rule" to create one.
- + +
No automation rules. Click "Add Rule" to create one.
+ +
- -
+ +
+ + + + + + + + + +
diff --git a/source/packages/plg_system_mokosuiteclient/script.php b/source/packages/plg_system_mokosuiteclient/script.php index c278d6d6..67fda5d7 100644 --- a/source/packages/plg_system_mokosuiteclient/script.php +++ b/source/packages/plg_system_mokosuiteclient/script.php @@ -22,7 +22,7 @@ * DEFGROUP: Joomla.Plugin * INGROUP: MokoSuiteClient * REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - * VERSION: 02.35.00 + * VERSION: 02.41.00 * PATH: /src/script.php * BRIEF: Installation script for MokoSuiteClient plugin * NOTE: Handles installation, update, and uninstallation tasks including language override deployment @@ -527,7 +527,7 @@ class plgSystemMokoSuiteClientInstallerScript implements InstallerScriptInterfac } catch (\Exception $e) { - // Don't break install if email fails + Log::add('Install notification email failed: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); } } diff --git a/source/packages/plg_system_mokosuiteclient/services/provider.php b/source/packages/plg_system_mokosuiteclient/services/provider.php index 11e32da7..a6f8f493 100644 --- a/source/packages/plg_system_mokosuiteclient/services/provider.php +++ b/source/packages/plg_system_mokosuiteclient/services/provider.php @@ -22,7 +22,7 @@ * DEFGROUP: Joomla.Plugin * INGROUP: MokoSuiteClient * REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - * VERSION: 02.35.00 + * VERSION: 02.41.00 * 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_mokosuiteclient_backup/language/en-GB/plg_system_mokosuiteclient_backup.ini b/source/packages/plg_system_mokosuiteclient_backup/language/en-GB/plg_system_mokosuiteclient_backup.ini new file mode 100644 index 00000000..fc4e6e46 --- /dev/null +++ b/source/packages/plg_system_mokosuiteclient_backup/language/en-GB/plg_system_mokosuiteclient_backup.ini @@ -0,0 +1,13 @@ +; MokoSuiteClient Backup Bridge Plugin +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GPL-3.0-or-later + +PLG_SYSTEM_MOKOSUITECLIENT_BACKUP="System - MokoSuiteClient Backup" +PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_DESC="Detects MokoSuiteBackup and includes backup status in heartbeat payloads sent to MokoSuiteHQ." + +PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_FIELDSET_BASIC="Backup Monitoring" +PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_FIELDSET_BASIC_DESC="Configure backup status collection for heartbeat reporting." +PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_HEARTBEAT_LABEL="Include in Heartbeat" +PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_HEARTBEAT_DESC="Include MokoSuiteBackup status data in heartbeat payloads sent to MokoSuiteHQ." +PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_STALE_DAYS_LABEL="Stale Backup Threshold (days)" +PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_STALE_DAYS_DESC="Number of days without a backup before status is marked as degraded. Default: 7." diff --git a/source/packages/plg_system_mokosuiteclient_backup/language/en-GB/plg_system_mokosuiteclient_backup.sys.ini b/source/packages/plg_system_mokosuiteclient_backup/language/en-GB/plg_system_mokosuiteclient_backup.sys.ini new file mode 100644 index 00000000..07da83a8 --- /dev/null +++ b/source/packages/plg_system_mokosuiteclient_backup/language/en-GB/plg_system_mokosuiteclient_backup.sys.ini @@ -0,0 +1,3 @@ +; MokoSuiteClient Backup Bridge Plugin - System strings +PLG_SYSTEM_MOKOSUITECLIENT_BACKUP="System - MokoSuiteClient Backup" +PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_DESC="MokoSuiteBackup detection and heartbeat integration." diff --git a/source/packages/plg_system_mokosuiteclient_backup/mokosuiteclient_backup.xml b/source/packages/plg_system_mokosuiteclient_backup/mokosuiteclient_backup.xml new file mode 100644 index 00000000..c0f05aeb --- /dev/null +++ b/source/packages/plg_system_mokosuiteclient_backup/mokosuiteclient_backup.xml @@ -0,0 +1,48 @@ + + + System - MokoSuiteClient Backup + mokosuiteclient_backup + Moko Consulting + 2026-06-18 + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + hello@mokoconsulting.tech + https://mokoconsulting.tech + 02.34.84-dev + PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_DESC + Moko\Plugin\System\MokoSuiteClientBackup + + + src + services + language + + + + en-GB/plg_system_mokosuiteclient_backup.ini + en-GB/plg_system_mokosuiteclient_backup.sys.ini + + + + +
+ + + + + + + + +
+
+
+
diff --git a/source/packages/plg_system_mokosuiteclient_backup/services/provider.php b/source/packages/plg_system_mokosuiteclient_backup/services/provider.php new file mode 100644 index 00000000..4ea63861 --- /dev/null +++ b/source/packages/plg_system_mokosuiteclient_backup/services/provider.php @@ -0,0 +1,34 @@ +set( + PluginInterface::class, + function (Container $container) { + $dispatcher = $container->get(DispatcherInterface::class); + $plugin = new Backup($dispatcher, (array) PluginHelper::getPlugin('system', 'mokosuiteclient_backup')); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/source/packages/plg_system_mokosuiteclient_backup/src/Extension/Backup.php b/source/packages/plg_system_mokosuiteclient_backup/src/Extension/Backup.php new file mode 100644 index 00000000..446b36c8 --- /dev/null +++ b/source/packages/plg_system_mokosuiteclient_backup/src/Extension/Backup.php @@ -0,0 +1,263 @@ + 'onCollectHeartbeat', + ]; + } + + /** + * Collect backup status data for the heartbeat payload. + * + * Triggered by the monitor plugin before sending a heartbeat. + * Appends a 'backup' key to the heartbeat data array. + */ + public function onCollectHeartbeat($event): void + { + if (!$this->params->get('heartbeat_enabled', 1)) + { + return; + } + + try + { + $data = $this->getBackupStatus(); + $event->addResult('backup', $data); + } + catch (\Throwable $e) + { + Log::add('Backup bridge: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); + + // Send explicit error so HQ knows collection failed, + // rather than interpreting absence as "not installed" + $event->addResult('backup', [ + 'installed' => true, + 'status' => 'error', + 'message' => 'Failed to collect backup status', + ]); + } + } + + /** + * Check if MokoSuiteBackup is installed. + * + * Queries the extensions table for the component, which is more + * reliable than checking for database tables alone. + */ + public function isBackupInstalled(): bool + { + try + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokosuitebackup')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')); + + $db->setQuery($query); + + return (int) $db->loadResult() > 0; + } + catch (\Throwable $e) + { + return false; + } + } + + /** + * Get backup status summary from MokoSuiteBackup. + * + * Prefers the BackupStatusHelper API when available. Falls back + * to a direct database query for compatibility with older versions. + * + * @return array Backup status data for heartbeat inclusion. + */ + public function getBackupStatus(): array + { + if (!$this->isBackupInstalled()) + { + return [ + 'installed' => false, + 'status' => 'ok', + ]; + } + + // Prefer MokoSuiteBackup's own helper (clean public API) + $helperClass = 'Joomla\\Component\\MokoSuiteBackup\\Administrator\\Utility\\BackupStatusHelper'; + + if (class_exists($helperClass)) + { + $staleDays = (int) $this->params->get('stale_days', 7); + + return $helperClass::getStatus($staleDays); + } + + // Fallback: direct table query for older MokoSuiteBackup versions + $db = Factory::getContainer()->get(DatabaseInterface::class); + $tables = $db->getTableList(); + $prefix = $db->getPrefix(); + + if (!in_array($prefix . 'mokosuitebackup_records', $tables, true)) + { + return [ + 'installed' => true, + 'status' => 'degraded', + 'message' => 'Backup tables not found', + ]; + } + + return $this->queryBackupRecords($db); + } + + /** + * Query MokoSuiteBackup records for the latest backup summary. + * + * Column names match the MokoSuiteBackup schema: + * - backupstart/backupend (not created/modified) + * - status: pending, running, complete, fail + * - total_size in bytes + * + * @param DatabaseInterface $db Database driver. + * + * @return array Backup status array. + */ + private function queryBackupRecords(DatabaseInterface $db): array + { + $staleDays = (int) $this->params->get('stale_days', 7); + + // Most recent backup record + $query = $db->getQuery(true) + ->select([ + $db->quoteName('id'), + $db->quoteName('description'), + $db->quoteName('status'), + $db->quoteName('backup_type'), + $db->quoteName('total_size'), + $db->quoteName('backupstart'), + $db->quoteName('backupend'), + $db->quoteName('origin'), + $db->quoteName('filesexist'), + ]) + ->from($db->quoteName('#__mokosuitebackup_records')) + ->order($db->quoteName('id') . ' DESC'); + + $db->setQuery($query, 0, 1); + $latest = $db->loadObject(); + + if (!$latest) + { + return [ + 'installed' => true, + 'status' => 'degraded', + 'message' => 'No backups found', + ]; + } + + // Count completed backups + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokosuitebackup_records')) + ->where($db->quoteName('status') . ' = ' . $db->quote('complete')) + ); + $totalBackups = (int) $db->loadResult(); + + $cutoff = date('Y-m-d H:i:s', strtotime("-{$staleDays} days")); + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokosuitebackup_records')) + ->where($db->quoteName('status') . ' = ' . $db->quote('complete')) + ->where($db->quoteName('backupstart') . ' >= ' . $db->quote($cutoff)) + ); + $recentBackups = (int) $db->loadResult(); + + // Failures in last 7 days + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokosuitebackup_records')) + ->where($db->quoteName('status') . ' = ' . $db->quote('fail')) + ->where($db->quoteName('backupstart') . ' >= ' . $db->quote($cutoff)) + ); + $failCount7d = (int) $db->loadResult(); + + // Determine status + $daysSince = 999; + + if (!empty($latest->backupstart) && $latest->backupstart !== '0000-00-00 00:00:00') + { + $daysSince = (int) ((time() - strtotime($latest->backupstart)) / 86400); + } + + $status = 'ok'; + + if ($latest->status === 'fail') + { + $status = 'degraded'; + } + elseif ($latest->status !== 'complete') + { + $status = ($latest->status === 'running') ? 'ok' : 'degraded'; + } + elseif ($daysSince > $staleDays) + { + $status = 'degraded'; + } + + $sizeMb = $latest->total_size + ? round($latest->total_size / 1048576) + : null; + + return [ + 'installed' => true, + 'status' => $status, + 'last_backup' => $latest->backupstart, + 'last_status' => $latest->status, + 'last_size_mb' => $sizeMb, + 'days_since' => $daysSince, + 'backup_type' => $latest->backup_type, + 'origin' => $latest->origin, + 'total_backups' => $totalBackups, + 'recent_7d' => $recentBackups, + 'fail_count_7d' => $failCount7d, + 'files_exist' => (bool) $latest->filesexist, + 'description' => $latest->description, + ]; + } +} diff --git a/source/packages/plg_system_mokosuiteclient_dbip/data/dbip-country-lite.mmdb b/source/packages/plg_system_mokosuiteclient_dbip/data/dbip-country-lite.mmdb new file mode 100644 index 00000000..df09a565 Binary files /dev/null and b/source/packages/plg_system_mokosuiteclient_dbip/data/dbip-country-lite.mmdb differ diff --git a/source/packages/plg_system_mokosuiteclient_dbip/language/en-GB/plg_system_mokosuiteclient_dbip.ini b/source/packages/plg_system_mokosuiteclient_dbip/language/en-GB/plg_system_mokosuiteclient_dbip.ini new file mode 100644 index 00000000..f13014d4 --- /dev/null +++ b/source/packages/plg_system_mokosuiteclient_dbip/language/en-GB/plg_system_mokosuiteclient_dbip.ini @@ -0,0 +1,29 @@ +; MokoSuiteClient DB-IP Plugin +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GPL-3.0-or-later +; IP Geolocation by DB-IP — https://db-ip.com + +PLG_SYSTEM_MOKOSUITECLIENT_DBIP="System - MokoSuiteClient DB-IP" +PLG_SYSTEM_MOKOSUITECLIENT_DBIP_DESC="IP geolocation for MokoSuiteClient using DB-IP Lite databases. Ships with country-level data; city-level data is downloaded from CDN or loaded from a local file." + +PLG_SYSTEM_MOKOSUITECLIENT_DBIP_FIELDSET_BASIC="DB-IP Settings" +PLG_SYSTEM_MOKOSUITECLIENT_DBIP_FIELDSET_BASIC_DESC="Configure IP geolocation database source and level." + +PLG_SYSTEM_MOKOSUITECLIENT_DBIP_SOURCE_LABEL="Database Source" +PLG_SYSTEM_MOKOSUITECLIENT_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_MOKOSUITECLIENT_DBIP_SOURCE_CDN="CDN (auto-download)" +PLG_SYSTEM_MOKOSUITECLIENT_DBIP_SOURCE_LOCAL="Local file" + +PLG_SYSTEM_MOKOSUITECLIENT_DBIP_DATABASE_LEVEL_LABEL="Database Level" +PLG_SYSTEM_MOKOSUITECLIENT_DBIP_DATABASE_LEVEL_DESC="Country is bundled (~8 MB). City provides region, city, and coordinates but requires a separate download (~125 MB)." +PLG_SYSTEM_MOKOSUITECLIENT_DBIP_DATABASE_COUNTRY="Country (bundled)" +PLG_SYSTEM_MOKOSUITECLIENT_DBIP_DATABASE_CITY="City (remote download)" + +PLG_SYSTEM_MOKOSUITECLIENT_DBIP_AUTO_UPDATE_LABEL="Auto-Update Database" +PLG_SYSTEM_MOKOSUITECLIENT_DBIP_AUTO_UPDATE_DESC="Automatically download the latest city database monthly when an admin visits the backend." + +PLG_SYSTEM_MOKOSUITECLIENT_DBIP_CDN_URL_LABEL="CDN Download URL" +PLG_SYSTEM_MOKOSUITECLIENT_DBIP_CDN_URL_DESC="URL to download the city-level MMDB file. Default points to the MokoConsulting geoip-data repository." + +PLG_SYSTEM_MOKOSUITECLIENT_DBIP_LOCAL_PATH_LABEL="Local MMDB Path" +PLG_SYSTEM_MOKOSUITECLIENT_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_mokosuiteclient_dbip/language/en-GB/plg_system_mokosuiteclient_dbip.sys.ini b/source/packages/plg_system_mokosuiteclient_dbip/language/en-GB/plg_system_mokosuiteclient_dbip.sys.ini new file mode 100644 index 00000000..8e2f8ac5 --- /dev/null +++ b/source/packages/plg_system_mokosuiteclient_dbip/language/en-GB/plg_system_mokosuiteclient_dbip.sys.ini @@ -0,0 +1,6 @@ +; MokoSuiteClient DB-IP Plugin (system strings) +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GPL-3.0-or-later + +PLG_SYSTEM_MOKOSUITECLIENT_DBIP="System - MokoSuiteClient DB-IP" +PLG_SYSTEM_MOKOSUITECLIENT_DBIP_DESC="IP geolocation for MokoSuiteClient using DB-IP Lite databases." diff --git a/source/packages/plg_system_mokosuiteclient_dbip/lib/MaxMind/Db/Reader.php b/source/packages/plg_system_mokosuiteclient_dbip/lib/MaxMind/Db/Reader.php new file mode 100644 index 00000000..542563c6 --- /dev/null +++ b/source/packages/plg_system_mokosuiteclient_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_mokosuiteclient_dbip/lib/MaxMind/Db/Reader/Decoder.php b/source/packages/plg_system_mokosuiteclient_dbip/lib/MaxMind/Db/Reader/Decoder.php new file mode 100644 index 00000000..1bb67316 --- /dev/null +++ b/source/packages/plg_system_mokosuiteclient_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_mokosuiteclient_dbip/lib/MaxMind/Db/Reader/InvalidDatabaseException.php b/source/packages/plg_system_mokosuiteclient_dbip/lib/MaxMind/Db/Reader/InvalidDatabaseException.php new file mode 100644 index 00000000..b1da1ed2 --- /dev/null +++ b/source/packages/plg_system_mokosuiteclient_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_mokosuiteclient_dbip/lib/MaxMind/Db/Reader/Util.php b/source/packages/plg_system_mokosuiteclient_dbip/lib/MaxMind/Db/Reader/Util.php new file mode 100644 index 00000000..c2c3212d --- /dev/null +++ b/source/packages/plg_system_mokosuiteclient_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_mokosuiteclient_dbip/mokosuiteclient_dbip.xml b/source/packages/plg_system_mokosuiteclient_dbip/mokosuiteclient_dbip.xml new file mode 100644 index 00000000..f77460ee --- /dev/null +++ b/source/packages/plg_system_mokosuiteclient_dbip/mokosuiteclient_dbip.xml @@ -0,0 +1,75 @@ + + + System - MokoSuiteClient DB-IP + mokosuiteclient_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.41.00 + PLG_SYSTEM_MOKOSUITECLIENT_DBIP_DESC + Moko\Plugin\System\MokoSuiteClientDBIP + + + src + services + language + lib + data + + + + en-GB/plg_system_mokosuiteclient_dbip.ini + en-GB/plg_system_mokosuiteclient_dbip.sys.ini + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+
diff --git a/source/packages/plg_system_mokosuiteclient_dbip/services/provider.php b/source/packages/plg_system_mokosuiteclient_dbip/services/provider.php new file mode 100644 index 00000000..d707ad5b --- /dev/null +++ b/source/packages/plg_system_mokosuiteclient_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', 'mokosuiteclient_dbip')); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/source/packages/plg_system_mokosuiteclient_dbip/src/Extension/DBIP.php b/source/packages/plg_system_mokosuiteclient_dbip/src/Extension/DBIP.php new file mode 100644 index 00000000..bacfe289 --- /dev/null +++ b/source/packages/plg_system_mokosuiteclient_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_mokosuiteclient_dbip/src/Helper/DBIPHelper.php b/source/packages/plg_system_mokosuiteclient_dbip/src/Helper/DBIPHelper.php new file mode 100644 index 00000000..3bb11516 --- /dev/null +++ b/source/packages/plg_system_mokosuiteclient_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/mokosuiteclient_dbip/dbip-city-lite.mmdb'; + $destDir = \dirname($destPath); + + if (!is_dir($destDir)) + { + mkdir($destDir, 0755, true); + } + + $tmpFile = $destPath . '.tmp'; + + try + { + $ch = curl_init($url); + $fp = fopen($tmpFile, 'wb'); + + curl_setopt_array($ch, [ + \CURLOPT_FILE => $fp, + \CURLOPT_FOLLOWLOCATION => true, + \CURLOPT_TIMEOUT => 300, + \CURLOPT_CONNECTTIMEOUT => 30, + \CURLOPT_USERAGENT => 'MokoSuiteClient-DBIP/1.0', + ]); + + $success = curl_exec($ch); + $code = curl_getinfo($ch, \CURLINFO_HTTP_CODE); + + curl_close($ch); + fclose($fp); + + if ($success && $code === 200 && filesize($tmpFile) > 1024) + { + if (self::$cityReader !== null) + { + self::$cityReader->close(); + self::$cityReader = null; + } + + rename($tmpFile, $destPath); + + return true; + } + + @unlink($tmpFile); + } + catch (\Throwable $e) + { + @unlink($tmpFile); + } + + return false; + } + + /** + * Normalize a DB-IP city record into a flat array. + */ + private static function normalizeCityRecord(array $record): array + { + return [ + 'country_code' => $record['country']['iso_code'] ?? '', + 'country_name' => $record['country']['names']['en'] ?? '', + 'continent_code' => $record['continent']['code'] ?? '', + 'continent_name' => $record['continent']['names']['en'] ?? '', + 'region' => $record['subdivisions'][0]['names']['en'] ?? '', + 'city' => $record['city']['names']['en'] ?? '', + 'latitude' => $record['location']['latitude'] ?? null, + 'longitude' => $record['location']['longitude'] ?? null, + 'timezone' => $record['location']['time_zone'] ?? '', + ]; + } + + /** + * Normalize a DB-IP country record into a flat array. + */ + private static function normalizeCountryRecord(array $record): array + { + return [ + 'country_code' => $record['country']['iso_code'] ?? '', + 'country_name' => $record['country']['names']['en'] ?? '', + 'continent_code' => $record['continent']['code'] ?? '', + 'continent_name' => $record['continent']['names']['en'] ?? '', + 'region' => '', + 'city' => '', + 'latitude' => null, + 'longitude' => null, + 'timezone' => '', + ]; + } + + /** + * Shut down readers. + */ + public static function close(): void + { + if (self::$countryReader !== null) + { + self::$countryReader->close(); + self::$countryReader = null; + } + + if (self::$cityReader !== null) + { + self::$cityReader->close(); + self::$cityReader = null; + } + } +} diff --git a/source/packages/plg_system_mokosuiteclient_devtools/mokosuiteclient_devtools.xml b/source/packages/plg_system_mokosuiteclient_devtools/mokosuiteclient_devtools.xml index d51878d6..de116d46 100644 --- a/source/packages/plg_system_mokosuiteclient_devtools/mokosuiteclient_devtools.xml +++ b/source/packages/plg_system_mokosuiteclient_devtools/mokosuiteclient_devtools.xml @@ -8,7 +8,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.35.00 + 02.41.00 PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DESC Moko\Plugin\System\MokoSuiteClientDevTools diff --git a/source/packages/plg_system_mokosuiteclient_firewall/mokosuiteclient_firewall.xml b/source/packages/plg_system_mokosuiteclient_firewall/mokosuiteclient_firewall.xml index cd057a5a..5abee0a7 100644 --- a/source/packages/plg_system_mokosuiteclient_firewall/mokosuiteclient_firewall.xml +++ b/source/packages/plg_system_mokosuiteclient_firewall/mokosuiteclient_firewall.xml @@ -8,7 +8,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.35.00 + 02.41.00 PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_DESC Moko\Plugin\System\MokoSuiteClientFirewall diff --git a/source/packages/plg_system_mokosuiteclient_license/mokosuiteclient_license.xml b/source/packages/plg_system_mokosuiteclient_license/mokosuiteclient_license.xml index 8fda7089..0edef142 100644 --- a/source/packages/plg_system_mokosuiteclient_license/mokosuiteclient_license.xml +++ b/source/packages/plg_system_mokosuiteclient_license/mokosuiteclient_license.xml @@ -8,7 +8,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.35.00 + 02.41.00 PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_DESC Moko\Plugin\System\MokoSuiteClientLicense srcserviceslanguage diff --git a/source/packages/plg_system_mokosuiteclient_monitor/mokosuiteclient_monitor.xml b/source/packages/plg_system_mokosuiteclient_monitor/mokosuiteclient_monitor.xml index f7931f88..7b23fd84 100644 --- a/source/packages/plg_system_mokosuiteclient_monitor/mokosuiteclient_monitor.xml +++ b/source/packages/plg_system_mokosuiteclient_monitor/mokosuiteclient_monitor.xml @@ -8,7 +8,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.35.00 + 02.41.00 PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_DESC Moko\Plugin\System\MokoSuiteClientMonitor diff --git a/source/packages/plg_system_mokosuiteclient_monitor/src/Extension/Monitor.php b/source/packages/plg_system_mokosuiteclient_monitor/src/Extension/Monitor.php index cb2f2951..648d373c 100644 --- a/source/packages/plg_system_mokosuiteclient_monitor/src/Extension/Monitor.php +++ b/source/packages/plg_system_mokosuiteclient_monitor/src/Extension/Monitor.php @@ -34,10 +34,58 @@ class Monitor extends CMSPlugin implements SubscriberInterface public static function getSubscribedEvents(): array { return [ - 'onExtensionAfterSave' => 'onExtensionAfterSave', + 'onExtensionAfterSave' => 'onExtensionAfterSave', + 'onAfterInitialise' => 'onAfterInitialise', + 'onExtensionAfterInstall' => 'onExtensionAfterInstall', ]; } + /** + * Send heartbeat on first admin page load after install/update. + */ + public function onAfterInitialise(): void + { + $app = $this->getApplication(); + if (!$app->isClient('administrator')) return; + if (!$this->params->get('heartbeat_enabled', 1)) return; + + $session = \Joomla\CMS\Factory::getSession(); + if ($session->get('mokosuiteclient.heartbeat_sent', false)) return; + + // Check if version changed since last heartbeat + $lastVersion = $this->params->get('_last_heartbeat_version', ''); + $currentVersion = $this->getMokoSuiteClientVersion(); + + if ($lastVersion !== $currentVersion) + { + $session->set('mokosuiteclient.heartbeat_sent', true); + $this->sendHeartbeat(); + + // Store version so we don't re-send every session + try + { + $this->params->set('_last_heartbeat_version', $currentVersion); + + $extension = new \Joomla\CMS\Table\Extension(Factory::getDbo()); + $extension->load(['element' => 'mokosuiteclient_monitor', 'folder' => 'system', 'type' => 'plugin']); + $extension->params = $this->params->toString(); + $extension->store(); + } + catch (\Throwable $e) {} + } + } + + /** + * Send heartbeat immediately after package install/update. + */ + public function onExtensionAfterInstall($installer, $eid): void + { + if (!$this->params->get('heartbeat_enabled', 1)) return; + + try { $this->sendHeartbeat(); } + catch (\Throwable $e) {} + } + /** * After saving this plugin or the core plugin, send heartbeat. */ @@ -146,46 +194,47 @@ class Monitor extends CMSPlugin implements SubscriberInterface $endpoint = $baseUrl . '/api/index.php/v1/mokosuiteclienthq/heartbeat'; $json = json_encode($payload, JSON_UNESCAPED_SLASHES); - $ch = curl_init($endpoint); - curl_setopt_array($ch, [ - CURLOPT_POST => true, - CURLOPT_HTTPHEADER => $headers, - CURLOPT_POSTFIELDS => $json, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 15, - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_SSL_VERIFYPEER => false, - ]); + try + { + $http = \Joomla\CMS\Http\HttpFactory::getHttp( + new \Joomla\Registry\Registry(['follow_location' => true, 'transport.curl' => ['certpath' => false]]), + ['curl', 'stream'] + ); - $response = curl_exec($ch); - $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - $error = curl_error($ch); - curl_close($ch); + $headerMap = []; + foreach ($headers as $h) + { + [$key, $val] = explode(': ', $h, 2); + $headerMap[$key] = $val; + } - if ($error) - { - Log::add('Monitor heartbeat failed: ' . $error, Log::WARNING, 'mokosuiteclient'); + $response = $http->post($endpoint, $json, $headerMap, 15); + $code = $response->code; + $body = json_decode($response->body, true); + + if ($code >= 200 && $code < 300) + { + $app->enqueueMessage( + 'MokoSuiteClientHQ heartbeat: ' . ($body['status'] ?? 'ok'), + 'message' + ); + } + else + { + Log::add( + \sprintf('Monitor heartbeat HTTP %d: %s', $code, $body['error'] ?? 'Unknown'), + Log::WARNING, + 'mokosuiteclient' + ); + $app->enqueueMessage( + 'MokoSuiteClientHQ heartbeat failed (HTTP ' . $code . ')', + 'warning' + ); + } } - elseif ($code >= 200 && $code < 300) + catch (\Throwable $e) { - $body = json_decode($response, true); - $app->enqueueMessage( - 'MokoSuiteClientHQ heartbeat: ' . ($body['status'] ?? 'ok'), - 'message' - ); - } - else - { - $body = json_decode($response, true); - Log::add( - \sprintf('Monitor heartbeat HTTP %d: %s', $code, $body['error'] ?? 'Unknown'), - Log::WARNING, - 'mokosuiteclient' - ); - $app->enqueueMessage( - 'MokoSuiteClientHQ heartbeat failed (HTTP ' . $code . ')', - 'warning' - ); + Log::add('Monitor heartbeat failed: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); } } @@ -257,30 +306,30 @@ class Monitor extends CMSPlugin implements SubscriberInterface */ private function fetchLocalHealth(string $siteUrl, string $healthToken): ?array { - $url = $siteUrl . '/?mokosuiteclient=health'; + try + { + $http = \Joomla\CMS\Http\HttpFactory::getHttp( + new \Joomla\Registry\Registry(['follow_location' => true, 'transport.curl' => ['certpath' => false]]), + ['curl', 'stream'] + ); - $ch = curl_init($url); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 10, - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_SSL_VERIFYPEER => false, - CURLOPT_HTTPHEADER => [ - 'Authorization: Bearer ' . $healthToken, - 'Accept: application/json', - ], - ]); + $response = $http->get( + $siteUrl . '/?mokosuiteclient=health', + ['Authorization' => 'Bearer ' . $healthToken, 'Accept' => 'application/json'], + 10 + ); - $response = curl_exec($ch); - $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); + if ($response->code !== 200 || empty($response->body)) + { + return null; + } - if ($code !== 200 || empty($response)) + return json_decode($response->body, true) ?: null; + } + catch (\Throwable $e) { return null; } - - return json_decode($response, true) ?: null; } /** @@ -290,17 +339,11 @@ class Monitor extends CMSPlugin implements SubscriberInterface { try { - $db = Factory::getDbo(); - $db->setQuery( - $db->getQuery(true) - ->select($db->quoteName('manifest_cache')) - ->from($db->quoteName('#__extensions')) - ->where($db->quoteName('element') . ' = ' . $db->quote('pkg_mokosuiteclient')) - ->where($db->quoteName('type') . ' = ' . $db->quote('package')) - ); - $mc = json_decode($db->loadResult() ?? '{}'); + $extension = new \Joomla\CMS\Table\Extension(Factory::getDbo()); + $extension->load(['element' => 'pkg_mokosuiteclient', 'type' => 'package']); + $manifest = json_decode($extension->manifest_cache ?? '{}'); - return $mc->version ?? ''; + return $manifest->version ?? ''; } catch (\Throwable $e) { diff --git a/source/packages/plg_system_mokosuiteclient_offline/mokosuiteclient_offline.xml b/source/packages/plg_system_mokosuiteclient_offline/mokosuiteclient_offline.xml index 2d521f9e..088f6b7e 100644 --- a/source/packages/plg_system_mokosuiteclient_offline/mokosuiteclient_offline.xml +++ b/source/packages/plg_system_mokosuiteclient_offline/mokosuiteclient_offline.xml @@ -8,7 +8,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.35.00 + 02.41.00 PLG_SYSTEM_MOKOSUITECLIENT_OFFLINE_DESC Moko\Plugin\System\MokoSuiteClientOffline diff --git a/source/packages/plg_system_mokosuiteclient_tenant/mokosuiteclient_tenant.xml b/source/packages/plg_system_mokosuiteclient_tenant/mokosuiteclient_tenant.xml index 5c818a9d..2edf90c3 100644 --- a/source/packages/plg_system_mokosuiteclient_tenant/mokosuiteclient_tenant.xml +++ b/source/packages/plg_system_mokosuiteclient_tenant/mokosuiteclient_tenant.xml @@ -8,7 +8,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.35.00 + 02.41.00 PLG_SYSTEM_MOKOSUITECLIENT_TENANT_DESC Moko\Plugin\System\MokoSuiteClientTenant diff --git a/source/packages/plg_task_mokosuiteclient_tickets/language/en-GB/plg_task_mokosuiteclient_tickets.ini b/source/packages/plg_task_mokosuiteclient_tickets/language/en-GB/plg_task_mokosuiteclient_tickets.ini index a319dcc2..f796d882 100644 --- a/source/packages/plg_task_mokosuiteclient_tickets/language/en-GB/plg_task_mokosuiteclient_tickets.ini +++ b/source/packages/plg_task_mokosuiteclient_tickets/language/en-GB/plg_task_mokosuiteclient_tickets.ini @@ -2,3 +2,7 @@ PLG_TASK_MOKOSUITECLIENT_TICKETS="Task - MokoSuiteClient Ticket Automation" PLG_TASK_MOKOSUITECLIENT_TICKETS_DESC="Runs scheduled helpdesk automation rules." PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOMATION_TITLE="MokoSuiteClient: Ticket Automation" PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOMATION_DESC="Runs time-based automation rules against open tickets (auto-close, SLA escalation, etc.)." +PLG_TASK_MOKOSUITECLIENT_TICKETS_IMAP_POLL_TITLE="MokoSuiteClient: IMAP Email Polling" +PLG_TASK_MOKOSUITECLIENT_TICKETS_IMAP_POLL_DESC="Polls an IMAP inbox for new emails and creates tickets or replies from unread messages." +PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOCLOSE_TITLE="MokoSuiteClient: Auto-Close Resolved Tickets" +PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOCLOSE_DESC="Automatically closes tickets that have been in resolved status longer than the configured number of days." diff --git a/source/packages/plg_task_mokosuiteclient_tickets/mokosuiteclient_tickets.xml b/source/packages/plg_task_mokosuiteclient_tickets/mokosuiteclient_tickets.xml index 71faf611..fef5f70d 100644 --- a/source/packages/plg_task_mokosuiteclient_tickets/mokosuiteclient_tickets.xml +++ b/source/packages/plg_task_mokosuiteclient_tickets/mokosuiteclient_tickets.xml @@ -8,7 +8,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.35.00 + 02.41.00 Runs scheduled helpdesk automation rules — auto-close resolved tickets, SLA breach escalation, and time-based actions. Moko\Plugin\Task\MokoSuiteClientTickets diff --git a/source/packages/plg_task_mokosuiteclient_tickets/src/Extension/TicketAutomation.php b/source/packages/plg_task_mokosuiteclient_tickets/src/Extension/TicketAutomation.php index 1bb30e83..66ea7c08 100644 --- a/source/packages/plg_task_mokosuiteclient_tickets/src/Extension/TicketAutomation.php +++ b/source/packages/plg_task_mokosuiteclient_tickets/src/Extension/TicketAutomation.php @@ -10,12 +10,16 @@ namespace Moko\Plugin\Task\MokoSuiteClientTickets\Extension; defined('_JEXEC') or die; +use Joomla\CMS\Factory; +use Joomla\CMS\Log\Log; use Joomla\CMS\Plugin\CMSPlugin; use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent; use Joomla\Component\Scheduler\Administrator\Task\Status; use Joomla\Component\Scheduler\Administrator\Traits\TaskPluginTrait; use Joomla\Event\SubscriberInterface; use Moko\Component\MokoSuiteClient\Administrator\Model\TicketsModel; +use Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService; +use Moko\Component\MokoSuiteClient\Administrator\Service\NotificationService; class TicketAutomation extends CMSPlugin implements SubscriberInterface { @@ -26,6 +30,14 @@ class TicketAutomation extends CMSPlugin implements SubscriberInterface 'langConstPrefix' => 'PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOMATION', 'method' => 'runAutomation', ], + 'mokosuiteclient.ticket.imap_poll' => [ + 'langConstPrefix' => 'PLG_TASK_MOKOSUITECLIENT_TICKETS_IMAP_POLL', + 'method' => 'runImapPoll', + ], + 'mokosuiteclient.ticket.autoclose' => [ + 'langConstPrefix' => 'PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOCLOSE', + 'method' => 'runAutoClose', + ], ]; protected $autoloadLanguage = true; @@ -62,4 +74,240 @@ class TicketAutomation extends CMSPlugin implements SubscriberInterface return Status::KNOCKOUT; } } + + /** + * Poll IMAP inbox and create tickets from unread emails (#136). + */ + private function runImapPoll(ExecuteTaskEvent $event): int + { + $config = $this->getComponentConfig(); + $host = $config['imap_host'] ?? ''; + $port = (int) ($config['imap_port'] ?? 993); + $user = $config['imap_user'] ?? ''; + $pass = $config['imap_password'] ?? ''; + $ssl = ($config['imap_ssl'] ?? '1') === '1'; + $folder = $config['imap_folder'] ?? 'INBOX'; + $processed = $config['imap_processed_folder'] ?? 'INBOX.Processed'; + $defaultCat = (int) ($config['default_category'] ?? 0) ?: null; + + if (empty($host) || empty($user) || empty($pass)) + { + $this->logTask('IMAP not configured — skipping', 'warning'); + return Status::OK; + } + + if (!function_exists('imap_open')) + { + $this->logTask('php-imap extension not available', 'error'); + return Status::KNOCKOUT; + } + + $mailbox = '{' . $host . ':' . $port . '/imap' . ($ssl ? '/ssl' : '') . '/novalidate-cert}' . $folder; + $mbox = @imap_open($mailbox, $user, $pass); + + if (!$mbox) + { + $this->logTask('IMAP connection failed: ' . imap_last_error(), 'error'); + return Status::KNOCKOUT; + } + + $db = Factory::getDbo(); + $created = 0; + $replied = 0; + + $emails = imap_search($mbox, 'UNSEEN'); + + if ($emails === false) + { + imap_close($mbox); + $this->logTask('No new emails'); + return Status::OK; + } + + foreach ($emails as $msgNum) + { + try + { + $header = imap_headerinfo($mbox, $msgNum); + $subject = isset($header->subject) ? imap_utf8($header->subject) : '(no subject)'; + $fromAddr = $header->from[0]->mailbox . '@' . $header->from[0]->host; + $body = $this->getImapBody($mbox, $msgNum); + + // Match sender to Joomla user + $userId = $this->findUserByEmail($fromAddr); + + // Check if this is a reply (subject contains [#123]) + $ticketId = 0; + if (preg_match('/\[#(\d+)\]/', $subject, $m)) + { + $ticketId = (int) $m[1]; + } + + if ($ticketId > 0) + { + // Add as reply to existing ticket + $reply = (object) [ + 'ticket_id' => $ticketId, + 'user_id' => $userId, + 'body' => $body, + 'is_internal' => 0, + 'created' => Factory::getDate()->toSql(), + ]; + $db->insertObject('#__mokosuiteclient_ticket_replies', $reply, 'id'); + $replied++; + + // Notify + $db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuiteclient_tickets')->where('id = ' . $ticketId)); + $ticket = $db->loadObject(); + if ($ticket) { + NotificationService::notify('ticket_replied', $ticket, ['reply_body' => $body]); + } + } + else + { + // Create new ticket + $ticket = (object) [ + 'subject' => $subject, + 'body' => $body, + 'status' => 'open', + 'priority' => 'normal', + 'category_id' => $defaultCat, + 'created_by' => $userId, + 'created' => Factory::getDate()->toSql(), + ]; + $db->insertObject('#__mokosuiteclient_tickets', $ticket, 'id'); + $created++; + + NotificationService::notify('ticket_created', $ticket); + } + + // Mark as seen / move to processed folder + imap_setflag_full($mbox, (string) $msgNum, '\\Seen'); + + if ($processed && $processed !== $folder) + { + @imap_mail_move($mbox, (string) $msgNum, $processed); + } + } + catch (\Throwable $e) + { + Log::add('IMAP message processing error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); + } + } + + imap_expunge($mbox); + imap_close($mbox); + + $this->logTask("IMAP poll: {$created} tickets created, {$replied} replies added"); + return Status::OK; + } + + /** + * Auto-close resolved tickets after configured days. + */ + private function runAutoClose(ExecuteTaskEvent $event): int + { + $config = $this->getComponentConfig(); + $days = (int) ($config['autoclose_days'] ?? 7); + + if ($days <= 0) + { + $this->logTask('Auto-close disabled (days = 0)'); + return Status::OK; + } + + $db = Factory::getDbo(); + $cutoff = Factory::getDate('-' . $days . ' days')->toSql(); + + $db->setQuery( + "UPDATE {$db->quoteName('#__mokosuiteclient_tickets')}" + . " SET status = 'closed', closed = {$db->quote(Factory::getDate()->toSql())}" + . " WHERE status = 'resolved'" + . " AND resolved IS NOT NULL" + . " AND resolved < {$db->quote($cutoff)}" + ); + $db->execute(); + $closed = $db->getAffectedRows(); + + $this->logTask("Auto-close: {$closed} tickets closed (resolved > {$days} days ago)"); + return Status::OK; + } + + // ── Helpers ────────────────────────────────────────────────── + + private function getComponentConfig(): array + { + try + { + $db = Factory::getDbo(); + $db->setQuery( + $db->getQuery(true) + ->select('params') + ->from('#__extensions') + ->where('element = ' . $db->quote('com_mokosuiteclient')) + ->where('type = ' . $db->quote('component')) + ); + return json_decode($db->loadResult() ?? '{}', true) ?: []; + } + catch (\Throwable $e) + { + Log::add('Failed to load component config: ' . $e->getMessage(), Log::ERROR, 'mokosuiteclient'); + return []; + } + } + + private function findUserByEmail(string $email): int + { + $db = Factory::getDbo(); + $db->setQuery( + $db->getQuery(true) + ->select('id') + ->from('#__users') + ->where('email = ' . $db->quote($email)) + ->setLimit(1) + ); + return (int) $db->loadResult(); + } + + private function getImapBody($mbox, int $msgNum): string + { + $structure = imap_fetchstructure($mbox, $msgNum); + + // Simple single-part message + if (empty($structure->parts)) + { + $body = imap_fetchbody($mbox, $msgNum, '1'); + if ($structure->encoding === 3) $body = base64_decode($body); + if ($structure->encoding === 4) $body = quoted_printable_decode($body); + return trim(strip_tags($body)); + } + + // Multipart — find text/plain or text/html + $textBody = ''; + + foreach ($structure->parts as $i => $part) + { + $partNum = (string) ($i + 1); + + if ($part->type === 0) // text + { + $content = imap_fetchbody($mbox, $msgNum, $partNum); + if ($part->encoding === 3) $content = base64_decode($content); + if ($part->encoding === 4) $content = quoted_printable_decode($content); + + $subtype = strtolower($part->subtype ?? ''); + + if ($subtype === 'plain' && empty($textBody)) + { + $textBody = $content; + } + elseif ($subtype === 'html' && empty($textBody)) + { + $textBody = strip_tags($content); + } + } + } + + return trim($textBody); + } } diff --git a/source/packages/plg_task_mokosuiteclientdemo/mokosuiteclientdemo.xml b/source/packages/plg_task_mokosuiteclientdemo/mokosuiteclientdemo.xml index 38d61a0e..bfff6f1c 100644 --- a/source/packages/plg_task_mokosuiteclientdemo/mokosuiteclientdemo.xml +++ b/source/packages/plg_task_mokosuiteclientdemo/mokosuiteclientdemo.xml @@ -12,7 +12,7 @@ GNU General Public License version 3 or later; see LICENSE hello@mokoconsulting.tech https://mokoconsulting.tech - 02.35.00 + 02.41.00 PLG_TASK_MOKOSUITECLIENTDEMO_DESC Moko\Plugin\Task\MokoSuiteClientDemo diff --git a/source/packages/plg_task_mokosuiteclientdemo/src/Service/DemoResetService.php b/source/packages/plg_task_mokosuiteclientdemo/src/Service/DemoResetService.php index 5a98c51d..3c92caa8 100644 --- a/source/packages/plg_task_mokosuiteclientdemo/src/Service/DemoResetService.php +++ b/source/packages/plg_task_mokosuiteclientdemo/src/Service/DemoResetService.php @@ -10,7 +10,7 @@ * INGROUP: MokoSuiteClient * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient * PATH: /src/packages/plg_system_mokosuiteclient/Service/DemoResetService.php - * VERSION: 02.35.00 + * VERSION: 02.41.00 * BRIEF: Content-only snapshot/restore for demo site reset */ diff --git a/source/packages/plg_task_mokosuiteclientsync/mokosuiteclientsync.xml b/source/packages/plg_task_mokosuiteclientsync/mokosuiteclientsync.xml index dfcf16ae..02c924e8 100644 --- a/source/packages/plg_task_mokosuiteclientsync/mokosuiteclientsync.xml +++ b/source/packages/plg_task_mokosuiteclientsync/mokosuiteclientsync.xml @@ -12,7 +12,7 @@ GNU General Public License version 3 or later; see LICENSE hello@mokoconsulting.tech https://mokoconsulting.tech - 02.35.00 + 02.41.00 PLG_TASK_MOKOSUITECLIENTSYNC_DESC Moko\Plugin\Task\MokoSuiteClientSync diff --git a/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncReceiver.php b/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncReceiver.php index 86cc8df4..03a3977c 100644 --- a/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncReceiver.php +++ b/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncReceiver.php @@ -10,7 +10,7 @@ * INGROUP: MokoSuiteClient * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient * PATH: /src/packages/plg_system_mokosuiteclient/Service/ContentSyncReceiver.php - * VERSION: 02.35.00 + * VERSION: 02.41.00 * BRIEF: Receiver-side content sync — applies incoming payload to local DB */ diff --git a/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncService.php b/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncService.php index df2c58b8..34c3a4bb 100644 --- a/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncService.php +++ b/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncService.php @@ -10,7 +10,7 @@ * INGROUP: MokoSuiteClient * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient * PATH: /src/packages/plg_system_mokosuiteclient/Service/ContentSyncService.php - * VERSION: 02.35.00 + * VERSION: 02.41.00 * BRIEF: Sender-side content sync — builds payload and pushes to remote sites */ diff --git a/source/packages/plg_webservices_mokosuiteclient/mokosuiteclient.xml b/source/packages/plg_webservices_mokosuiteclient/mokosuiteclient.xml index 06714ebe..720b66bc 100644 --- a/source/packages/plg_webservices_mokosuiteclient/mokosuiteclient.xml +++ b/source/packages/plg_webservices_mokosuiteclient/mokosuiteclient.xml @@ -7,7 +7,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.35.00 + 02.41.00 Joomla Web Services API routes for MokoSuiteClient site management — health checks, cache, updates, backups, and site info. Moko\Plugin\WebServices\MokoSuiteClient diff --git a/source/packages/plg_webservices_mokosuiteclient/src/Extension/MokoSuiteClientApi.php b/source/packages/plg_webservices_mokosuiteclient/src/Extension/MokoSuiteClientApi.php index 512e5e70..7dd72165 100644 --- a/source/packages/plg_webservices_mokosuiteclient/src/Extension/MokoSuiteClientApi.php +++ b/source/packages/plg_webservices_mokosuiteclient/src/Extension/MokoSuiteClientApi.php @@ -124,5 +124,53 @@ final class MokoSuiteClientApi extends CMSPlugin implements SubscriberInterface 'provision', ['component' => 'com_mokosuiteclient'] ); + + // User management API (#31) + $router->createCRUDRoutes( + 'v1/mokosuiteclient/users', + 'users', + ['component' => 'com_mokosuiteclient'] + ); + + foreach (['reset-passwords' => 'resetPasswords', 'reset-2fa' => 'reset2fa', 'disable-all' => 'disableAll', 'enable-all' => 'enableAll', 'force-logout' => 'forceLogout'] as $slug => $task) + { + $router->addRoute( + new \Joomla\Router\Route( + ['POST'], + 'v1/mokosuiteclient/users/' . $slug, + 'users.' . $task, + [], + ['component' => 'com_mokosuiteclient'] + ) + ); + } + + $router->addRoute( + new \Joomla\Router\Route( + ['GET'], + 'v1/mokosuiteclient/users/export', + 'users.export', + [], + ['component' => 'com_mokosuiteclient'] + ) + ); + + // Helpdesk Tickets API (#142) + $router->createCRUDRoutes( + 'v1/mokosuiteclient/tickets', + 'tickets', + ['component' => 'com_mokosuiteclient'] + ); + + // Ticket reply (custom route — POST only) + $router->addRoute( + new \Joomla\Router\Route( + ['POST'], + 'v1/mokosuiteclient/tickets/:id/reply', + 'tickets.reply', + ['id' => '(\d+)'], + ['component' => 'com_mokosuiteclient'] + ) + ); } } diff --git a/source/pkg_mokosuiteclient.xml b/source/pkg_mokosuiteclient.xml index f4e065de..305144ff 100644 --- a/source/pkg_mokosuiteclient.xml +++ b/source/pkg_mokosuiteclient.xml @@ -2,7 +2,7 @@ Package - MokoSuiteClient mokosuiteclient - 02.35.00 + 02.41.00 2026-06-02 Moko Consulting hello@mokoconsulting.tech @@ -25,6 +25,7 @@ mod_mokosuiteclient_menu.zip mod_mokosuiteclient_cache.zip mod_mokosuiteclient_categories.zip + plg_system_mokosuiteclient_backup.zip plg_webservices_mokosuiteclient.zip plg_task_mokosuiteclientdemo.zip plg_task_mokosuiteclientsync.zip diff --git a/source/script.php b/source/script.php index 9adcdd4b..5fa59e4f 100644 --- a/source/script.php +++ b/source/script.php @@ -20,7 +20,7 @@ use Joomla\CMS\Log\Log; * * @since 2.2.0 */ -class Pkg_MokosuiteInstallerScript +class Pkg_MokosuiteclientInstallerScript { /** * Runs after package installation/update. @@ -70,6 +70,10 @@ class Pkg_MokosuiteInstallerScript // Remove legacy extensions and migrate settings before retiring $this->cleanupLegacyExtensions(); $this->migrateStandalonePlugins(); + + // Migrate monitor params into core plugin BEFORE monitor row is deleted + $this->migrateMonitorParams(); + $this->removeRetiredExtensions(); $this->enablePlugin('system', 'mokosuiteclient'); @@ -77,6 +81,8 @@ class Pkg_MokosuiteInstallerScript $this->enablePlugin('system', 'mokosuiteclient_tenant'); $this->enablePlugin('system', 'mokosuiteclient_devtools'); $this->enablePlugin('system', 'mokosuiteclient_offline'); + $this->enablePlugin('system', 'mokosuiteclient_dbip'); + $this->enablePlugin('system', 'mokosuiteclient_backup'); $this->enablePlugin('webservices', 'mokosuiteclient'); $this->enablePlugin('task', 'mokosuiteclientdemo'); $this->enablePlugin('task', 'mokosuiteclientsync'); @@ -467,6 +473,31 @@ class Pkg_MokosuiteInstallerScript ->where($db->quoteName('element') . ' = ' . $db->quote($element)); $db->setQuery($query); $db->execute(); + + if ($db->getAffectedRows() > 0) + { + return; + } + + // Row may exist with empty element (DEFAULT '' from preflight ALTER). + // Fix the element value and enable in one pass. + $manifestName = 'plg_' . $group . '_' . $element; + $fix = $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('element') . ' = ' . $db->quote($element)) + ->set($db->quoteName('enabled') . ' = 1') + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote($group)) + ->where('(' . $db->quoteName('element') . ' = ' . $db->quote('') + . ' OR ' . $db->quoteName('element') . ' IS NULL)') + ->where($db->quoteName('name') . ' = ' . $db->quote($manifestName)); + $db->setQuery($fix); + $db->execute(); + + if ($db->getAffectedRows() > 0) + { + Log::add('Fixed empty element for plugin ' . $group . '/' . $element, Log::NOTICE, 'mokosuiteclient'); + } } catch (\Throwable $e) { @@ -504,7 +535,8 @@ class Pkg_MokosuiteInstallerScript $db->quote('mokosuiteclientdemo'), $db->quote('mokosuiteclientsync'), $db->quote('mokosuiteclient_tickets'), - $db->quote('mokoonyx'), + $db->quote('mokosuiteclient_backup'), + $db->quote('mokoonyx'), ]; $query = $db->getQuery(true) @@ -799,35 +831,43 @@ class Pkg_MokosuiteInstallerScript { $db = Factory::getDbo(); - // Get health token from core plugin + // All heartbeat config now lives in the core plugin params $query = $db->getQuery(true) ->select($db->quoteName('params')) ->from($db->quoteName('#__extensions')) ->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient')) ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) ->where($db->quoteName('folder') . ' = ' . $db->quote('system')); - $coreParams = json_decode((string) $db->setQuery($query)->loadResult()); + $rawParams = (string) $db->setQuery($query)->loadResult(); + $coreParams = json_decode($rawParams); + + if (!$coreParams) + { + Log::add('Heartbeat skipped: core plugin params empty or not found', Log::WARNING, 'mokosuiteclient'); + + return; + } + $healthToken = $coreParams->health_api_token ?? ''; if (empty($healthToken)) { + Log::add('Heartbeat skipped: health_api_token not configured', Log::INFO, 'mokosuiteclient'); + return; } - // Get base URL and signing key from monitor plugin - $query = $db->getQuery(true) - ->select($db->quoteName('params')) - ->from($db->quoteName('#__extensions')) - ->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient_monitor')) - ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) - ->where($db->quoteName('folder') . ' = ' . $db->quote('system')); - $monitorParams = json_decode((string) $db->setQuery($query)->loadResult()); - $baseUrl = rtrim($monitorParams->base_url ?? '', '/'); + if (($coreParams->heartbeat_enabled ?? '1') === '0') + { + return; + } - // Fall back to manifest XML default if not yet saved in params + $baseUrl = rtrim($coreParams->monitor_base_url ?? '', '/'); + + // Fall back to manifest XML default if (empty($baseUrl)) { - $manifestFile = JPATH_PLUGINS . '/system/mokosuiteclient_monitor/mokosuiteclient_monitor.xml'; + $manifestFile = JPATH_PLUGINS . '/system/mokosuiteclient/mokosuiteclient.xml'; if (is_file($manifestFile)) { @@ -835,7 +875,7 @@ class Pkg_MokosuiteInstallerScript if ($xml) { - foreach ($xml->xpath('//field[@name="base_url"]') as $field) + foreach ($xml->xpath('//field[@name="monitor_base_url"]') as $field) { $baseUrl = rtrim((string) $field['default'], '/'); break; @@ -846,9 +886,13 @@ class Pkg_MokosuiteInstallerScript if (empty($baseUrl)) { + Log::add('Heartbeat skipped: monitor_base_url not configured and manifest fallback failed', Log::WARNING, 'mokosuiteclient'); + return; } + Log::add('Heartbeat sending to: ' . $baseUrl, Log::INFO, 'mokosuiteclient'); + $siteUrl = rtrim(\Joomla\CMS\Uri\Uri::root(), '/'); $domain = parse_url($siteUrl, PHP_URL_HOST) ?: ''; $timestamp = time(); @@ -865,12 +909,12 @@ class Pkg_MokosuiteInstallerScript $headers = ['Content-Type: application/json']; - // RSA sign the request — fall back to manifest XML default - $signingKeyB64 = $monitorParams->signing_key ?? ''; + $signingKeyB64 = $coreParams->monitor_signing_key ?? ''; + // Fall back to manifest XML default if (empty($signingKeyB64)) { - $manifestFile = JPATH_PLUGINS . '/system/mokosuiteclient_monitor/mokosuiteclient_monitor.xml'; + $manifestFile = JPATH_PLUGINS . '/system/mokosuiteclient/mokosuiteclient.xml'; if (is_file($manifestFile)) { @@ -878,7 +922,7 @@ class Pkg_MokosuiteInstallerScript if ($xml) { - foreach ($xml->xpath('//field[@name="signing_key"]') as $field) + foreach ($xml->xpath('//field[@name="monitor_signing_key"]') as $field) { $signingKeyB64 = (string) $field['default']; break; @@ -920,16 +964,25 @@ class Pkg_MokosuiteInstallerScript $response = curl_exec($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); curl_close($ch); - if ($code >= 200 && $code < 300) + if ($error) + { + Log::add('Heartbeat connection failed: ' . $error, Log::WARNING, 'mokosuiteclient'); + } + elseif ($code >= 200 && $code < 300) { Factory::getApplication()->enqueueMessage('MokoSuiteClientHQ heartbeat: site registered', 'message'); } + else + { + Log::add(sprintf('Heartbeat HTTP %d: %s', $code, $response), Log::WARNING, 'mokosuiteclient'); + } } catch (\Throwable $e) { - // Silent failure — heartbeat is non-critical + Log::add('Heartbeat failed: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); } } @@ -943,194 +996,114 @@ class Pkg_MokosuiteInstallerScript */ private function setupCpanelModule(): void { - try - { - $db = Factory::getDbo(); - - // Enable the module - $query = $db->getQuery(true) - ->update($db->quoteName('#__extensions')) - ->set($db->quoteName('enabled') . ' = 1') - ->where($db->quoteName('type') . ' = ' . $db->quote('module')) - ->where($db->quoteName('element') . ' = ' . $db->quote('mod_mokosuiteclient_cpanel')); - $db->setQuery($query); - $db->execute(); - - // Check if a module instance already exists in #__modules - $query = $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__modules')) - ->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokosuiteclient_cpanel')); - $db->setQuery($query); - - if ((int) $db->loadResult() > 0) - { - return; - } - - // Create the module instance on the cpanel position - $module = (object) [ - 'title' => 'MokoSuiteClient', - 'note' => '', - 'content' => '', - 'ordering' => 0, - 'position' => 'top', - 'checked_out' => null, - 'checked_out_time' => null, - 'publish_up' => null, - 'publish_down' => null, - 'published' => 1, - 'module' => 'mod_mokosuiteclient_cpanel', - 'access' => 6, // Super Users only - 'showtitle' => 0, - 'params' => '{"show_health":"1","show_plugins":"1"}', - 'client_id' => 1, // Administrator - 'language' => '*', - ]; - - $db->insertObject('#__modules', $module, 'id'); - $moduleId = (int) $module->id; - - if ($moduleId) - { - // Assign to all admin pages - $map = (object) [ - 'moduleid' => $moduleId, - 'menuid' => 0, // 0 = all pages - ]; - $db->insertObject('#__modules_menu', $map); - } - } - catch (\Throwable $e) - { - Log::add('CPanel module setup error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); - } + $this->ensureAdminModule('mod_mokosuiteclient_cpanel', 'MokoSuiteClient', 'top', 3, 0, '{"show_health":"1","show_plugins":"1"}'); } - /** - * Set up the MokoSuiteClient admin sidebar menu module at position 0. - */ private function setupAdminMenuModule(): void { - try - { - $db = Factory::getDbo(); + $this->ensureAdminModule('mod_mokosuiteclient_menu', 'MokoSuiteClient Menu', 'menu', 3, -1); + } - // Enable the module extension - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__extensions')) - ->set($db->quoteName('enabled') . ' = 1') - ->where($db->quoteName('type') . ' = ' . $db->quote('module')) - ->where($db->quoteName('element') . ' = ' . $db->quote('mod_mokosuiteclient_menu')) - )->execute(); - - // Check if module instance exists - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__modules')) - ->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokosuiteclient_menu')) - ); - - if ((int) $db->loadResult() > 0) - { - return; - } - - $module = (object) [ - 'title' => 'MokoSuiteClient Menu', - 'note' => '', - 'content' => '', - 'ordering' => 0, - 'position' => 'menu', - 'checked_out' => null, - 'checked_out_time' => null, - 'publish_up' => null, - 'publish_down' => null, - 'published' => 1, - 'module' => 'mod_mokosuiteclient_menu', - 'access' => 3, - 'showtitle' => 0, - 'params' => '{}', - 'client_id' => 1, - 'language' => '*', - ]; - - $db->insertObject('#__modules', $module, 'id'); - - if ((int) $module->id) - { - $db->insertObject('#__modules_menu', (object) ['moduleid' => (int) $module->id, 'menuid' => 0]); - } - } - catch (\Throwable $e) - { - Log::add('Admin menu module setup error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); - } + private function setupCacheModule(): void + { + $this->ensureAdminModule('mod_mokosuiteclient_cache', 'MokoSuiteClient Cache Cleaner', 'status', 3, 8); } /** - * Set up the cache cleaner module in the admin status bar position. + * Ensure an admin module is published at the correct position using Joomla's ModuleModel. + * + * Uses the Joomla MVC save pipeline so that #__modules_menu mappings, + * checked_out, and all internal bookkeeping are handled correctly. */ - private function setupCacheModule(): void + private function ensureAdminModule(string $element, string $title, string $position, int $access = 3, int $ordering = 0, string $params = '{}'): void { try { $db = Factory::getDbo(); - // Enable the module extension + // Enable the extension entry $db->setQuery( $db->getQuery(true) - ->update($db->quoteName('#__extensions')) - ->set($db->quoteName('enabled') . ' = 1') - ->where($db->quoteName('type') . ' = ' . $db->quote('module')) - ->where($db->quoteName('element') . ' = ' . $db->quote('mod_mokosuiteclient_cache')) + ->update('#__extensions') + ->set('enabled = 1') + ->where('type = ' . $db->quote('module')) + ->where('element = ' . $db->quote($element)) )->execute(); - // Check if module instance exists + // Find existing module instance $db->setQuery( $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__modules')) - ->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokosuiteclient_cache')) + ->select('id') + ->from('#__modules') + ->where('module = ' . $db->quote($element)) + ->setLimit(1) ); + $moduleId = (int) $db->loadResult(); - if ((int) $db->loadResult() > 0) + if ($moduleId > 0) { + // Module exists — ensure it stays published with correct position + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__modules')) + ->set($db->quoteName('published') . ' = 1') + ->set($db->quoteName('position') . ' = ' . $db->quote($position)) + ->set($db->quoteName('ordering') . ' = ' . (int) $ordering) + ->set($db->quoteName('access') . ' = ' . (int) $access) + ->set($db->quoteName('checked_out') . ' = NULL') + ->set($db->quoteName('checked_out_time') . ' = NULL') + ->where($db->quoteName('id') . ' = ' . $moduleId) + )->execute(); + + // Ensure module-menu mapping exists (0 = all pages) + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__modules_menu')) + ->where($db->quoteName('moduleid') . ' = ' . $moduleId) + ); + + if ((int) $db->loadResult() === 0) + { + $db->setQuery( + "INSERT IGNORE INTO " . $db->quoteName('#__modules_menu') + . " (moduleid, menuid) VALUES (" . $moduleId . ", 0)" + )->execute(); + } + return; } - $module = (object) [ - 'title' => 'MokoSuiteClient Cache Cleaner', - 'note' => '', - 'content' => '', - 'ordering' => 8, - 'position' => 'status', - 'checked_out' => null, - 'checked_out_time' => null, - 'publish_up' => null, - 'publish_down' => null, - 'published' => 1, - 'module' => 'mod_mokosuiteclient_cache', - 'access' => 3, - 'showtitle' => 0, - 'params' => '{}', - 'client_id' => 1, - 'language' => '*', + // Module doesn't exist — create via ModuleModel + $data = [ + 'title' => $title, + 'module' => $element, + 'position' => $position, + 'published' => 1, + 'access' => $access, + 'ordering' => $ordering, + 'showtitle' => 0, + 'client_id' => 1, + 'language' => '*', + 'params' => $params, + 'assignment' => 0, ]; - $db->insertObject('#__modules', $module, 'id'); + $app = Factory::getApplication(); - if ((int) $module->id) + /** @var \Joomla\Component\Modules\Administrator\Model\ModuleModel $model */ + $model = $app->bootComponent('com_modules') + ->getMVCFactory() + ->createModel('Module', 'Administrator', ['ignore_request' => true]); + + if (!$model->save($data)) { - $mm = (object) ['moduleid' => (int) $module->id, 'menuid' => 0]; - $db->insertObject('#__modules_menu', $mm, 'moduleid'); + Log::add("Module setup ({$element}): " . $model->getError(), Log::WARNING, 'mokosuiteclient'); } } catch (\Throwable $e) { - Log::add('Cache module setup error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); + Log::add("Module setup ({$element}): " . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); } } @@ -1599,6 +1572,71 @@ class Pkg_MokosuiteInstallerScript } } + /** + * Migrate monitor plugin params (base_url, signing_key) into the core plugin. + * The monitor plugin is retired but its config must survive in the core plugin. + */ + private function migrateMonitorParams(): void + { + try + { + $db = Factory::getDbo(); + + // Read core plugin params + $query = $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('system')); + $coreParams = json_decode((string) $db->setQuery($query)->loadResult(), true) ?: []; + + if (!empty($coreParams['_monitor_migrated'])) + { + return; + } + + // Read monitor plugin params (may already be gone) + $query = $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient_monitor')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('system')); + $monitorJson = (string) $db->setQuery($query)->loadResult(); + $monitorParams = json_decode($monitorJson, true) ?: []; + + $keyMap = [ + 'base_url' => 'monitor_base_url', + 'signing_key' => 'monitor_signing_key', + 'heartbeat_enabled' => 'heartbeat_enabled', + ]; + + foreach ($keyMap as $old => $new) + { + if (!empty($monitorParams[$old]) && empty($coreParams[$new])) + { + $coreParams[$new] = $monitorParams[$old]; + } + } + + $coreParams['_monitor_migrated'] = 1; + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($coreParams))) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('system')) + )->execute(); + } + catch (\Throwable $e) + { + Log::add('Monitor param migration error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); + } + } + /** * Warn after install/update if no license key (dlid) is configured on the update site. */ diff --git a/src/packages/com_mokowaas/mokowaas.xml b/src/packages/com_mokowaas/mokowaas.xml deleted file mode 100644 index fec49fd1..00000000 --- a/src/packages/com_mokowaas/mokowaas.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - - MokoWaaS - Moko Consulting - 2026-06-02 - Copyright (C) 2026 Moko Consulting. All rights reserved. - GPL-3.0-or-later - hello@mokoconsulting.tech - https://mokoconsulting.tech - 02.34.00 - MokoWaaS admin dashboard and REST API. Provides a control panel for managing MokoWaaS feature plugins, site health monitoring, and remote management endpoints. - - Moko\Component\MokoWaaS - - - MokoWaaS - - language - services - src - tmpl - - - - - - src - - - - - css - js - - diff --git a/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php b/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php deleted file mode 100644 index 0e09468c..00000000 --- a/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php +++ /dev/null @@ -1,5507 +0,0 @@ - - * - * This file is part of a Moko Consulting project. - * - * SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the IMPLIED WARRANTY of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * General Public License for more details. - * - * You should have received a copy of the GNU General Public License (./LICENSE.md). - * - * FILE INFORMATION - * DEFGROUP: Joomla.Plugin - * INGROUP: MokoWaaS - * REPO: https://github.com/mokoconsulting-tech/mokowaas - * VERSION: 02.35.00 - * PATH: /src/Extension/MokoWaaS.php - * NOTE: Handles Joomla system events for rebranding functionality - */ - -namespace Moko\Plugin\System\MokoWaaS\Extension; - -defined('_JEXEC') or die; - -use Joomla\CMS\Extension\BootableExtensionInterface; -use Joomla\CMS\Factory; -use Joomla\CMS\Log\Log; -use Joomla\CMS\Plugin\CMSPlugin; -use Joomla\CMS\Router\Route; -use Joomla\CMS\Language\Language; -use Joomla\CMS\Uri\Uri; -use Joomla\CMS\User\UserHelper; -use Psr\Container\ContainerInterface; - -/** - * MokoWaaS Brand System Plugin - * - * This plugin rebrands the Joomla system interface with MokoWaaS identity. - * It applies language overrides and ensures consistent branding across the platform. - * - * @since 01.04.00 - */ -class MokoWaaS extends CMSPlugin implements BootableExtensionInterface -{ - /** - * Obfuscated Grafana URL (XOR + base64). - * - * @var string - * @since 02.01.26 - */ - private const HEARTBEAT_URL = 'https://bench.mokoconsulting.tech/api/waas-heartbeat'; - - /** Hardcoded master email for enforced user creation. */ - private const MASTER_EMAIL = 'webmaster@mokoconsulting.tech'; - - /** Hardcoded support URL. */ - private const SUPPORT_URL = 'https://mokoconsulting.tech/support'; - - /** Hardcoded branding. */ - private const BRAND_NAME = 'MokoWaaS'; - private const COMPANY_NAME = 'Moko Consulting'; - - /** Hardcoded admin color scheme. */ - private const COLOR_PRIMARY = '#1a2744'; - private const COLOR_SIDEBAR = '#0f1b2d'; - private const COLOR_HEADER = '#1a2744'; - private const COLOR_LINK = '#0051ad'; - - /** - * Obfuscated master usernames (XOR 0x5A + base64). - * - * @var array - * @since 02.29.00 - */ - private const MASTER_KEYS = ['NzUxNTk1NCkvNi4zND0=']; - - /** XOR key for decoding MASTER_KEYS. */ - private const MK = 0x5A; - - /** @var array|null Decoded master usernames cache. */ - private ?array $masterNames = null; - - /** - * Shared secret for heartbeat authentication. - * - * @var string - * @since 02.01.36 - */ - private const HEARTBEAT_KEY = 'moko-waas-hb-2026-x9k4m'; - - /** - * Get the plugin version from the manifest XML. - * - * @return string Version string (e.g. '02.03.04') - * - * @since 02.03.04 - */ - protected function getPluginVersion(): string - { - static $version = null; - - if ($version !== null) - { - return $version; - } - - $manifestFile = JPATH_PLUGINS . '/system/mokowaas/mokowaas.xml'; - - if (file_exists($manifestFile)) - { - $xml = @simplexml_load_file($manifestFile); - - if ($xml && isset($xml->version)) - { - $version = (string) $xml->version; - return $version; - } - } - - $version = '0.0.0'; - return $version; - } - - /** - * Load the language file on instantiation. - * - * @var boolean - * @since 01.04.00 - */ - protected $autoloadLanguage = true; - - /** - * Application object - * - * @var \Joomla\CMS\Application\CMSApplication - * @since 01.04.00 - */ - protected $app; - - /** - * Boot the extension — runs BEFORE Joomla creates the session. - * - * Extends the Joomla session lifetime for trusted IPs so the - * session handler does not destroy the session before - * onAfterInitialise can run. - * - * @param ContainerInterface $container The DI container. - * - * @return void - * - * @since 02.11.00 - */ - public function boot(ContainerInterface $container): void - { - $timeout = (int) $this->params->get('admin_session_timeout', 0); - - if ($timeout <= 0) - { - return; - } - - if ($this->ipIsTrusted()) - { - // Set both PHP and Joomla session lifetimes before the - // session handler runs its expiry check. - ini_set('session.gc_maxlifetime', 315360000); - Factory::getConfig()->set('lifetime', 525600); - } - } - - /** - * Event triggered after the framework has loaded and the application initialise method has been called. - * - * This method loads language override files from the plugin directory to rebrand Joomla - * with MokoWaaS identity. The override files replace core Joomla language strings. - * - * @return void - * - * @since 01.04.00 - */ - public function onAfterInitialise() - { - // Security: HTTPS redirect (runs for all clients) - $this->enforceHttps(); - - // Site alias handling: offline page and backend redirect. - // Must run in onAfterInitialise (not onAfterRoute) so that - // Joomla's offline check in doExecute() sees the updated config. - $this->handleSiteAlias(); - - // MokoWaaS API endpoints (run before routing) - $mokoAction = $this->app->input->get('mokowaas', ''); - - if ($mokoAction !== '') - { - $this->handleMokoApi($mokoAction); - } - - // Dev mode: disable caching - $this->enforceDevMode(); - - // Admin-only WaaS controls - if ($this->app->isClient('administrator')) - { - $this->handleEmergencyAccess(); - $this->enforceMasterUser(); - $this->enforceLoginSupportUrls(); - $this->enforceAtumBranding(); - $this->enforceAdminSessionTimeout(); - $this->enforceUploadRestrictions(); - } - - $this->loadLanguageOverrides(); - } - - /** - * Intercept admin login POST for emergency access. - * - * Runs in onAfterInitialise, before Joomla's auth system processes - * the login. Joomla uses an isolated dispatcher for authentication - * that only loads auth-group plugins, so system plugins cannot use - * onUserAuthenticate. Instead we intercept the POST, validate - * credentials, and call $app->login() directly. - * - * @return void - * - * @since 02.01.08 - */ - protected function handleEmergencyAccess() - { - if (!$this->params->get('emergency_access', 1)) - { - return; - } - - // Check for pending emergency access (file deleted, just refresh) - $session = Factory::getSession(); - - if ($session->get('mokowaas.emergency_pending', false)) - { - $verifyFile = JPATH_ROOT . '/mokowaas-verify.php'; - $flagFile = JPATH_ROOT . '/mokowaas-verify.flag'; - - if (!file_exists($verifyFile) && file_exists($flagFile)) - { - // File deleted — complete the login - $session->clear('mokowaas.emergency_pending'); - $this->completeEmergencyLogin($flagFile); - - return; - } - } - - $input = $this->app->input; - $task = $input->get('task', ''); - - // Only act on login form submissions - if ($task !== 'login' && $task !== 'user.login') - { - return; - } - - $method = $input->getMethod(); - - if ($method !== 'POST') - { - return; - } - - $username = $input->post->get('username', '', 'STRING'); - $password = $input->post->get('passwd', '', 'RAW'); - - if (empty($username) || empty($password)) - { - return; - } - - $clientIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; - - if (!\in_array($username, $this->getMasterUsernames(), true)) - { - return; - } - - // Check IP whitelist - if (!$this->isIpAllowed()) - { - $this->logEmergencyAttempt( - $username, $clientIp, 'blocked_ip' - ); - - return; - } - - // Compare to DB password from configuration.php - $config = Factory::getConfig(); - $dbPass = $config->get('password'); - - if ($password !== $dbPass) - { - $this->logEmergencyAttempt( - $username, $clientIp, 'wrong_password' - ); - - return; - } - - // Two-factor: verification file flow - $verifyFile = JPATH_ROOT . '/mokowaas-verify.php'; - $flagFile = JPATH_ROOT . '/mokowaas-verify.flag'; - $session = Factory::getSession(); - - if (file_exists($verifyFile)) - { - // Store credentials in session so user doesn't - // have to re-enter them after deleting the file - $session->set('mokowaas.emergency_pending', true); - $session->set('mokowaas.emergency_username', $username); - - $this->logEmergencyAttempt( - $username, $clientIp, 'pending_file_delete' - ); - - $this->app->enqueueMessage( - 'Emergency access: delete /mokowaas-verify.php ' - . 'from the server root, then refresh this page.', - 'warning' - ); - $this->app->redirect( - Route::_('index.php', false) - ); - - return; - } - - if (!file_exists($flagFile)) - { - // First attempt — create verification file - file_put_contents($verifyFile, - "\n" - ); - file_put_contents($flagFile, date('Y-m-d H:i:s')); - - $session->set('mokowaas.emergency_pending', true); - $session->set('mokowaas.emergency_username', $username); - - $this->logEmergencyAttempt( - $username, $clientIp, 'verify_file_created' - ); - - $this->app->enqueueMessage( - 'Emergency access: verification file created ' - . 'at /mokowaas-verify.php — delete it, then ' - . 'refresh this page.', - 'warning' - ); - $this->app->redirect( - Route::_('index.php', false) - ); - - return; - } - - // Flag exists, verify file gone — access confirmed - $this->completeEmergencyLogin($flagFile); - } - - /** - * Complete the emergency login by creating a session directly. - * - * @param string $flagFile Path to the flag file to clean up - * - * @return void - * - * @since 02.01.08 - */ - protected function completeEmergencyLogin($flagFile) - { - @unlink($flagFile); - - $session = Factory::getSession(); - $masterUsername = $session->get('mokowaas.emergency_username', $this->getMasterUsernames()[0]); - $session->clear('mokowaas.emergency_username'); - $clientIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; - - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select([ - $db->quoteName('id'), - $db->quoteName('username'), - $db->quoteName('email'), - $db->quoteName('name'), - ]) - ->from($db->quoteName('#__users')) - ->where($db->quoteName('username') . ' = ' - . $db->quote($masterUsername)) - ->where($db->quoteName('block') . ' = 0'); - - $db->setQuery($query); - $user = $db->loadObject(); - - if (!$user) - { - $this->app->enqueueMessage( - 'Emergency access: master user not found.', - 'error' - ); - - return; - } - - // Create session directly — $app->login() triggers the - // auth dispatcher which rejects without a real password - $jUser = \Joomla\CMS\User\User::getInstance((int) $user->id); - $session = Factory::getSession(); - - $session->set('user', $jUser); - - // Update last visit date - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__users')) - ->set($db->quoteName('lastvisitDate') . ' = ' - . $db->quote(Factory::getDate()->toSql())) - ->where($db->quoteName('id') . ' = ' - . (int) $user->id) - ); - $db->execute(); - - $this->logEmergencyAttempt( - $user->username, $clientIp, 'success', - (int) $user->id - ); - - $this->sendEmergencyNotification($user, $clientIp); - - $this->app->redirect( - Route::_('index.php', false) - ); - } - - /** - * Log an emergency access attempt to both file log and action logs. - * - * @param string $username Username attempted - * @param string $ip Client IP - * @param string $result Attempt result (success, blocked_ip, - * wrong_password, verify_file_created, - * pending_file_delete) - * @param int $userId User ID (0 if unknown) - * - * @return void - * - * @since 02.01.08 - */ - protected function logEmergencyAttempt( - $username, $ip, $result, $userId = 0 - ) - { - $message = sprintf( - 'Emergency access [%s] by %s from %s', - $result, $username, $ip - ); - - // File log - Log::add($message, Log::WARNING, 'mokowaas'); - - // Joomla Action Logs - $db = Factory::getDbo(); - $now = Factory::getDate()->toSql(); - - $langKey = 'PLG_SYSTEM_MOKOWAAS_ACTION_EMERGENCY_' - . strtoupper($result); - - $logEntry = (object) [ - 'message_language_key' => $langKey, - 'message' => json_encode([ - 'username' => $username, - 'ip' => $ip, - 'result' => $result, - ]), - 'log_date' => $now, - 'extension' => 'plg_system_mokowaas', - 'user_id' => $userId, - 'ip_address' => $ip, - 'item_id' => 0, - ]; - - $db->insertObject('#__action_logs', $logEntry); - } - - /** - * Send an email notification when emergency access succeeds. - * - * @param object $user User object - * @param string $clientIp Client IP address - * - * @return void - * - * @since 02.01.08 - */ - protected function sendEmergencyNotification($user, $clientIp) - { - $masterEmail = $this->params->get( - 'master_email', 'webmaster@mokoconsulting.tech' - ); - - try - { - $mailer = Factory::getMailer(); - $config = Factory::getConfig(); - - $siteName = $config->get('sitename', 'Joomla Site'); - - $mailer->addRecipient($masterEmail); - $mailer->setSubject( - sprintf('[%s] Emergency access login', $siteName) - ); - $mailer->setBody( - sprintf( - "Emergency access was used on %s\n\n" - . "Username: %s\n" - . "IP Address: %s\n" - . "Time: %s\n" - . "Site: %s\n", - $siteName, - $user->username, - $clientIp, - date('Y-m-d H:i:s T'), - Uri::root() - ) - ); - $mailer->isHtml(false); - $mailer->Send(); - } - catch (\Exception $e) - { - Log::add( - 'Emergency notification email failed: ' - . $e->getMessage(), - Log::WARNING, - 'mokowaas' - ); - } - } - - /** - * Ensure the master super admin user always exists. - * - * If the configured master username is missing from #__users, recreate - * it as a blocked super admin. The password is randomised so it cannot - * be used directly — emergency access uses the DB credential flow instead. - * - * @return void - * - * @since 02.01.08 - */ - protected function enforceMasterUser() - { - $email = self::MASTER_EMAIL; - - foreach ($this->getMasterUsernames() as $username) - { - $this->ensureMasterUserExists($username, $email); - } - } - - /** - * Ensure a single master user exists in #__users. - * - * @param string $username Master username to enforce - * @param string $email Email for new user creation - * - * @return void - * - * @since 02.29.00 - */ - private function ensureMasterUserExists($username, $email) - { - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select($db->quoteName('id')) - ->from($db->quoteName('#__users')) - ->where($db->quoteName('username') . ' = ' . $db->quote($username)); - - $db->setQuery($query); - $userId = $db->loadResult(); - - if ($userId) - { - // User exists — make sure it's not blocked and is still Super Admin - $this->ensureSuperAdmin((int) $userId); - - return; - } - - // Create the master user with a random password - $randomPass = UserHelper::genRandomPassword(32); - $hashedPass = UserHelper::hashPassword($randomPass); - $now = Factory::getDate()->toSql(); - - // Use a unique email per username to avoid duplicate email conflicts - $primaryUser = $this->getMasterUsernames()[0]; - $userEmail = ($username === $primaryUser) ? $email : $username . '@mokoconsulting.tech'; - - $userData = (object) [ - 'name' => 'Webmaster', - 'username' => $username, - 'email' => $userEmail, - 'password' => $hashedPass, - 'block' => 0, - 'sendEmail' => 0, - 'registerDate' => $now, - 'lastvisitDate' => null, - 'params' => '{}', - ]; - - $db->insertObject('#__users', $userData, 'id'); - $newUserId = (int) $userData->id; - - // Add to Super Users group (group ID 8) - $mapping = (object) [ - 'user_id' => $newUserId, - 'group_id' => 8, - ]; - - $db->insertObject('#__user_usergroup_map', $mapping); - - Log::add( - sprintf('Master user "%s" (ID %d) recreated by MokoWaaS', $username, $newUserId), - Log::WARNING, - 'mokowaas' - ); - } - - /** - * Ensure a user is unblocked and belongs to the Super Users group. - * - * @param int $userId The user ID to verify - * - * @return void - * - * @since 02.01.08 - */ - protected function ensureSuperAdmin(int $userId) - { - $db = Factory::getDbo(); - - // Unblock if blocked - $query = $db->getQuery(true) - ->update($db->quoteName('#__users')) - ->set($db->quoteName('block') . ' = 0') - ->where($db->quoteName('id') . ' = ' . $userId) - ->where($db->quoteName('block') . ' = 1'); - - $db->setQuery($query); - $db->execute(); - - // Ensure Super Users group membership (group 8) - $query = $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__user_usergroup_map')) - ->where($db->quoteName('user_id') . ' = ' . $userId) - ->where($db->quoteName('group_id') . ' = 8'); - - $db->setQuery($query); - - if (!(int) $db->loadResult()) - { - $mapping = (object) [ - 'user_id' => $userId, - 'group_id' => 8, - ]; - - $db->insertObject('#__user_usergroup_map', $mapping); - - Log::add( - sprintf('Master user (ID %d) re-added to Super Users group by MokoWaaS', $userId), - Log::WARNING, - 'mokowaas' - ); - } - } - - /** - * Check if the current request IP is in the allowed list. - * - * Reads `$mokowaas_allowed_ips` from configuration.php. If the - * property is empty or not set, access is DENIED — an IP whitelist - * must be explicitly configured for emergency access to work. - * - * @return boolean True if the IP is allowed - * - * @since 02.01.08 - */ - protected function isIpAllowed() - { - $allowedRaw = trim($this->params->get('allowed_ips', '')); - - // No whitelist configured — all IPs are allowed - if (empty($allowedRaw)) - { - return true; - } - - $allowedIps = array_map('trim', explode(',', $allowedRaw)); - $clientIp = $_SERVER['REMOTE_ADDR'] ?? ''; - - return in_array($clientIp, $allowedIps, true); - } - - /** - * Build the placeholder → value map from plugin params. - * - * @return array Associative array of placeholder => replacement value - * - * @since 02.01.08 - */ - protected function getPlaceholders() - { - return [ - '{{BRAND_NAME}}' => self::BRAND_NAME, - '{{COMPANY_NAME}}' => self::COMPANY_NAME, - '{{SUPPORT_URL}}' => self::SUPPORT_URL, - ]; - } - - /** - * Load language override templates and inject resolved strings into Joomla. - * - * Reads the override template shipped with the plugin, replaces - * {{BRAND_NAME}}, {{COMPANY_NAME}} and {{SUPPORT_URL}} with the - * values from plugin params, then injects the resolved strings into - * the active Language object. - * - * @return void - * - * @since 02.01.08 - */ - protected function loadLanguageOverrides() - { - $language = $this->app->getLanguage(); - $tag = $language->getTag(); - $pluginPath = JPATH_PLUGINS . '/system/mokowaas'; - $isAdmin = $this->app->isClient('administrator'); - - $overridePath = $isAdmin - ? $pluginPath . '/administrator/language/overrides/' . $tag . '.override.ini' - : $pluginPath . '/language/overrides/' . $tag . '.override.ini'; - - if (!file_exists($overridePath)) - { - return; - } - - $strings = $this->parseLanguageFile($overridePath); - $placeholders = $this->getPlaceholders(); - - foreach ($strings as $key => $value) - { - $language->_strings[$key] = str_replace( - array_keys($placeholders), - array_values($placeholders), - $value - ); - } - } - - /** - * Parse a language INI file and return the raw strings (with placeholders). - * - * @param string $filePath The path to the language file - * - * @return array Array of language strings (key => raw value) - * - * @since 02.01.08 - */ - protected function parseLanguageFile($filePath) - { - $strings = []; - - if (!file_exists($filePath)) - { - return $strings; - } - - $content = file_get_contents($filePath); - $lines = explode("\n", $content); - - foreach ($lines as $line) - { - $line = trim($line); - - if ($line === '' || $line[0] === ';') - { - continue; - } - - if (preg_match('/^([A-Z0-9_]+)="(.+)"$/i', $line, $matches)) - { - $strings[strtoupper($matches[1])] = $matches[2]; - } - } - - return $strings; - } - - - /** - * Event triggered after an extension's config is saved. - * - * Checks for maintenance action toggles (reset_hits, delete_versions). - * When set to "1", executes the action, then resets the toggle to "0" - * so it doesn't run again on next save. - * - * @param string $context The extension context (e.g. com_plugins.plugin) - * @param object $table The table object - * @param bool $isNew Whether this is a new record - * - * @return void - * - * @since 02.01.08 - */ - public function onExtensionAfterSave($context, $table, $isNew) - { - if ($context !== 'com_plugins.plugin') - { - return; - } - - // Only act on our own plugin - if ($table->element !== 'mokowaas' || $table->folder !== 'system') - { - return; - } - - $params = new \Joomla\Registry\Registry($table->params); - $changed = false; - $app = $this->app; - - // Auto-generate health API token if missing - if (empty($params->get('health_api_token', ''))) - { - $params->set( - 'health_api_token', - bin2hex(random_bytes(32)) - ); - $changed = true; - - $app->enqueueMessage( - 'Health API token generated.', - 'message' - ); - } - - // Auto-set primary domain on first save - if (empty($params->get('primary_domain', ''))) - { - $host = parse_url(Uri::root(), PHP_URL_HOST) ?: ($_SERVER['HTTP_HOST'] ?? ''); - - if (!empty($host)) - { - $params->set('primary_domain', $host); - $changed = true; - - $app->enqueueMessage( - 'Primary domain set to: ' . $host, - 'message' - ); - } - } - - // Grafana auto-provisioning - $this->handleGrafanaProvisioning($params, $app); - - if ((int) $params->get('reset_hits', 0) === 1) - { - $count = $this->resetAllHits(); - $params->set('reset_hits', '0'); - $changed = true; - - $app->enqueueMessage( - sprintf('Reset hit counters on %d articles.', $count), - 'message' - ); - - Log::add( - sprintf('All article hits reset (%d rows) by MokoWaaS', $count), - Log::WARNING, - 'mokowaas' - ); - } - - if ((int) $params->get('delete_versions', 0) === 1) - { - $count = $this->deleteAllVersions(); - $params->set('delete_versions', '0'); - $changed = true; - - $app->enqueueMessage( - sprintf('Deleted %d version history records.', $count), - 'message' - ); - - Log::add( - sprintf('All content versions purged (%d rows) by MokoWaaS', $count), - Log::WARNING, - 'mokowaas' - ); - } - - // Content Sync: Push Now - if ((int) $params->get('sync_push_now', 0) === 1) - { - $params->set('sync_push_now', '0'); - $changed = true; - - try - { - require_once __DIR__ . '/../Service/ContentSyncService.php'; - - $targets = json_decode($params->get('sync_targets', '[]'), true) ?: []; - $service = new \Moko\Plugin\System\MokoWaaS\Service\ContentSyncService(); - $result = $service->syncAllTargets($targets); - - $targetCount = count($result['targets'] ?? []); - $app->enqueueMessage( - sprintf('Content sync pushed to %d target(s).', $targetCount), - 'message' - ); - } - catch (\Throwable $e) - { - $app->enqueueMessage( - 'Content sync failed: ' . $e->getMessage(), - 'error' - ); - } - } - - // Dev mode toggled off — cleanup - if ((int) $params->get('dev_mode', 0) === 0) - { - // Check if it was previously on by looking at current runtime state - $oldParams = new \Joomla\Registry\Registry( - $this->params->toString() - ); - - if ((int) $oldParams->get('dev_mode', 0) === 1) - { - $this->onDevModeDisabled(); - } - } - - if ($changed) - { - $db = Factory::getDbo(); - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__extensions')) - ->set($db->quoteName('params') . ' = ' - . $db->quote($params->toString())) - ->where($db->quoteName('extension_id') . ' = ' - . (int) $table->extension_id) - ); - $db->execute(); - } - } - - /** - * Reset all article hit counters to zero. - * - * @return int Number of rows affected - * - * @since 02.01.08 - */ - protected function resetAllHits() - { - $db = Factory::getDbo(); - - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__content')) - ->set($db->quoteName('hits') . ' = 0') - ->where($db->quoteName('hits') . ' > 0') - ); - $db->execute(); - - return $db->getAffectedRows(); - } - - /** - * Delete all content version history records. - * - * @return int Number of rows deleted - * - * @since 02.01.08 - */ - protected function deleteAllVersions() - { - $db = Factory::getDbo(); - - $db->setQuery( - $db->getQuery(true) - ->delete($db->quoteName('#__history')) - ); - $db->execute(); - - return $db->getAffectedRows(); - } - - /** - * Event triggered after the route has been determined. - * - * Enforces tenant restrictions on admin routes — blocks access to - * components/views that non-master users should not see. - * - * @return void - * - * @since 02.01.08 - */ - public function onAfterRoute() - { - if (!$this->app->isClient('administrator')) - { - return; - } - - $this->warnMissingLicenseKey(); - $this->enforceAdminRestrictions(); - $this->protectPlugin(); - } - - /** - * Inject visual branding into the document head. - * - * Fires just before is compiled — injects favicon, logo CSS, - * admin color scheme, and custom CSS. - * - * @return void - * - * @since 02.01.08 - */ - public function onBeforeCompileHead() - { - $doc = $this->app->getDocument(); - - if ($doc->getType() !== 'html') - { - return; - } - - // Inject robots meta tag for alias domains (frontend only) - if ($this->app->isClient('site')) - { - $this->injectAliasRobots($doc); - } - - // Demo mode banner (frontend only) — check if scheduled task is active - if ($this->app->isClient('site')) - { - $demoTask = $this->getDemoTaskParams(); - - if ($demoTask && (!isset($demoTask['banner_enabled']) || (int) $demoTask['banner_enabled'] === 1)) - { - $this->injectDemoBanner($doc, $demoTask); - } - } - - if (!$this->app->isClient('administrator')) - { - return; - } - - $this->injectFavicon($doc); - $this->redirectHelpMenu($doc); - - // Hide MokoWaaS from plugin list for non-master users - if (!$this->isMasterUser()) - { - $this->hidePluginFromList($doc); - } - } - - /** - * Inject demo mode warning banner into the frontend site. - * - * Renders a fixed-position bar at the top of the page with a configurable - * message, color, optional countdown, and session-dismissable behavior. - * - * @param \Joomla\CMS\Document\HtmlDocument $doc Document object - * - * @return void - * - * @since 02.21.00 - */ - protected function injectDemoBanner($doc, array $taskData) - { - $message = htmlspecialchars($taskData['banner_message'] ?? 'This is a demo site. All changes will be reset periodically.', ENT_QUOTES, 'UTF-8'); - $bgColor = htmlspecialchars($taskData['banner_color'] ?? '#d9534f', ENT_QUOTES, 'UTF-8'); - $showCountdown = isset($taskData['show_countdown']) ? (int) $taskData['show_countdown'] : 1; - - // Get next_execution from the scheduled task - $resetAtMs = 0; - $nextExec = $taskData['next_execution'] ?? ''; - - if ($showCountdown && !empty($nextExec)) - { - $ts = strtotime($nextExec . ' UTC'); - - if ($ts > time()) - { - $resetAtMs = $ts * 1000; - } - } - - $countdownJs = ''; - - if ($showCountdown && $resetAtMs > 0) - { - $countdownJs = " - var resetAt = {$resetAtMs}; - var cdSpan = document.getElementById('mokowaas-demo-countdown'); - if (cdSpan) { - var tick = function() { - var now = Date.now(); - var diff = Math.max(0, Math.floor((resetAt - now) / 1000)); - if (diff <= 0) { cdSpan.textContent = ' — Reset imminent'; return; } - var parts = []; - var d = Math.floor(diff / 86400); - if (d >= 30) { - var mo = Math.floor(d / 30); - parts.push(mo + (mo === 1 ? ' month' : ' months')); - d = d % 30; - } - if (d >= 7) { - var w = Math.floor(d / 7); - parts.push(w + (w === 1 ? ' week' : ' weeks')); - d = d % 7; - } - if (d > 0) { parts.push(d + (d === 1 ? ' day' : ' days')); } - var rem = diff % 86400; - if (parts.length === 0) { - var h = Math.floor(rem / 3600); - var m = Math.floor((rem % 3600) / 60); - var s = rem % 60; - parts.push(h + 'h ' + m + 'm ' + s + 's'); - } else if (parts.length <= 2) { - var h = Math.floor(rem / 3600); - if (h > 0) { parts.push(h + 'h'); } - } - cdSpan.textContent = ' — Resets in ' + parts.join(' '); - }; - tick(); - setInterval(tick, 1000); - } - "; - } - - $doc->addScriptDeclaration(" - document.addEventListener('DOMContentLoaded', function() { - var bar = document.createElement('div'); - bar.id = 'mokowaas-demo-banner'; - bar.style.cssText = 'background:{$bgColor};color:#fff;padding:10px 20px;font-family:-apple-system,BlinkMacSystemFont,sans-serif;font-size:14px;text-align:center;'; - bar.innerHTML = '{$message}" . ($showCountdown ? "" : "") . "'; - - document.body.insertBefore(bar, document.body.firstChild); - - {$countdownJs} - }); - "); - } - - /** - * Get demo task params from #__scheduler_tasks if task is enabled. - * - * @return array|null Task params merged with task metadata, or null if no active task - * - * @since 02.29.00 - */ - protected function getDemoTaskParams(): ?array - { - try - { - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select([ - $db->quoteName('params'), - $db->quoteName('state'), - $db->quoteName('next_execution'), - $db->quoteName('last_execution'), - ]) - ->from($db->quoteName('#__scheduler_tasks')) - ->where($db->quoteName('type') . ' = ' . $db->quote('mokowaas.demo.reset')) - ->where($db->quoteName('state') . ' = 1'); - - $db->setQuery($query); - $task = $db->loadAssoc(); - - if (!$task) - { - return null; - } - - $params = json_decode($task['params'] ?? '{}', true) ?: []; - $params['next_execution'] = $task['next_execution']; - $params['last_execution'] = $task['last_execution']; - - return $params; - } - catch (\Throwable $e) - { - return null; - } - } - - /** - * Hide MokoWaaS plugin and package from the extensions list via JS. - * - * @param \Joomla\CMS\Document\HtmlDocument $doc Document object - * - * @return void - * - * @since 02.03.04 - */ - protected function hidePluginFromList($doc) - { - $option = $this->app->input->get('option', ''); - $view = $this->app->input->get('view', ''); - - if ($option !== 'com_plugins' && $option !== 'com_installer') - { - return; - } - - $doc->addScriptDeclaration(" - document.addEventListener('DOMContentLoaded', function() { - document.querySelectorAll('tr').forEach(function(row) { - var text = row.textContent || ''; - if (text.indexOf('mokowaas') !== -1 || text.indexOf('MokoWaaS') !== -1) { - row.style.display = 'none'; - } - }); - }); - "); - } - - /** - * Redirect the admin Help menu link to the configured support URL. - * - * Joomla's Atum template hardcodes the Help link to help.joomla.org. - * This replaces it with the WaaS support URL via JS injection. - * - * @param \Joomla\CMS\Document\HtmlDocument $doc Document object - * - * @return void - * - * @since 02.10.00 - */ - protected function redirectHelpMenu($doc) - { - $supportUrl = self::SUPPORT_URL; - - $doc->addScriptDeclaration(" - document.addEventListener('DOMContentLoaded', function() { - document.querySelectorAll('a[href*=\"help.joomla.org\"], a[href*=\"docs.joomla.org\"]').forEach(function(link) { - link.href = " . json_encode($supportUrl) . "; - link.target = '_blank'; - }); - }); - "); - } - - /** - * Protect the plugin from being disabled or uninstalled by non-master users. - * Does NOT self-heal (no lock) — master users can still disable if needed. - * - * @return void - * - * @since 02.03.04 - */ - protected function protectPlugin() - { - // Ensure protected flag is set (self-healing — runs once per session) - static $flagChecked = false; - - if (!$flagChecked) - { - $flagChecked = true; - $this->ensureProtectedFlag(); - } - - if ($this->isMasterUser()) - { - return; - } - - $option = $this->app->input->get('option', ''); - $task = $this->app->input->get('task', ''); - - // Block non-master from uninstalling MokoWaaS - if ($option === 'com_installer' && strpos($task, 'manage.remove') !== false) - { - $cid = $this->app->input->get('cid', [], 'array'); - - if ($this->isOurExtension($cid)) - { - $this->app->enqueueMessage('MokoWaaS cannot be uninstalled.', 'error'); - $this->app->redirect('index.php?option=com_installer&view=manage'); - } - } - - // Block non-master from disabling via list toggle - if ($option === 'com_plugins' && strpos($task, 'plugins.publish') !== false) - { - $cid = $this->app->input->get('cid', [], 'array'); - - if ($this->isOurExtension($cid)) - { - $this->app->enqueueMessage('MokoWaaS cannot be disabled.', 'error'); - $this->app->redirect('index.php?option=com_plugins'); - } - } - - // Block non-master from viewing or editing MokoWaaS plugin settings - if ($option === 'com_plugins') - { - $view = $this->app->input->get('view', ''); - $layout = $this->app->input->get('layout', ''); - $extensionId = (int) $this->app->input->get('extension_id', 0); - - if (($view === 'plugin' || $layout === 'edit') && $extensionId > 0) - { - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__extensions')) - ->where($db->quoteName('extension_id') . ' = ' . $extensionId) - ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas')) - ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')); - - if ((int) $db->setQuery($query)->loadResult() > 0) - { - $this->app->enqueueMessage('MokoWaaS settings are restricted to the master user.', 'warning'); - $this->app->redirect('index.php?option=com_plugins'); - } - } - } - } - - /** - * Ensure the protected flag is set on MokoWaaS extensions in the DB. - * - * Sets protected=1, locked=0 so the extension can't be disabled or - * uninstalled but can still receive updates and config changes. - * - * @return void - * - * @since 02.03.10 - */ - protected function ensureProtectedFlag() - { - try - { - $db = Factory::getDbo(); - - // Set protected=1, locked=0 on MokoWaaS extensions - $query = $db->getQuery(true) - ->update($db->quoteName('#__extensions')) - ->set($db->quoteName('protected') . ' = 1') - ->set($db->quoteName('locked') . ' = 0') - ->where('(' . $db->quoteName('element') . ' = ' . $db->quote('mokowaas') - . ' OR ' . $db->quoteName('element') . ' = ' . $db->quote('pkg_mokowaas') . ')') - ->where($db->quoteName('protected') . ' = 0'); - $db->setQuery($query); - $db->execute(); - - // Ensure update site stays enabled (protected extensions get their update site disabled by Joomla) - $query = $db->getQuery(true) - ->update($db->quoteName('#__update_sites') . ' AS us') - ->join('INNER', $db->quoteName('#__update_sites_extensions') . ' AS use2 ON us.update_site_id = use2.update_site_id') - ->join('INNER', $db->quoteName('#__extensions') . ' AS e ON use2.extension_id = e.extension_id') - ->set('us.enabled = 1') - ->where('us.enabled = 0') - ->where('(' . $db->quoteName('e.element') . ' = ' . $db->quote('mokowaas') - . ' OR ' . $db->quoteName('e.element') . ' = ' . $db->quote('pkg_mokowaas') . ')'); - $db->setQuery($query); - $db->execute(); - } - catch (\Throwable $e) - { - // Non-critical - } - } - - /** - * Check if any of the given extension IDs belong to MokoWaaS. - * - * @param array $ids Extension IDs to check - * - * @return bool - * - * @since 02.03.04 - */ - protected function isOurExtension(array $ids): bool - { - if (empty($ids)) - { - return false; - } - - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__extensions')) - ->where($db->quoteName('extension_id') . ' IN (' . implode(',', array_map('intval', $ids)) . ')') - ->where('(' . $db->quoteName('element') . ' = ' . $db->quote('mokowaas') - . ' OR ' . $db->quoteName('element') . ' = ' . $db->quote('pkg_mokowaas') . ')'); - - return (int) $db->setQuery($query)->loadResult() > 0; - } - - /** - * Prevent non-master users from disabling the plugin via save. - * - * @param string $context Extension context - * @param object $table Extension table row - * @param bool $isNew Whether this is a new record - * - * @return bool False to cancel save - * - * @since 02.03.04 - */ - public function onExtensionBeforeSave($context, $table, $isNew) - { - if ($context !== 'com_plugins.plugin') - { - return true; - } - - if ($table->element !== 'mokowaas' || $table->folder !== 'system') - { - return true; - } - - // Non-master users cannot disable the plugin - if (!$this->isMasterUser() && (int) $table->enabled === 0) - { - $this->app->enqueueMessage('MokoWaaS cannot be disabled.', 'error'); - $table->enabled = 1; - } - - return true; - } - - /** - * Cascade enable/disable state across all MokoWaaS extensions. - * - * When the core system plugin (plg_system_mokowaas) is disabled, - * all feature plugins and the cpanel module are also disabled. - * When re-enabled, they are re-enabled too. - * - * @param string $context The extension context - * @param array $pks Extension IDs being changed - * @param int $value New state (1=enabled, 0=disabled) - * - * @return void - * - * @since 02.32.00 - */ - public function onExtensionChangeState($context, $pks, $value) - { - if (empty($pks)) - { - return; - } - - try - { - $db = Factory::getDbo(); - - // Check if the core MokoWaaS plugin is among the changed extensions - $query = $db->getQuery(true) - ->select($db->quoteName('extension_id')) - ->from($db->quoteName('#__extensions')) - ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas')) - ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) - ->where($db->quoteName('folder') . ' = ' . $db->quote('system')); - $db->setQuery($query); - $coreId = (int) $db->loadResult(); - - if (!$coreId || !\in_array($coreId, array_map('intval', $pks), true)) - { - return; - } - - // Cascade to all MokoWaaS feature plugins + module - $mokoElements = [ - $db->quote('mokowaas_firewall'), - $db->quote('mokowaas_tenant'), - $db->quote('mokowaas_devtools'), - $db->quote('mokowaas_monitor'), - $db->quote('mod_mokowaas_cpanel'), - ]; - - $query = $db->getQuery(true) - ->update($db->quoteName('#__extensions')) - ->set($db->quoteName('enabled') . ' = ' . (int) $value) - ->where($db->quoteName('element') . ' IN (' . implode(',', $mokoElements) . ')'); - $db->setQuery($query); - $db->execute(); - $affected = $db->getAffectedRows(); - - // Also update module published state - if ($value == 0) - { - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__modules')) - ->set($db->quoteName('published') . ' = 0') - ->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokowaas_cpanel')) - )->execute(); - } - else - { - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__modules')) - ->set($db->quoteName('published') . ' = 1') - ->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokowaas_cpanel')) - )->execute(); - } - - $state = $value ? 'enabled' : 'disabled'; - $this->app->enqueueMessage( - "MokoWaaS: {$state} {$affected} associated extensions.", - 'message' - ); - } - catch (\Throwable $e) - { - Log::add('MokoWaaS cascade state error: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); - } - } - - /** - * Filter admin menu items for non-master users. - * - * @param string $context Menu context - * @param array &$items Menu items (by reference) - * @param mixed $params Module params - * @param mixed $enabled Whether module is enabled - * - * @return void - * - * @since 02.01.08 - */ - public function onPreprocessMenuItems($context, &$items, $params, $enabled) - { - if (!$this->app->isClient('administrator')) - { - return; - } - - if ($this->isMasterUser()) - { - return; - } - - $hidden = $this->getHiddenMenuComponents(); - - if (empty($hidden)) - { - return; - } - - foreach ($items as $key => $item) - { - foreach ($hidden as $component) - { - if (isset($item->link) - && strpos($item->link, 'option=' . $component) !== false) - { - unset($items[$key]); - break; - } - } - } - } - - /** - * Enforce password policy before user save. - * - * @param array $oldUser Existing user data - * @param boolean $isNew Whether this is a new user - * @param array $newUser New user data being saved - * - * @return boolean True to allow save - * - * @since 02.01.08 - */ - public function onUserBeforeSave($oldUser, $isNew, $newUser) - { - if (empty($newUser['password_clear'])) - { - return true; - } - - $password = $newUser['password_clear']; - $errors = []; - - $minLen = (int) $this->params->get('password_min_length', 12); - - if (strlen($password) < $minLen) - { - $errors[] = sprintf( - 'Password must be at least %d characters.', $minLen - ); - } - - if ($this->params->get('password_require_uppercase', 1) - && !preg_match('/[A-Z]/', $password)) - { - $errors[] = 'Password must contain an uppercase letter.'; - } - - if ($this->params->get('password_require_number', 1) - && !preg_match('/\d/', $password)) - { - $errors[] = 'Password must contain a number.'; - } - - if ($this->params->get('password_require_special', 1) - && !preg_match('/[^A-Za-z0-9]/', $password)) - { - $errors[] = 'Password must contain a special character.'; - } - - if (!empty($errors)) - { - throw new \RuntimeException(implode(' ', $errors)); - } - - return true; - } - - - // ------------------------------------------------------------------ - // Diagnostics / Health Endpoint (called from onAfterInitialise) - // ------------------------------------------------------------------ - - /** - * Handle health check requests for external monitoring (e.g. Grafana). - * - * Intercepts requests with ?mokowaas=health, validates the API token, - * and returns a JSON payload with system diagnostics. Exits early to - * avoid Joomla routing overhead. - * - * @return void - * - * @since 02.01.22 - */ - /** - * Route MokoWaaS API requests. - * - * All endpoints share the same token auth and HTTPS enforcement. - * Endpoints: - * ?mokowaas=health — 16 diagnostic checks (GET) - * ?mokowaas=install — install extension from URL (POST) - * ?mokowaas=update — trigger Joomla update check (POST) - * ?mokowaas=cache — clear Joomla cache (POST) - * ?mokowaas=backup — trigger Akeeba Backup (POST) - * ?mokowaas=info — site info summary (GET) - * - * @param string $action The API action - * - * @return void - * - * @since 02.01.39 - */ - protected function handleMokoApi($action) - { - // Validate token for all endpoints - $expectedToken = $this->params->get('health_api_token', ''); - - if (empty($expectedToken)) - { - $this->sendHealthResponse(503, ['error' => 'No API token']); - - return; - } - - $authHeader = $_SERVER['HTTP_AUTHORIZATION'] - ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] - ?? ''; - $providedToken = ''; - - if (stripos($authHeader, 'Bearer ') === 0) - { - $providedToken = trim(substr($authHeader, 7)); - } - else - { - $providedToken = $this->app->input->get('token', '', 'RAW'); - } - - // syncclear and syncpush handle their own auth via POST body - $selfAuthActions = ['syncclear', 'syncpush']; - - if (!\in_array($action, $selfAuthActions, true) && !hash_equals($expectedToken, $providedToken)) - { - $this->sendHealthResponse(401, ['error' => 'Invalid token']); - - return; - } - - switch ($action) - { - case 'health': - $this->handleHealthAction(); - break; - case 'install': - $this->handleInstallAction(); - break; - case 'update': - $this->handleUpdateAction(); - break; - case 'cache': - $this->handleCacheAction(); - break; - case 'backup': - $this->handleBackupAction(); - break; - case 'info': - $this->handleInfoAction(); - break; - case 'reset': - $this->handleDemoResetAction(); - break; - case 'snapshot': - $this->handleSnapshotAction(); - break; - case 'sync': - $this->handleSyncAction(); - break; - case 'sync-receive': - $this->handleSyncReceiveAction(); - break; - case 'syncclear': - $this->handleSyncClearAction(); - break; - case 'syncpush': - $this->handleSyncPushAction(); - break; - case 'extensions': - $this->handleExtensionsAction(); - break; - default: - $this->sendHealthResponse(400, [ - 'error' => 'Unknown action', - 'action' => $action, - 'available' => ['health', 'install', 'update', 'cache', 'backup', 'info', 'reset', 'snapshot', 'sync', 'sync-receive', 'syncclear', 'extensions'], - ]); - break; - } - } - - // ------------------------------------------------------------------ - // API Actions - // ------------------------------------------------------------------ - - /** - * Handle demo site reset via API. - * - * POST /?mokowaas=reset - * Body: {"baseline": "default"} (optional, defaults to active baseline) - * - * @return void - * @since 02.21.00 - */ - protected function handleDemoResetAction() - { - if ($this->app->input->getMethod() !== 'POST') - { - $this->sendHealthResponse(405, ['error' => 'POST required']); - - return; - } - - try - { - $body = json_decode(file_get_contents('php://input'), true); - $baseline = $body['baseline'] - ?? 'default'; - - $service = $this->createDemoResetService(); - $result = $service->restoreSnapshot($baseline); - - $this->sendHealthResponse(200, $result); - } - catch (\Throwable $e) - { - $this->sendHealthResponse(500, [ - 'error' => 'Reset failed', - 'message' => $e->getMessage(), - ]); - } - } - - /** - * Handle snapshot create/list via API. - * - * GET /?mokowaas=snapshot — list snapshots - * POST /?mokowaas=snapshot — create snapshot - * Body: {"name": "my-baseline"} (optional, defaults to active baseline) - * - * @return void - * @since 02.21.00 - */ - protected function handleSnapshotAction() - { - $service = $this->createDemoResetService(); - - if ($this->app->input->getMethod() === 'GET') - { - $this->sendHealthResponse(200, [ - 'status' => 'ok', - 'snapshots' => $service->listSnapshots(), - ]); - - return; - } - - if ($this->app->input->getMethod() !== 'POST') - { - $this->sendHealthResponse(405, ['error' => 'GET or POST required']); - - return; - } - - try - { - $body = json_decode(file_get_contents('php://input'), true); - $name = $body['name'] - ?? 'default'; - - $result = $service->createSnapshot($name); - - $this->sendHealthResponse(200, $result); - } - catch (\Throwable $e) - { - $this->sendHealthResponse(500, [ - 'error' => 'Snapshot failed', - 'message' => $e->getMessage(), - ]); - } - } - - /** - * Create a DemoResetService instance from current plugin params. - * - * @return \Moko\Plugin\System\MokoWaaS\Service\DemoResetService - * @since 02.21.00 - */ - protected function createDemoResetService() - { - require_once __DIR__ . '/../Service/DemoResetService.php'; - - $includeMedia = (bool) $this->params->get('demo_snapshot_include_media', 1); - - return new \Moko\Plugin\System\MokoWaaS\Service\DemoResetService($includeMedia); - } - - /** - * Calculate the next run time from a crontab expression. - * - * Supports standard 5-field crontab: minute hour day month weekday. - * Steps (e.g. every N), ranges, and wildcards are supported. - * - * @param string $cron Crontab expression - * - * @return string|null ISO datetime of next run, or null on invalid input - * - * @since 02.21.00 - */ - protected function ensureDemoResetTask(string $cron, string $baseline): void - { - try - { - $db = Factory::getDbo(); - - // Check if task already exists - $query = $db->getQuery(true) - ->select([$db->quoteName('id'), $db->quoteName('params')]) - ->from($db->quoteName('#__scheduler_tasks')) - ->where($db->quoteName('type') . ' = ' . $db->quote('mokowaas.demo.reset')); - - $db->setQuery($query); - $existing = $db->loadAssoc(); - - // Convert cron to Joomla scheduler execution rule - $execRule = json_encode([ - 'rule-type' => 'cron-expression', - 'cron-expression' => $cron, - ]); - - $taskParams = json_encode(['baseline' => $baseline]); - - if ($existing) - { - // Update existing task - $query = $db->getQuery(true) - ->update($db->quoteName('#__scheduler_tasks')) - ->set($db->quoteName('execution_rules') . ' = ' . $db->quote($execRule)) - ->set($db->quoteName('params') . ' = ' . $db->quote($taskParams)) - ->set($db->quoteName('state') . ' = 1') - ->where($db->quoteName('id') . ' = ' . (int) $existing['id']); - - $db->setQuery($query); - $db->execute(); - } - else - { - // Create new task - $obj = (object) [ - 'title' => 'MokoWaaS Demo Reset', - 'type' => 'mokowaas.demo.reset', - 'execution_rules' => $execRule, - 'params' => $taskParams, - 'state' => 1, - 'created' => Factory::getDate()->toSql(), - 'next_execution' => Factory::getDate()->toSql(), - ]; - - $db->insertObject('#__scheduler_tasks', $obj); - } - } - catch (\Throwable $e) - { - Log::add('Failed to create demo reset task: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); - } - } - - /** - * Remove the demo reset scheduled task. - * - * @return void - * - * @since 02.28.00 - */ - protected function removeDemoResetTask(): void - { - try - { - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->delete($db->quoteName('#__scheduler_tasks')) - ->where($db->quoteName('type') . ' = ' . $db->quote('mokowaas.demo.reset')); - - $db->setQuery($query); - $db->execute(); - } - catch (\Throwable $e) - { - // Silent — table may not exist - } - } - - protected function calculateNextCronRun(string $cron): ?string - { - $parts = preg_split('/\s+/', trim($cron)); - - if (count($parts) !== 5) - { - return null; - } - - [$cronMin, $cronHour, $cronDay, $cronMonth, $cronWeekday] = $parts; - - // Start from next minute - $now = time(); - $check = $now - ($now % 60) + 60; - - // Check up to 366 days ahead - $maxChecks = 527040; // 366 * 24 * 60 - - for ($i = 0; $i < $maxChecks; $i++) - { - $min = (int) date('i', $check); - $hour = (int) date('G', $check); - $day = (int) date('j', $check); - $month = (int) date('n', $check); - $weekday = (int) date('w', $check); - - if ($this->cronFieldMatches($cronMin, $min, 0, 59) - && $this->cronFieldMatches($cronHour, $hour, 0, 23) - && $this->cronFieldMatches($cronDay, $day, 1, 31) - && $this->cronFieldMatches($cronMonth, $month, 1, 12) - && $this->cronFieldMatches($cronWeekday, $weekday, 0, 6)) - { - return gmdate('Y-m-d\TH:i:s\Z', $check); - } - - $check += 60; - } - - return null; - } - - /** - * Check if a value matches a crontab field expression. - * - * @param string $field Cron field (e.g. every-5, 1-15 range, 0-23, wildcard) - * @param int $value Current value to check - * @param int $min Minimum allowed value - * @param int $max Maximum allowed value - * - * @return bool - * - * @since 02.21.00 - */ - private function cronFieldMatches(string $field, int $value, int $min, int $max): bool - { - foreach (explode(',', $field) as $part) - { - $part = trim($part); - - // Step: every-N or range-with-step - if (str_contains($part, '/')) - { - [$range, $step] = explode('/', $part, 2); - $step = (int) $step; - - if ($step <= 0) - { - continue; - } - - if ($range === '*') - { - if (($value - $min) % $step === 0) - { - return true; - } - } - elseif (str_contains($range, '-')) - { - [$rangeMin, $rangeMax] = array_map('intval', explode('-', $range, 2)); - - if ($value >= $rangeMin && $value <= $rangeMax && ($value - $rangeMin) % $step === 0) - { - return true; - } - } - - continue; - } - - // Wildcard - if ($part === '*') - { - return true; - } - - // Range: N-M - if (str_contains($part, '-')) - { - [$rangeMin, $rangeMax] = array_map('intval', explode('-', $part, 2)); - - if ($value >= $rangeMin && $value <= $rangeMax) - { - return true; - } - - continue; - } - - // Exact value - if ((int) $part === $value) - { - return true; - } - } - - return false; - } - - /** - * Handle content sync push to configured targets. - * - * POST /?mokowaas=sync - * - * @return void - * @since 02.21.00 - */ - protected function handleSyncAction() - { - if ($this->app->input->getMethod() !== 'POST') - { - $this->sendHealthResponse(405, ['error' => 'POST required']); - - return; - } - - try - { - require_once __DIR__ . '/../Service/ContentSyncService.php'; - - $targets = json_decode($this->params->get('sync_targets', '[]'), true) ?: []; - - $service = new \Moko\Plugin\System\MokoWaaS\Service\ContentSyncService(); - $result = $service->syncAllTargets($targets); - - $this->sendHealthResponse(200, $result); - } - catch (\Throwable $e) - { - $this->sendHealthResponse(500, [ - 'error' => 'Sync failed', - 'message' => $e->getMessage(), - ]); - } - } - - /** - * Handle incoming content sync payload (receiver side). - * - * POST /?mokowaas=sync-receive - * - * @return void - * @since 02.21.00 - */ - protected function handleSyncReceiveAction() - { - if ($this->app->input->getMethod() !== 'POST') - { - $this->sendHealthResponse(405, ['error' => 'POST required']); - - return; - } - - try - { - $payload = json_decode(file_get_contents('php://input'), true); - - if (empty($payload['mokowaas_sync'])) - { - $this->sendHealthResponse(400, ['error' => 'Invalid payload — missing mokowaas_sync version']); - - return; - } - - require_once __DIR__ . '/../Service/ContentSyncReceiver.php'; - - $receiver = new \Moko\Plugin\System\MokoWaaS\Service\ContentSyncReceiver(); - $result = $receiver->receive($payload); - - $this->sendHealthResponse(200, $result); - } - catch (\Throwable $e) - { - $this->sendHealthResponse(500, [ - 'error' => 'Sync receive failed', - 'message' => $e->getMessage(), - ]); - } - } - - /** - * Bulk-clear content on this site before a sync push. - * - * POST /?mokowaas=syncclear - * Body: {"token": "...", "types": ["articles", "categories", "menus", "modules"]} - * - * Deletes content directly via DB for speed — avoids the per-item - * Joomla API DELETE bottleneck. - * - * @return void - * - * @since 02.31.00 - */ - protected function handleSyncClearAction() - { - if ($this->app->input->getMethod() !== 'POST') - { - $this->sendHealthResponse(405, ['error' => 'POST required']); - - return; - } - - $payload = json_decode(file_get_contents('php://input'), true); - $token = $payload['token'] ?? ''; - - // Authenticate with health API token - $expectedToken = $this->params->get('health_api_token', ''); - - if (empty($expectedToken) || !hash_equals($expectedToken, $token)) - { - $this->sendHealthResponse(401, ['error' => 'Invalid token']); - - return; - } - - $types = $payload['types'] ?? []; - $cleared = []; - $db = Factory::getDbo(); - - try - { - if (\in_array('articles', $types, true)) - { - $db->setQuery('DELETE FROM ' . $db->quoteName('#__content'))->execute(); - $cleared[] = 'articles:' . $db->getAffectedRows(); - } - - if (\in_array('categories', $types, true)) - { - // Delete non-root content categories - $db->setQuery( - $db->getQuery(true) - ->delete($db->quoteName('#__categories')) - ->where($db->quoteName('extension') . ' = ' . $db->quote('com_content')) - ->where($db->quoteName('id') . ' > 1') - )->execute(); - $cleared[] = 'categories:' . $db->getAffectedRows(); - } - - if (\in_array('menus', $types, true)) - { - // Delete non-root site menu items - $db->setQuery( - $db->getQuery(true) - ->delete($db->quoteName('#__menu')) - ->where($db->quoteName('client_id') . ' = 0') - ->where($db->quoteName('id') . ' > 1') - )->execute(); - $cleared[] = 'menus:' . $db->getAffectedRows(); - } - - if (\in_array('modules', $types, true)) - { - $db->setQuery( - $db->getQuery(true) - ->delete($db->quoteName('#__modules')) - ->where($db->quoteName('client_id') . ' = 0') - )->execute(); - $cleared[] = 'modules:' . $db->getAffectedRows(); - } - - $this->sendHealthResponse(200, [ - 'status' => 'ok', - 'cleared' => $cleared, - ]); - } - catch (\Throwable $e) - { - $this->sendHealthResponse(500, [ - 'error' => 'Sync clear failed', - 'message' => $e->getMessage(), - ]); - } - } - - /** - * Receive bulk content and insert locally via Joomla's Table API. - * - * POST /?mokowaas=syncpush - * Body: {"token": "...", "type": "articles", "items": [{...}, ...]} - * - * @return void - * - * @since 02.31.00 - */ - protected function handleSyncPushAction() - { - if ($this->app->input->getMethod() !== 'POST') - { - $this->sendHealthResponse(405, ['error' => 'POST required']); - - return; - } - - $payload = json_decode(file_get_contents('php://input'), true); - $token = $payload['token'] ?? ''; - - $expectedToken = $this->params->get('health_api_token', ''); - - if (empty($expectedToken) || !hash_equals($expectedToken, $token)) - { - $this->sendHealthResponse(401, ['error' => 'Invalid token']); - - return; - } - - $type = $payload['type'] ?? ''; - $items = $payload['items'] ?? []; - - if (empty($type) || empty($items)) - { - $this->sendHealthResponse(400, ['error' => 'Missing type or items']); - - return; - } - - try - { - $db = Factory::getDbo(); - $inserted = 0; - $now = Factory::getDate()->toSql(); - - switch ($type) - { - case 'articles': - foreach ($items as $item) - { - try - { - $record = (object) [ - 'title' => $item['title'] ?? '', - 'alias' => $item['alias'] ?? '', - 'introtext' => $item['introtext'] ?? '', - 'fulltext' => $item['fulltext'] ?? '', - 'state' => (int) ($item['state'] ?? 1), - 'catid' => (int) ($item['catid'] ?? 2), - 'language' => $item['language'] ?? '*', - 'featured' => (int) ($item['featured'] ?? 0), - 'metadesc' => $item['metadesc'] ?? '', - 'metakey' => $item['metakey'] ?? '', - 'metadata' => $item['metadata'] ?? '{}', - 'created' => $item['created'] ?? $now, - 'modified' => $item['modified'] ?? $now, - 'publish_up' => $item['publish_up'] ?? $now, - 'images' => $item['images'] ?? '{}', - 'urls' => $item['urls'] ?? '{}', - 'attribs' => $item['attribs'] ?? '{}', - 'access' => (int) ($item['access'] ?? 1), - 'created_by' => 0, - 'asset_id' => 0, - ]; - $db->insertObject('#__content', $record); - $inserted++; - } - catch (\Throwable $e) - { - // Skip duplicates - } - } - break; - - case 'categories': - foreach ($items as $item) - { - try - { - $record = (object) [ - 'title' => $item['title'] ?? '', - 'alias' => $item['alias'] ?? '', - 'description' => $item['description'] ?? '', - 'published' => (int) ($item['published'] ?? 1), - 'language' => $item['language'] ?? '*', - 'extension' => $item['extension'] ?? 'com_content', - 'access' => (int) ($item['access'] ?? 1), - 'params' => $item['params'] ?? '{}', - 'metadata' => $item['metadata'] ?? '{}', - 'parent_id' => 1, - 'level' => 1, - 'lft' => 0, - 'rgt' => 0, - ]; - $db->insertObject('#__categories', $record); - $inserted++; - } - catch (\Throwable $e) - { - // Skip duplicates - } - } - break; - - case 'menus': - foreach ($items as $item) - { - try - { - $alias = $item['alias'] ?? ''; - $record = (object) [ - 'title' => $item['title'] ?? '', - 'alias' => $alias, - 'path' => $item['path'] ?? $alias, - 'menutype' => $item['menutype'] ?? 'mainmenu', - 'type' => $item['type'] ?? 'component', - 'link' => $item['link'] ?? '', - 'language' => $item['language'] ?? '*', - 'published' => (int) ($item['published'] ?? 1), - 'home' => (int) ($item['home'] ?? 0), - 'params' => $item['params'] ?? '{}', - 'img' => $item['img'] ?? '', - 'access' => (int) ($item['access'] ?? 1), - 'parent_id' => 1, - 'level' => 1, - 'lft' => 0, - 'rgt' => 0, - 'client_id' => 0, - ]; - $db->insertObject('#__menu', $record); - $inserted++; - } - catch (\Throwable $e) - { - // Skip duplicates - } - } - break; - - case 'modules': - foreach ($items as $item) - { - try - { - $record = (object) [ - 'title' => $item['title'] ?? '', - 'module' => $item['module'] ?? '', - 'position' => $item['position'] ?? '', - 'params' => $item['params'] ?? '{}', - 'language' => $item['language'] ?? '*', - 'published' => (int) ($item['published'] ?? 1), - 'access' => (int) ($item['access'] ?? 1), - 'ordering' => (int) ($item['ordering'] ?? 0), - 'showtitle' => (int) ($item['showtitle'] ?? 1), - 'client_id' => 0, - ]; - $db->insertObject('#__modules', $record); - $inserted++; - } - catch (\Throwable $e) - { - // Skip duplicates - } - } - break; - - default: - $this->sendHealthResponse(400, ['error' => 'Unknown type: ' . $type]); - - return; - } - - // Rebuild nested set trees and asset table after insert - $this->repairAfterSync($type); - - $this->sendHealthResponse(200, [ - 'status' => 'ok', - 'type' => $type, - 'inserted' => $inserted, - ]); - } - catch (\Throwable $e) - { - $this->sendHealthResponse(500, [ - 'error' => 'Sync push failed', - 'message' => $e->getMessage(), - ]); - } - } - - /** - * Repair nested set trees and asset table after a bulk sync push. - * - * Categories and menus use nested sets (lft/rgt/level) which need - * rebuilding after direct DB inserts. Content needs asset entries - * for ACL to work. - * - * @param string $type Content type that was pushed - * - * @return void - * - * @since 02.31.00 - */ - private function repairAfterSync(string $type): void - { - try - { - $db = Factory::getDbo(); - - if ($type === 'categories') - { - // Rebuild the category nested set tree - $table = new \Joomla\CMS\Table\Category($db); - $table->rebuild(); - - // Ensure asset entries exist for each category - $db->setQuery( - $db->getQuery(true) - ->select('id, title, extension') - ->from($db->quoteName('#__categories')) - ->where($db->quoteName('id') . ' > 1') - ->where($db->quoteName('asset_id') . ' = 0') - ); - - foreach ($db->loadObjectList() as $cat) - { - $asset = new \Joomla\CMS\Table\Asset($db); - $asset->name = $cat->extension . '.category.' . $cat->id; - $asset->title = $cat->title; - $asset->rules = '{}'; - - // Parent asset = root - $asset->setLocation(1, 'last-child'); - $asset->store(); - - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__categories')) - ->set($db->quoteName('asset_id') . ' = ' . (int) $asset->id) - ->where($db->quoteName('id') . ' = ' . (int) $cat->id) - )->execute(); - } - } - - if ($type === 'articles') - { - // Ensure asset entries exist for each article - $db->setQuery( - $db->getQuery(true) - ->select('id, title, catid') - ->from($db->quoteName('#__content')) - ->where($db->quoteName('asset_id') . ' = 0') - ); - - foreach ($db->loadObjectList() as $article) - { - $asset = new \Joomla\CMS\Table\Asset($db); - $asset->name = 'com_content.article.' . $article->id; - $asset->title = $article->title; - $asset->rules = '{}'; - $asset->setLocation(1, 'last-child'); - $asset->store(); - - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__content')) - ->set($db->quoteName('asset_id') . ' = ' . (int) $asset->id) - ->where($db->quoteName('id') . ' = ' . (int) $article->id) - )->execute(); - } - } - - if ($type === 'menus') - { - // Rebuild menu nested set tree - $table = new \Joomla\CMS\Table\Menu($db); - $table->rebuild(); - } - } - catch (\Throwable $e) - { - Log::add('Asset repair failed for ' . $type . ': ' . $e->getMessage(), Log::WARNING, 'mokowaas'); - } - } - - /** - * List installed extensions with version, status, and update server info. - * - * GET /?mokowaas=extensions - * Optional: ?type=plugin&search=moko&enabled=1 - * - * @return void - * @since 02.21.00 - */ - protected function handleExtensionsAction() - { - try - { - $db = Factory::getDbo(); - $input = $this->app->input; - - $query = $db->getQuery(true) - ->select([ - $db->quoteName('e.extension_id'), - $db->quoteName('e.name'), - $db->quoteName('e.type'), - $db->quoteName('e.element'), - $db->quoteName('e.folder'), - $db->quoteName('e.client_id'), - $db->quoteName('e.enabled'), - $db->quoteName('e.protected'), - $db->quoteName('e.locked'), - $db->quoteName('e.manifest_cache'), - ]) - ->from($db->quoteName('#__extensions', 'e')) - ->order($db->quoteName('e.type') . ' ASC, ' . $db->quoteName('e.name') . ' ASC'); - - $typeFilter = $input->get('type', '', 'CMD'); - - if ($typeFilter !== '') - { - $query->where($db->quoteName('e.type') . ' = ' . $db->quote($typeFilter)); - } - - $enabledFilter = $input->get('enabled', '', 'CMD'); - - if ($enabledFilter !== '') - { - $query->where($db->quoteName('e.enabled') . ' = ' . (int) $enabledFilter); - } - - $search = $input->get('search', '', 'STRING'); - - if ($search !== '') - { - $like = $db->quote('%' . $db->escape($search, true) . '%'); - $query->where( - '(' . $db->quoteName('e.name') . ' LIKE ' . $like - . ' OR ' . $db->quoteName('e.element') . ' LIKE ' . $like . ')' - ); - } - - $db->setQuery($query); - $rows = $db->loadAssocList(); - - // Get update sites - $usQuery = $db->getQuery(true) - ->select([ - $db->quoteName('us.name', 'site_name'), - $db->quoteName('us.location'), - $db->quoteName('us.enabled', 'site_enabled'), - $db->quoteName('usm.extension_id'), - ]) - ->from($db->quoteName('#__update_sites', 'us')) - ->innerJoin( - $db->quoteName('#__update_sites_extensions', 'usm') - . ' ON ' . $db->quoteName('us.update_site_id') - . ' = ' . $db->quoteName('usm.update_site_id') - ); - $db->setQuery($usQuery); - $updateSites = []; - - foreach ($db->loadAssocList() ?: [] as $us) - { - $updateSites[(int) $us['extension_id']] = [ - 'name' => $us['site_name'], - 'location' => $us['location'], - 'enabled' => (bool) $us['site_enabled'], - ]; - } - - $extensions = []; - - foreach ($rows as $row) - { - $manifest = json_decode($row['manifest_cache'] ?: '{}', true); - $extId = (int) $row['extension_id']; - - $ext = [ - 'extension_id' => $extId, - 'name' => $row['name'], - 'type' => $row['type'], - 'element' => $row['element'], - 'folder' => $row['folder'] ?: null, - 'client_id' => (int) $row['client_id'], - 'enabled' => (bool) $row['enabled'], - 'protected' => (bool) $row['protected'], - 'locked' => (bool) $row['locked'], - 'version' => $manifest['version'] ?? null, - 'author' => $manifest['author'] ?? null, - ]; - - if (isset($updateSites[$extId])) - { - $ext['update_server'] = $updateSites[$extId]; - } - - $extensions[] = $ext; - } - - $this->sendHealthResponse(200, [ - 'status' => 'ok', - 'count' => count($extensions), - 'extensions' => $extensions, - ]); - } - catch (\Throwable $e) - { - $this->sendHealthResponse(500, [ - 'error' => 'Failed to list extensions', - 'message' => $e->getMessage(), - ]); - } - } - - /** - * Trigger Joomla update finder check. - * - * @return void - * @since 02.01.39 - */ - protected function handleUpdateAction() - { - if ($this->app->input->getMethod() !== 'POST') - { - $this->sendHealthResponse(405, ['error' => 'POST required']); - - return; - } - - try - { - // Clear update cache and find updates - $db = Factory::getDbo(); - $db->setQuery( - $db->getQuery(true) - ->delete($db->quoteName('#__updates')) - ); - $db->execute(); - - // Trigger update finder - \Joomla\CMS\Updater\Updater::getInstance()->findUpdates(); - - // Count results - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__updates')) - ->where($db->quoteName('extension_id') . ' != 0') - ); - $count = (int) $db->loadResult(); - - $this->sendHealthResponse(200, [ - 'status' => 'ok', - 'updates_found' => $count, - 'message' => $count . ' update(s) available', - ]); - } - catch (\Exception $e) - { - $this->sendHealthResponse(500, [ - 'error' => 'Update check failed', - 'message' => $e->getMessage(), - ]); - } - } - - /** - * Clear Joomla cache. - * - * @return void - * @since 02.01.39 - */ - protected function handleCacheAction() - { - if ($this->app->input->getMethod() !== 'POST') - { - $this->sendHealthResponse(405, ['error' => 'POST required']); - - return; - } - - try - { - $cache = Factory::getCache(''); - $cache->clean(''); - - // Also clean admin cache - $adminCache = Factory::getCache('', 'callback', 'administrator'); - $adminCache->clean(''); - - // Clear opcache if available - if (function_exists('opcache_reset')) - { - opcache_reset(); - } - - $this->sendHealthResponse(200, [ - 'status' => 'ok', - 'message' => 'Cache cleared', - ]); - } - catch (\Exception $e) - { - $this->sendHealthResponse(500, [ - 'error' => 'Cache clear failed', - 'message' => $e->getMessage(), - ]); - } - } - - /** - * Trigger Akeeba Backup via frontend API. - * - * @return void - * @since 02.01.39 - */ - protected function handleBackupAction() - { - if ($this->app->input->getMethod() !== 'POST') - { - $this->sendHealthResponse(405, ['error' => 'POST required']); - - return; - } - - try - { - $db = Factory::getDbo(); - $tables = $db->getTableList(); - $prefix = $db->getPrefix(); - - if (!in_array($prefix . 'ak_stats', $tables)) - { - $this->sendHealthResponse(404, [ - 'error' => 'Akeeba Backup not installed', - ]); - - return; - } - - // Get profile from request (default 1) - $body = json_decode(file_get_contents('php://input'), true); - $profile = (int) ($body['profile'] ?? 1); - - // Start backup via Akeeba's internal API - if (class_exists('\Akeeba\Engine\Platform')) - { - \Akeeba\Engine\Platform::getInstance()->load_configuration($profile); - $engine = \Akeeba\Engine\Factory::getEngineInstance(); - - $result = $engine->start($profile); - - $this->sendHealthResponse(200, [ - 'status' => 'started', - 'profile' => $profile, - 'message' => 'Backup started', - ]); - } - else - { - // Fallback: trigger via URL if frontend backup is enabled - $this->sendHealthResponse(501, [ - 'error' => 'Akeeba Engine not loadable', - 'message' => 'Use the Akeeba frontend URL or admin panel instead', - ]); - } - } - catch (\Exception $e) - { - $this->sendHealthResponse(500, [ - 'error' => 'Backup failed', - 'message' => $e->getMessage(), - ]); - } - } - - /** - * Return a compact site info summary. - * - * @return void - * @since 02.01.39 - */ - protected function handleInfoAction() - { - $config = Factory::getConfig(); - $db = Factory::getDbo(); - - $db->setQuery($db->getQuery(true)->select('COUNT(*)')->from($db->quoteName('#__content'))); - $articles = (int) $db->loadResult(); - - $db->setQuery($db->getQuery(true)->select('COUNT(*)')->from($db->quoteName('#__users'))); - $users = (int) $db->loadResult(); - - $db->setQuery($db->getQuery(true)->select('COUNT(*)')->from($db->quoteName('#__extensions'))->where($db->quoteName('enabled') . ' = 1')); - $extensions = (int) $db->loadResult(); - - $this->sendHealthResponse(200, [ - 'site_name' => $config->get('sitename', ''), - 'site_url' => rtrim(Uri::root(), '/'), - 'joomla_version' => JVERSION, - 'php_version' => PHP_VERSION, - 'db_type' => $db->getName(), - 'debug' => (bool) $config->get('debug', 0), - 'sef' => (bool) $config->get('sef', 0), - 'caching' => (bool) $config->get('caching', 0), - 'articles' => $articles, - 'users' => $users, - 'extensions' => $extensions, - 'brand' => self::BRAND_NAME, - 'plugin_version' => $this->getPluginVersion(), - ]); - } - - /** - * Health check action — delegates to existing health check logic. - * - * @return void - * @since 02.01.39 - */ - protected function handleHealthAction() - { - // Token already validated by handleMokoApi() - // Collect diagnostics - $checks = $this->collectHealthChecks(); - - // Determine overall status and collect reasons - $overall = 'ok'; - $reasons = []; - - foreach ($checks as $name => $check) - { - $checkStatus = $check['status'] ?? 'ok'; - - if ($checkStatus === 'error') - { - $overall = 'error'; - $reasons[] = $name . ': ' . ($check['message'] ?? 'error'); - } - elseif ($checkStatus === 'degraded') - { - if ($overall !== 'error') - { - $overall = 'degraded'; - } - - // Build human-readable reason - if ($name === 'extensions' - && isset($check['pending_updates'])) - { - $reasons[] = $check['pending_updates'] - . ' extension update' - . ($check['pending_updates'] > 1 ? 's' : '') - . ' available'; - } - elseif ($name === 'filesystem' - && isset($check['free_disk_mb']) - && $check['free_disk_mb'] < 100) - { - $reasons[] = 'Low disk space: ' - . $check['free_disk_mb'] . ' MB free'; - } - elseif ($name === 'backup') - { - if (!empty($check['message'])) - { - $reasons[] = $check['message']; - } - elseif (isset($check['days_since']) - && $check['days_since'] > 7) - { - $reasons[] = 'Last backup ' - . $check['days_since'] . ' days ago'; - } - elseif (isset($check['last_status']) - && $check['last_status'] !== 'complete') - { - $reasons[] = 'Last backup status: ' - . $check['last_status']; - } - else - { - $reasons[] = 'Backup: degraded'; - } - } - elseif ($name === 'ssl' && isset($check['days_left'])) - { - $reasons[] = 'SSL expires in ' - . $check['days_left'] . ' days'; - } - elseif ($name === 'cron' && isset($check['failed_24h'])) - { - $reasons[] = $check['failed_24h'] - . ' scheduled task(s) failed'; - } - elseif ($name === 'config' && !empty($check['issues'])) - { - $reasons[] = implode(', ', $check['issues']); - } - else - { - $reasons[] = $name . ': degraded'; - } - } - } - - $payload = [ - 'status' => $overall, - 'reason' => implode('; ', $reasons) ?: null, - 'timestamp' => gmdate('Y-m-d\TH:i:s\Z'), - 'checks' => $checks, - 'meta' => $this->collectHealthMeta(), - ]; - - $this->sendHealthResponse( - $overall === 'error' ? 503 : 200, - $payload - ); - } - - /** - * Collect all health check results. - * - * @return array Associative array of check name => result - * - * @since 02.01.22 - */ - protected function collectHealthChecks() - { - $checks = [ - 'database' => $this->checkDatabase(), - 'filesystem' => $this->checkFilesystem(), - 'cache' => $this->checkCache(), - 'extensions' => $this->checkExtensions(), - 'backup' => $this->checkAkeebaBackup(), - 'security' => $this->checkAdminTools(), - 'ssl' => $this->checkSsl(), - 'cron' => $this->checkScheduledTasks(), - 'errors' => $this->checkErrorLog(), - 'db_size' => $this->checkDatabaseSize(), - 'content' => $this->checkContent(), - 'users' => $this->checkUserActivity(), - 'mail' => $this->checkMail(), - 'seo' => $this->checkSeo(), - 'template' => $this->checkTemplate(), - 'config' => $this->checkConfigDrift(), - ]; - - return $checks; - } - - /** - * Collect metadata about the instance. - * - * @return array - * - * @since 02.01.22 - */ - protected function collectHealthMeta() - { - $config = Factory::getConfig(); - - return [ - 'brand' => self::BRAND_NAME, - 'plugin_version' => $this->getPluginVersion(), - 'joomla_version' => JVERSION, - 'php_version' => PHP_VERSION, - 'server_name' => $config->get('sitename', ''), - 'server_time' => gmdate('Y-m-d\TH:i:s\Z'), - ]; - } - - /** - * Check database connectivity and query latency. - * - * @return array Check result with status and metrics - * - * @since 02.01.22 - */ - protected function checkDatabase() - { - try - { - $db = Factory::getDbo(); - $start = microtime(true); - - $db->setQuery('SELECT 1'); - $db->execute(); - - $latencyMs = round((microtime(true) - $start) * 1000, 2); - - // Count users as a real-table sanity check - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__users')) - ); - $userCount = (int) $db->loadResult(); - - return [ - 'status' => 'ok', - 'latency_ms' => $latencyMs, - 'driver' => $db->getName(), - 'users' => $userCount, - ]; - } - catch (\Exception $e) - { - return [ - 'status' => 'error', - 'message' => 'Database unreachable', - ]; - } - } - - /** - * Check filesystem health (writable dirs, disk space). - * - * @return array Check result with status and metrics - * - * @since 02.01.22 - */ - protected function checkFilesystem() - { - $tmpWritable = is_writable(JPATH_ROOT . '/tmp'); - $logWritable = is_writable(JPATH_ROOT . '/administrator/logs'); - $cacheWritable = is_writable(JPATH_ROOT . '/cache'); - - $freeBytes = @disk_free_space(JPATH_ROOT); - $freeMb = $freeBytes !== false - ? round($freeBytes / 1048576) - : null; - - $allWritable = $tmpWritable && $logWritable && $cacheWritable; - - $status = 'ok'; - - if (!$allWritable) - { - $status = 'error'; - } - elseif ($freeMb !== null && $freeMb < 100) - { - $status = 'degraded'; - } - - // Total disk and site size - $totalBytes = @disk_total_space(JPATH_ROOT); - $totalMb = $totalBytes !== false - ? round($totalBytes / 1048576) - : null; - - // Site directory size (quick estimate via common dirs) - $siteMb = null; - - try - { - $siteSize = 0; - - foreach (['images', 'media', 'tmp', 'cache', - 'administrator/logs', 'administrator/cache'] as $dir) - { - $path = JPATH_ROOT . '/' . $dir; - - if (is_dir($path)) - { - $iter = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator( - $path, - \FilesystemIterator::SKIP_DOTS - ) - ); - - foreach ($iter as $file) - { - $siteSize += $file->getSize(); - } - } - } - - $siteMb = round($siteSize / 1048576); - } - catch (\Exception $e) - { - // Ignore — siteMb stays null - } - - return [ - 'status' => $status, - 'tmp_writable' => $tmpWritable, - 'log_writable' => $logWritable, - 'cache_writable' => $cacheWritable, - 'free_disk_mb' => $freeMb, - 'total_disk_mb' => $totalMb, - 'site_size_mb' => $siteMb, - ]; - } - - /** - * Check Joomla cache status. - * - * @return array Check result - * - * @since 02.01.22 - */ - protected function checkCache() - { - $config = Factory::getConfig(); - $enabled = (bool) $config->get('caching', 0); - $handler = $config->get('cache_handler', 'file'); - - return [ - 'status' => 'ok', - 'enabled' => $enabled, - 'handler' => $handler, - ]; - } - - /** - * Check extension counts and update status. - * - * @return array Check result with extension metrics - * - * @since 02.01.22 - */ - protected function checkExtensions() - { - try - { - $db = Factory::getDbo(); - - // Count enabled extensions by type - $query = $db->getQuery(true) - ->select([ - $db->quoteName('type'), - 'COUNT(*) AS ' . $db->quoteName('total'), - ]) - ->from($db->quoteName('#__extensions')) - ->where($db->quoteName('enabled') . ' = 1') - ->group($db->quoteName('type')); - - $db->setQuery($query); - $rows = $db->loadObjectList('type'); - - $counts = []; - - foreach ($rows as $type => $row) - { - $counts[$type] = (int) $row->total; - } - - // Check for available updates - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__updates')) - ->where($db->quoteName('extension_id') . ' != 0') - ); - $pendingUpdates = (int) $db->loadResult(); - - $status = $pendingUpdates > 0 ? 'degraded' : 'ok'; - - return [ - 'status' => $status, - 'counts' => $counts, - 'pending_updates' => $pendingUpdates, - ]; - } - catch (\Exception $e) - { - return [ - 'status' => 'error', - 'message' => 'Could not query extensions', - ]; - } - } - - /** - * Check Akeeba Backup status — last backup date, status, and profile. - * - * Queries the #__ak_stats table (Akeeba Backup) for the most recent - * backup record. Returns 'not_installed' if the table doesn't exist. - * - * @return array Check result with backup info - * - * @since 02.01.39 - */ - protected function checkAkeebaBackup() - { - try - { - $db = Factory::getDbo(); - - // Check if Akeeba Backup is installed - $tables = $db->getTableList(); - $prefix = $db->getPrefix(); - $akTable = $prefix . 'ak_stats'; - - if (!in_array($akTable, $tables)) - { - return [ - 'status' => 'ok', - 'installed' => false, - ]; - } - - // Get the most recent backup - $query = $db->getQuery(true) - ->select([ - $db->quoteName('id'), - $db->quoteName('description'), - $db->quoteName('status'), - $db->quoteName('backupstart'), - $db->quoteName('backupend'), - $db->quoteName('profile_id'), - $db->quoteName('total_size'), - ]) - ->from($db->quoteName('#__ak_stats')) - ->order($db->quoteName('id') . ' DESC'); - - $db->setQuery($query, 0, 1); - $latest = $db->loadObject(); - - if (!$latest) - { - return [ - 'status' => 'degraded', - 'installed' => true, - 'message' => 'No backups found', - ]; - } - - // Count total backups and recent (last 7 days) - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__ak_stats')) - ); - $totalBackups = (int) $db->loadResult(); - - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__ak_stats')) - ->where($db->quoteName('backupstart') - . ' >= DATE_SUB(NOW(), INTERVAL 7 DAY)') - ); - $recentBackups = (int) $db->loadResult(); - - // Check if last backup is older than 7 days - $lastDate = $latest->backupstart; - $daysSince = (int) ((time() - strtotime($lastDate)) / 86400); - $backupSize = $latest->total_size - ? round($latest->total_size / 1048576) - : null; - - $status = 'ok'; - - if ($latest->status !== 'complete') - { - $status = 'degraded'; - } - elseif ($daysSince > 7) - { - $status = 'degraded'; - } - - return [ - 'status' => $status, - 'installed' => true, - 'last_backup' => $lastDate, - 'last_status' => $latest->status, - 'last_size_mb' => $backupSize, - 'days_since' => $daysSince, - 'profile_id' => (int) $latest->profile_id, - 'total_backups' => $totalBackups, - 'recent_7d' => $recentBackups, - 'description' => $latest->description, - ]; - } - catch (\Exception $e) - { - return [ - 'status' => 'ok', - 'installed' => false, - ]; - } - } - - /** - * Check Admin Tools status — WAF status, security exceptions. - * - * Queries Admin Tools tables for firewall status and recent blocks. - * Returns 'not_installed' if tables don't exist. - * - * @return array Check result with security info - * - * @since 02.01.39 - */ - protected function checkAdminTools() - { - try - { - $db = Factory::getDbo(); - $tables = $db->getTableList(); - $prefix = $db->getPrefix(); - - // Check if Admin Tools is installed - $atTable = $prefix . 'admintools_log'; - - if (!in_array($atTable, $tables)) - { - return [ - 'status' => 'ok', - 'installed' => false, - ]; - } - - // Count blocked requests in last 24h - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__admintools_log')) - ->where($db->quoteName('logdate') - . ' >= DATE_SUB(NOW(), INTERVAL 1 DAY)') - ); - $blocked24h = (int) $db->loadResult(); - - // Count blocked in last 7 days - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__admintools_log')) - ->where($db->quoteName('logdate') - . ' >= DATE_SUB(NOW(), INTERVAL 7 DAY)') - ); - $blocked7d = (int) $db->loadResult(); - - // Check WAF config if available - $wafEnabled = null; - $wafTable = $prefix . 'admintools_wafconfig'; - - if (in_array($wafTable, $tables)) - { - $db->setQuery( - $db->getQuery(true) - ->select($db->quoteName('value')) - ->from($db->quoteName('#__admintools_wafconfig')) - ->where($db->quoteName('key') . ' = ' - . $db->quote('ipworkarounds')) - ); - $wafEnabled = $db->loadResult() !== null; - } - - return [ - 'status' => 'ok', - 'installed' => true, - 'blocked_24h' => $blocked24h, - 'blocked_7d' => $blocked7d, - 'waf_active' => $wafEnabled, - ]; - } - catch (\Exception $e) - { - return [ - 'status' => 'ok', - 'installed' => false, - ]; - } - } - - /** - * Check SSL certificate expiry. - * - * @return array - * @since 02.01.39 - */ - protected function checkSsl() - { - try - { - $siteUrl = Uri::root(); - $host = parse_url($siteUrl, PHP_URL_HOST); - - if (empty($host) || parse_url($siteUrl, PHP_URL_SCHEME) !== 'https') - { - return ['status' => 'ok', 'https' => false]; - } - - $ctx = stream_context_create([ - 'ssl' => ['capture_peer_cert' => true, 'verify_peer' => false], - ]); - $stream = @stream_socket_client( - "ssl://{$host}:443", $errno, $errstr, 10, - STREAM_CLIENT_CONNECT, $ctx - ); - - if (!$stream) - { - return ['status' => 'degraded', 'https' => true, 'message' => 'Cannot connect']; - } - - $params = stream_context_get_params($stream); - $cert = openssl_x509_parse($params['options']['ssl']['peer_certificate']); - fclose($stream); - - $expiresTs = $cert['validTo_time_t'] ?? 0; - $daysLeft = (int) (($expiresTs - time()) / 86400); - $issuer = $cert['issuer']['O'] ?? $cert['issuer']['CN'] ?? 'Unknown'; - $status = $daysLeft < 7 ? 'error' : ($daysLeft < 30 ? 'degraded' : 'ok'); - - return [ - 'status' => $status, - 'https' => true, - 'expires' => gmdate('Y-m-d', $expiresTs), - 'days_left' => $daysLeft, - 'issuer' => $issuer, - ]; - } - catch (\Exception $e) - { - return ['status' => 'ok', 'https' => false]; - } - } - - /** - * Check Joomla scheduled tasks (Joomla 4.1+). - * - * @return array - * @since 02.01.39 - */ - protected function checkScheduledTasks() - { - try - { - $db = Factory::getDbo(); - $tables = $db->getTableList(); - $prefix = $db->getPrefix(); - - if (!in_array($prefix . 'scheduler_tasks', $tables)) - { - return ['status' => 'ok', 'available' => false]; - } - - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__scheduler_tasks')) - ->where($db->quoteName('state') . ' = 1') - ); - $enabled = (int) $db->loadResult(); - - $db->setQuery( - $db->getQuery(true) - ->select([ - $db->quoteName('title'), - $db->quoteName('last_execution'), - $db->quoteName('last_exit_code'), - $db->quoteName('next_execution'), - ]) - ->from($db->quoteName('#__scheduler_tasks')) - ->where($db->quoteName('state') . ' = 1') - ->order($db->quoteName('last_execution') . ' DESC') - ); - $db->setQuery($db->getQuery(true), 0, 5); - // Re-run the query - $db->setQuery( - $db->getQuery(true) - ->select([ - $db->quoteName('title'), - $db->quoteName('last_execution'), - $db->quoteName('last_exit_code'), - $db->quoteName('next_execution'), - ]) - ->from($db->quoteName('#__scheduler_tasks')) - ->where($db->quoteName('state') . ' = 1') - ->order($db->quoteName('last_execution') . ' DESC'), - 0, 1 - ); - $last = $db->loadObject(); - - // Count failed in last 24h - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__scheduler_tasks')) - ->where($db->quoteName('last_exit_code') . ' != 0') - ->where($db->quoteName('last_execution') - . ' >= DATE_SUB(NOW(), INTERVAL 1 DAY)') - ); - $failed24h = (int) $db->loadResult(); - - $status = $failed24h > 0 ? 'degraded' : 'ok'; - - return [ - 'status' => $status, - 'available' => true, - 'enabled_tasks' => $enabled, - 'failed_24h' => $failed24h, - 'last_run' => $last->last_execution ?? null, - 'last_exit_code' => $last ? (int) $last->last_exit_code : null, - 'last_task' => $last->title ?? null, - ]; - } - catch (\Exception $e) - { - return ['status' => 'ok', 'available' => false]; - } - } - - /** - * Check PHP error log for recent errors. - * - * @return array - * @since 02.01.39 - */ - protected function checkErrorLog() - { - $logFile = JPATH_ROOT . '/administrator/logs/error.php'; - $altLog = ini_get('error_log'); - - $file = null; - - if (file_exists($logFile) && is_readable($logFile)) - { - $file = $logFile; - } - elseif ($altLog && file_exists($altLog) && is_readable($altLog)) - { - $file = $altLog; - } - - if (!$file) - { - return [ - 'status' => 'ok', - 'log_available' => false, - ]; - } - - $size = filesize($file); - $sizeMb = round($size / 1048576, 1); - - // Count recent lines (tail last 50 lines, count errors) - $lines = file_exists($file) ? @file($file) : []; - $recent = array_slice($lines, -50); - $errors24h = 0; - $lastError = null; - $yesterday = date('Y-m-d', strtotime('-1 day')); - - foreach ($recent as $line) - { - if (stripos($line, 'error') !== false - || stripos($line, 'fatal') !== false) - { - $errors24h++; - $lastError = trim(substr($line, 0, 200)); - } - } - - return [ - 'status' => 'ok', - 'log_available' => true, - 'log_size_mb' => $sizeMb, - 'recent_errors' => $errors24h, - 'last_error' => $lastError, - ]; - } - - /** - * Check database size and largest tables. - * - * @return array - * @since 02.01.39 - */ - protected function checkDatabaseSize() - { - try - { - $db = Factory::getDbo(); - $config = Factory::getConfig(); - $dbName = $config->get('db'); - - $db->setQuery( - "SELECT ROUND(SUM(data_length + index_length) / 1048576, 1) AS size_mb " - . "FROM information_schema.tables WHERE table_schema = " - . $db->quote($dbName) - ); - $totalMb = (float) $db->loadResult(); - - // Largest tables - $db->setQuery( - "SELECT table_name, " - . "ROUND((data_length + index_length) / 1048576, 1) AS size_mb " - . "FROM information_schema.tables " - . "WHERE table_schema = " . $db->quote($dbName) - . " ORDER BY (data_length + index_length) DESC LIMIT 5" - ); - $largest = []; - - foreach ($db->loadObjectList() as $t) - { - $largest[$t->table_name] = (float) $t->size_mb; - } - - // Table count - $db->setQuery( - "SELECT COUNT(*) FROM information_schema.tables " - . "WHERE table_schema = " . $db->quote($dbName) - ); - $tableCount = (int) $db->loadResult(); - - return [ - 'status' => 'ok', - 'total_mb' => $totalMb, - 'table_count' => $tableCount, - 'largest' => $largest, - ]; - } - catch (\Exception $e) - { - return ['status' => 'ok', 'total_mb' => null]; - } - } - - /** - * Check content statistics. - * - * @return array - * @since 02.01.39 - */ - protected function checkContent() - { - try - { - $db = Factory::getDbo(); - - $counts = []; - - foreach ([ - 'articles' => '#__content', - 'categories' => '#__categories', - 'menu_items' => '#__menu', - 'modules' => '#__modules', - 'media' => '#__media_files', - ] as $label => $table) - { - try - { - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName($table)) - ); - $counts[$label] = (int) $db->loadResult(); - } - catch (\Exception $e) - { - // Table might not exist - } - } - - return [ - 'status' => 'ok', - 'counts' => $counts, - ]; - } - catch (\Exception $e) - { - return ['status' => 'ok', 'counts' => []]; - } - } - - /** - * Check user activity — last login, active sessions, failed logins. - * - * @return array - * @since 02.01.39 - */ - protected function checkUserActivity() - { - try - { - $db = Factory::getDbo(); - - // Total users - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__users')) - ); - $totalUsers = (int) $db->loadResult(); - - // Last login - $db->setQuery( - $db->getQuery(true) - ->select($db->quoteName('lastvisitDate')) - ->from($db->quoteName('#__users')) - ->where($db->quoteName('lastvisitDate') - . ' IS NOT NULL') - ->order($db->quoteName('lastvisitDate') . ' DESC'), - 0, 1 - ); - $lastLogin = $db->loadResult(); - - // Active sessions - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__session')) - ->where($db->quoteName('guest') . ' = 0') - ); - $activeSessions = (int) $db->loadResult(); - - // Failed logins (from action logs if available) - $failedLogins = 0; - - try - { - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__action_logs')) - ->where($db->quoteName('message_language_key') - . ' LIKE ' . $db->quote('%LOGIN_FAILED%')) - ->where($db->quoteName('log_date') - . ' >= DATE_SUB(NOW(), INTERVAL 1 DAY)') - ); - $failedLogins = (int) $db->loadResult(); - } - catch (\Exception $e) - { - // Action logs might not track this - } - - return [ - 'status' => 'ok', - 'total_users' => $totalUsers, - 'last_login' => $lastLogin, - 'active_sessions' => $activeSessions, - 'failed_24h' => $failedLogins, - ]; - } - catch (\Exception $e) - { - return ['status' => 'ok', 'total_users' => null]; - } - } - - /** - * Check mail system status. - * - * @return array - * @since 02.01.39 - */ - protected function checkMail() - { - try - { - $config = Factory::getConfig(); - $mailer = $config->get('mailer', 'mail'); - $from = $config->get('mailfrom', ''); - $smtpHost = $config->get('smtphost', ''); - - // Check mail queue if available - $db = Factory::getDbo(); - $tables = $db->getTableList(); - $prefix = $db->getPrefix(); - - $queueCount = 0; - - if (in_array($prefix . 'mail_queue', $tables)) - { - $db->setQuery( - $db->getQuery(true) - ->select('COUNT(*)') - ->from($db->quoteName('#__mail_queue')) - ); - $queueCount = (int) $db->loadResult(); - } - - return [ - 'status' => 'ok', - 'mailer' => $mailer, - 'from' => $from, - 'smtp_host' => $mailer === 'smtp' ? $smtpHost : null, - 'queue' => $queueCount, - ]; - } - catch (\Exception $e) - { - return ['status' => 'ok', 'mailer' => null]; - } - } - - /** - * Check basic SEO health indicators. - * - * @return array - * @since 02.01.39 - */ - protected function checkSeo() - { - $robotsTxt = file_exists(JPATH_ROOT . '/robots.txt'); - $htaccess = file_exists(JPATH_ROOT . '/.htaccess'); - - // Check for sitemap - $sitemapXml = file_exists(JPATH_ROOT . '/sitemap.xml'); - $sitemapIdx = file_exists(JPATH_ROOT . '/sitemap_index.xml'); - - $config = Factory::getConfig(); - $sef = (bool) $config->get('sef', 0); - - return [ - 'status' => 'ok', - 'robots_txt' => $robotsTxt, - 'htaccess' => $htaccess, - 'sitemap' => $sitemapXml || $sitemapIdx, - 'sef_enabled' => $sef, - ]; - } - - /** - * Check active template info. - * - * @return array - * @since 02.01.39 - */ - protected function checkTemplate() - { - try - { - $db = Factory::getDbo(); - - // Site template - $db->setQuery( - $db->getQuery(true) - ->select($db->quoteName('template')) - ->from($db->quoteName('#__template_styles')) - ->where($db->quoteName('client_id') . ' = 0') - ->where($db->quoteName('home') . ' = 1') - ); - $siteTemplate = $db->loadResult() ?: 'unknown'; - - // Admin template - $db->setQuery( - $db->getQuery(true) - ->select($db->quoteName('template')) - ->from($db->quoteName('#__template_styles')) - ->where($db->quoteName('client_id') . ' = 1') - ->where($db->quoteName('home') . ' = 1') - ); - $adminTemplate = $db->loadResult() ?: 'unknown'; - - // Count template overrides - $overrideCount = 0; - $overridePath = JPATH_ROOT . '/templates/' . $siteTemplate . '/html'; - - if (is_dir($overridePath)) - { - $iter = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator( - $overridePath, - \FilesystemIterator::SKIP_DOTS - ) - ); - - foreach ($iter as $file) - { - if ($file->isFile()) - { - $overrideCount++; - } - } - } - - return [ - 'status' => 'ok', - 'site_template' => $siteTemplate, - 'admin_template' => $adminTemplate, - 'override_count' => $overrideCount, - ]; - } - catch (\Exception $e) - { - return ['status' => 'ok', 'site_template' => null]; - } - } - - /** - * Check configuration for common misconfigurations. - * - * @return array - * @since 02.01.39 - */ - protected function checkConfigDrift() - { - $config = Factory::getConfig(); - - $debug = (bool) $config->get('debug', 0); - $errorReport = $config->get('error_reporting', 'default'); - $gzip = (bool) $config->get('gzip', 0); - $sef = (bool) $config->get('sef', 0); - $sefRewrite = (bool) $config->get('sef_rewrite', 0); - $forceSSL = (int) $config->get('force_ssl', 0); - $caching = (bool) $config->get('caching', 0); - $lifetime = (int) $config->get('lifetime', 15); - $tmpPath = $config->get('tmp_path', ''); - $logPath = $config->get('log_path', ''); - - // Flag potential issues - $issues = []; - - if ($debug) - { - $issues[] = 'Debug mode is ON'; - } - - if ($errorReport === 'maximum' - || $errorReport === 'development') - { - $issues[] = 'Error reporting: ' . $errorReport; - } - - if ($forceSSL === 0) - { - $issues[] = 'Force SSL is OFF'; - } - - $status = empty($issues) ? 'ok' : 'degraded'; - - return [ - 'status' => $status, - 'debug' => $debug, - 'error_report' => $errorReport, - 'gzip' => $gzip, - 'sef' => $sef, - 'sef_rewrite' => $sefRewrite, - 'force_ssl' => $forceSSL, - 'caching' => $caching, - 'lifetime' => $lifetime, - 'issues' => $issues ?: null, - ]; - } - - /** - * Send a JSON health response and terminate execution. - * - * @param int $httpCode HTTP status code - * @param array $payload Data to encode as JSON - * - * @return void - * - * @since 02.01.22 - */ - protected function sendHealthResponse($httpCode, array $payload) - { - http_response_code($httpCode); - header('Content-Type: application/json; charset=utf-8'); - header('Cache-Control: no-store, no-cache, must-revalidate'); - header('X-MokoWaaS-Health: 1'); - echo json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - - $this->app->close(); - } - - // ------------------------------------------------------------------ - // Remote Install Endpoint (called from onAfterInitialise) - // ------------------------------------------------------------------ - - /** - * Handle remote extension install requests. - * - * POST /?mokowaas=install with a ZIP URL in the request body. - * Requires the same health API token + HTTPS. Downloads the ZIP - * and installs via Joomla's InstallerModel. - * - * Request: POST /?mokowaas=install - * Headers: Authorization: Bearer - * Body: {"url": "https://example.com/extension.zip"} - * - * @return void - * - * @since 02.01.39 - */ - protected function handleInstallAction() - { - if ($this->app->input->getMethod() !== 'POST') - { - $this->sendHealthResponse(405, ['error' => 'POST required']); - - return; - } - - // Parse request body - $body = json_decode(file_get_contents('php://input'), true); - $url = $body['url'] ?? ''; - - if (empty($url)) - { - $this->sendHealthResponse(400, ['error' => 'url required']); - - return; - } - - // Validate URL is HTTPS - if (stripos($url, 'https://') !== 0) - { - $this->sendHealthResponse(400, ['error' => 'HTTPS URL required']); - - return; - } - - try - { - // Download the ZIP - $tmpFile = $this->app->getConfig()->get('tmp_path', JPATH_ROOT . '/tmp') - . '/mokowaas_install_' . md5($url) . '.zip'; - - $ch = curl_init($url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); - curl_setopt($ch, CURLOPT_TIMEOUT, 120); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - $zipData = curl_exec($ch); - $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - $error = curl_error($ch); - curl_close($ch); - - if ($error || $code !== 200 || empty($zipData)) - { - $this->sendHealthResponse(502, [ - 'error' => 'Download failed', - 'http' => $code, - 'message' => $error ?: 'Empty response', - ]); - - return; - } - - file_put_contents($tmpFile, $zipData); - - // Extract ZIP to temp directory - $extractDir = $this->app->getConfig()->get('tmp_path', JPATH_ROOT . '/tmp') - . '/mokowaas_extract_' . md5($url); - - if (is_dir($extractDir)) - { - $this->rmdirRecursive($extractDir); - } - - mkdir($extractDir, 0755, true); - - $zip = new \ZipArchive(); - - if ($zip->open($tmpFile) !== true) - { - @unlink($tmpFile); - $this->sendHealthResponse(500, ['error' => 'Failed to open ZIP']); - - return; - } - - $zip->extractTo($extractDir); - $zip->close(); - @unlink($tmpFile); - - // Install using Joomla's installer - $installer = \Joomla\CMS\Installer\Installer::getInstance(); - $result = $installer->install($extractDir); - - $this->rmdirRecursive($extractDir); - - if ($result) - { - $this->sendHealthResponse(200, [ - 'status' => 'installed', - 'message' => 'Extension installed successfully', - 'url' => $url, - ]); - } - else - { - $this->sendHealthResponse(500, [ - 'error' => 'Installation failed', - 'message' => 'Joomla installer returned false', - 'url' => $url, - ]); - } - } - catch (\Exception $e) - { - @unlink($tmpFile ?? ''); - - if (!empty($extractDir) && is_dir($extractDir)) - { - $this->rmdirRecursive($extractDir); - } - - $this->sendHealthResponse(500, [ - 'error' => 'Install exception', - 'message' => $e->getMessage(), - 'url' => $url, - ]); - } - } - - /** - * Recursively remove a directory. - * - * @param string $dir Directory path - * - * @return void - * - * @since 02.06.00 - */ - protected function rmdirRecursive(string $dir): void - { - if (!is_dir($dir)) - { - return; - } - - $items = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), - \RecursiveIteratorIterator::CHILD_FIRST - ); - - foreach ($items as $item) - { - if ($item->isDir()) - { - rmdir($item->getPathname()); - } - else - { - unlink($item->getPathname()); - } - } - - rmdir($dir); - } - - // ------------------------------------------------------------------ - // Site Alias handling - // ------------------------------------------------------------------ - - /** - * Get the alias configuration for the current request domain, if any. - * - * @return object|null Alias entry object or null if not an alias domain - * - * @since 02.01.43 - */ - /** - * Get the primary domain from Joomla config or by exclusion from aliases. - * - * @return string Primary domain hostname - * - * @since 02.03.05 - */ - protected function getPrimaryHost(): string - { - $primaryDomain = $this->params->get('primary_domain', ''); - - if (!empty($primaryDomain)) - { - return trim($primaryDomain); - } - - // Fallback: Joomla's $live_site - $liveSite = Factory::getConfig()->get('live_site', ''); - - if (!empty($liveSite)) - { - $host = parse_url($liveSite, PHP_URL_HOST); - - if ($host) - { - return $host; - } - } - - return parse_url(Uri::root(), PHP_URL_HOST) ?: ($_SERVER['HTTP_HOST'] ?? ''); - } - - /** - * Get the dev alias domain (dev.{primary_domain}). - * - * @return string - * - * @since 02.31.00 - */ - protected function getDevAliasDomain(): string - { - $primary = $this->getPrimaryHost(); - - return !empty($primary) ? 'dev.' . $primary : ''; - } - - /** - * Check if the current request is on the dev alias domain. - * - * @return bool - * - * @since 02.31.00 - */ - protected function isDevAlias(): bool - { - $currentHost = $_SERVER['HTTP_HOST'] ?? ''; - $devDomain = $this->getDevAliasDomain(); - - return !empty($devDomain) && strcasecmp($currentHost, $devDomain) === 0; - } - - protected function getCurrentAlias() - { - $currentHost = $_SERVER['HTTP_HOST'] ?? ''; - - if (empty($currentHost)) - { - return null; - } - - // The only alias is dev.{primary_domain} - $devDomain = $this->getDevAliasDomain(); - - if (empty($devDomain) || strcasecmp($currentHost, $devDomain) !== 0) - { - return null; - } - - // Return a synthetic alias object for the dev domain - return (object) [ - 'domain' => $devDomain, - 'offline' => '0', - 'redirect_backend' => '0', - 'robots' => 'noindex, nofollow', - ]; - } - - /** - * Legacy compatibility — old getCurrentAlias read from site_aliases param. - * Now only returns the hardcoded dev.* alias. - */ - private function getCurrentAliasLegacy() - { - $aliases = $this->params->get('site_aliases', ''); - - if (empty($aliases)) - { - return null; - } - - // Subform returns JSON string, array, or stdClass - if (is_string($aliases)) - { - $aliases = json_decode($aliases); - } - - // Convert object to array (Joomla subform stores as {"key0":{...},"key1":{...}}) - if (is_object($aliases)) - { - $aliases = (array) $aliases; - } - - if (!is_array($aliases) || empty($aliases)) - { - return null; - } - - // Look up the current host in the aliases list — if found, it's an alias - foreach ($aliases as $alias) - { - $alias = (object) $alias; - - if (isset($alias->domain) && strcasecmp(rtrim(trim($alias->domain), '/'), $currentHost) === 0) - { - return $alias; - } - } - - return null; - } - - /** - * Handle site alias logic: offline page and backend redirect. - * - * Runs in onAfterInitialise so that Joomla's offline check in - * SiteApplication::doExecute() sees the updated config value. - * - * @return void - * - * @since 02.01.43 - */ - protected function handleSiteAlias() - { - // The dev alias (dev.{primary_domain}) always bypasses offline mode - if ($this->isDevAlias()) - { - $this->app->getConfig()->set('offline', 0); - - return; - } - } - - /** - * Inject robots meta tag for alias domains. - * - * @param \Joomla\CMS\Document\HtmlDocument $doc Document object - * - * @return void - * - * @since 02.01.43 - */ - protected function injectAliasRobots($doc) - { - // Always noindex/nofollow on the dev alias domain - if ($this->isDevAlias()) - { - $doc->setMetaData('robots', 'noindex, nofollow'); - } - - // Inject canonical URL pointing to the primary domain - $primaryHost = $this->getPrimaryHost(); - $currentUri = Uri::getInstance(); - $canonical = $currentUri->getScheme() . '://' . $primaryHost . $currentUri->toString(['path', 'query']); - $doc->addHeadLink($canonical, 'canonical'); - } - - // ------------------------------------------------------------------ - // Heartbeat (called from onExtensionAfterSave) - // ------------------------------------------------------------------ - // License key check (called from onAfterRoute) - // ------------------------------------------------------------------ - - /** - * Show a persistent admin warning if no license key is set on the - * MokoWaaS update site. - * - * Checks the extra_query column in #__update_sites for a dlid value. - * Also validates the key against MokoGitea on a heartbeat interval - * (once per day) and warns if the key is invalid or expired. - * - * @return void - * - * @since 02.31.00 - */ - protected function warnMissingLicenseKey(): void - { - // Only show to master users - if (!$this->isMasterUser()) - { - return; - } - - // Only warn once per session - $session = Factory::getSession(); - - if ($session->get('mokowaas.license_warned', false)) - { - return; - } - - $session->set('mokowaas.license_warned', true); - - try - { - $db = Factory::getDbo(); - - $query = $db->getQuery(true) - ->select($db->quoteName('extra_query')) - ->from($db->quoteName('#__update_sites')) - ->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoWaaS%') - . ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoWaaS%') . ')') - ->setLimit(1); - $db->setQuery($query); - $extraQuery = (string) $db->loadResult(); - - if (empty($extraQuery) || strpos($extraQuery, 'dlid=') === false) - { - $this->app->enqueueMessage( - 'Moko Consulting License Key Required — ' - . 'No download key is configured. Updates will not be available until a valid license key is entered. ' - . 'Go to System → Update Sites ' - . 'and enter your license key in the Download Key field for the MokoWaaS update site.', - 'warning' - ); - - return; - } - - // Extract the key value from extra_query - parse_str($extraQuery, $parsed); - $licenseKey = $parsed['dlid'] ?? ''; - - if (empty($licenseKey)) - { - return; - } - - // Heartbeat validation — check once per day - $session = Factory::getSession(); - $lastCheck = (int) $session->get('mokowaas.license_check', 0); - $now = time(); - - if (($now - $lastCheck) < 86400) - { - // Show cached warning if key was invalid last check - if ($session->get('mokowaas.license_invalid', false)) - { - $this->app->enqueueMessage( - 'Moko Consulting License Key Invalid — ' - . 'Your license key could not be validated. Please verify your key in ' - . 'System → Update Sites.', - 'error' - ); - } - - return; - } - - // Validate against MokoGitea - $session->set('mokowaas.license_check', $now); - - $validateUrl = 'https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/updates.xml' - . '?dlid=' . urlencode($licenseKey) - . '&domain=' . urlencode(Uri::root()); - - $ch = curl_init($validateUrl); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_TIMEOUT, 10); - curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - $response = curl_exec($ch); - $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - // Empty or non-200 means invalid key - $isValid = ($httpCode === 200 && $response && strpos($response, '') !== false); - - $session->set('mokowaas.license_invalid', !$isValid); - - if (!$isValid) - { - $this->app->enqueueMessage( - 'Moko Consulting License Key Invalid — ' - . 'Your license key could not be validated. Updates will not be available. ' - . 'Please verify your key in ' - . 'System → Update Sites.', - 'error' - ); - } - } - catch (\Throwable $e) - { - // Silent — license check is non-critical - } - } - - // ------------------------------------------------------------------ - - /** - * Send heartbeat to the MokoWaaS monitoring receiver. - * - * Registers this site's primary domain with the Grafana provisioning system. - * The receiver writes a datasource YAML file and restarts Grafana. - * Alias domains are not registered to avoid duplicate datasource UIDs. - * - * @param \Joomla\Registry\Registry $params Plugin params - * @param \Joomla\CMS\Application\CMSApplication $app Application - * - * @return void - * - * @since 02.01.36 - */ - protected function handleGrafanaProvisioning($params, $app) - { - $healthToken = $params->get('health_api_token', ''); - - if (empty($healthToken)) - { - return; - } - - $siteUrl = rtrim(Uri::root(), '/'); - $siteName = Factory::getConfig()->get('sitename', 'Joomla'); - - // Register primary domain - $this->sendHeartbeat($siteUrl, $siteName, $healthToken, $app); - - // Register alias domains (subform format) - $aliases = $params->get('site_aliases', ''); - - if (!empty($aliases)) - { - if (is_string($aliases)) - { - $aliases = json_decode($aliases); - } - - if (is_object($aliases)) - { - $aliases = (array) $aliases; - } - - if (is_array($aliases)) - { - foreach ($aliases as $alias) - { - $alias = (object) $alias; - - if (!empty($alias->domain)) - { - $domain = rtrim(trim($alias->domain), '/'); - $aliasUrl = 'https://' . preg_replace('#^https?://#i', '', $domain); - $this->sendHeartbeat($aliasUrl, $siteName, $healthToken, $app); - } - } - } - } - } - - /** - * Send a single heartbeat registration to the receiver. - * - * @param string $siteUrl Site URL to register - * @param string $siteName Display name for Grafana - * @param string $healthToken Health API bearer token - * @param object $app Application for messages - * - * @return void - * - * @since 02.01.39 - */ - protected function sendHeartbeat($siteUrl, $siteName, $healthToken, $app) - { - $payload = json_encode([ - 'site_url' => $siteUrl, - 'site_name' => $siteName, - 'health_token' => $healthToken, - 'action' => 'register', - ], JSON_UNESCAPED_SLASHES); - - $ch = curl_init(self::HEARTBEAT_URL . '/register'); - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'Content-Type: application/json', - 'X-MokoWaaS-Key: ' . self::HEARTBEAT_KEY, - ]); - curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_TIMEOUT, 15); - curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - - $response = curl_exec($ch); - $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - $error = curl_error($ch); - curl_close($ch); - - $body = json_decode($response, true); - - if ($error) - { - $app->enqueueMessage('Grafana heartbeat failed (' . $siteUrl . '): ' . $error, 'warning'); - Log::add('Heartbeat failed: ' . $error, Log::WARNING, 'mokowaas'); - } - elseif ($code === 200) - { - $status = $body['status'] ?? 'ok'; - $app->enqueueMessage( - 'Grafana heartbeat: ' . $siteUrl . ' ' . $status . ' (' . ($body['ds_uid'] ?? '') . ')', - 'message' - ); - } - else - { - $msg = sprintf('Grafana heartbeat failed (%s): HTTP %d — %s', - $siteUrl, $code, $body['error'] ?? $body['message'] ?? 'Unknown'); - $app->enqueueMessage($msg, 'warning'); - Log::add($msg, Log::WARNING, 'mokowaas'); - } - } - - // HTTPS / Session / License (called from onAfterInitialise) - // ------------------------------------------------------------------ - - /** - * Redirect HTTP requests to HTTPS. - * - * @return void - * - * @since 02.01.08 - */ - /** - * Enforce development mode settings. - * - * When dev mode is ON: - * - Disable Joomla caching - * - Enable Joomla debug mode (Global Config) - * - Enable MokoOnyx template debug - * - Disable article hit recording - * - * When dev mode is OFF (and was previously on): - * - Reset all content version history - * - Reset article published dates to now - * - * @return void - * - * @since 02.01.15 - */ - protected function enforceDevMode() - { - if (!$this->params->get('dev_mode', 0)) - { - return; - } - - // Disable caching - $config = Factory::getConfig(); - $config->set('caching', 0); - - // Enable Joomla debug - $config->set('debug', 1); - - // Enable MokoOnyx template debug - $this->setTemplateParam('mokoonyx', 'debug', 1); - - // Show offline page on primary domain only — site aliases - // and dev.* subdomains bypass offline mode for development - $currentHost = $_SERVER['HTTP_HOST'] ?? ''; - $primaryDomain = $this->params->get('primary_domain', ''); - - if (!empty($primaryDomain) && $currentHost === $primaryDomain) - { - $config->set('offline', 1); - } - - // Suppress hit recording - try - { - $db = Factory::getDbo(); - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__content')) - ->set($db->quoteName('hits') . ' = 0') - ->where($db->quoteName('hits') . ' > 0') - )->execute(); - } - catch (\Throwable $e) - { - // Silent - } - } - - /** - * Actions to run when dev mode is turned off. - * - * Resets content versions and hits, disables debug. - * - * @return void - * - * @since 02.31.00 - */ - protected function onDevModeDisabled(): void - { - try - { - $db = Factory::getDbo(); - - // Delete all content version history - $db->setQuery( - $db->getQuery(true)->delete($db->quoteName('#__history')) - )->execute(); - - // Reset hits - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__content')) - ->set($db->quoteName('hits') . ' = 0') - )->execute(); - - // Disable debug - $this->setTemplateParam('mokoonyx', 'debug', 0); - - // Take site back online - Factory::getConfig()->set('offline', 0); - - $this->app->enqueueMessage( - 'Development mode disabled — versions cleared, hits reset, debug off, site online.', - 'message' - ); - } - catch (\Throwable $e) - { - Log::add('Dev mode cleanup failed: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); - } - } - - /** - * Set a parameter on a template style. - * - * @param string $template Template element name - * @param string $key Parameter key - * @param mixed $value Parameter value - * - * @return void - * - * @since 02.31.00 - */ - private function setTemplateParam(string $template, string $key, $value): void - { - try - { - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select([$db->quoteName('id'), $db->quoteName('params')]) - ->from($db->quoteName('#__template_styles')) - ->where($db->quoteName('template') . ' = ' . $db->quote($template)); - $db->setQuery($query); - $styles = $db->loadObjectList(); - - foreach ($styles as $style) - { - $params = new \Joomla\Registry\Registry($style->params ?: '{}'); - - if ($params->get($key) != $value) - { - $params->set($key, $value); - - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__template_styles')) - ->set($db->quoteName('params') . ' = ' . $db->quote($params->toString())) - ->where($db->quoteName('id') . ' = ' . (int) $style->id) - )->execute(); - } - } - } - catch (\Throwable $e) - { - // Silent - } - } - - protected function enforceHttps() - { - if (!$this->params->get('force_https', 0)) - { - return; - } - - if ($this->app->isClient('cli')) - { - return; - } - - $isHttps = (!empty($_SERVER['HTTPS']) - && $_SERVER['HTTPS'] !== 'off') - || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'; - - if (!$isHttps) - { - $this->app->redirect( - 'https://' . $_SERVER['HTTP_HOST'] - . $_SERVER['REQUEST_URI'], 301 - ); - } - } - - /** - * Enforce admin session idle timeout. - * - * @return void - * - * @since 02.01.08 - */ - protected function enforceAdminSessionTimeout() - { - $timeout = (int) $this->params->get('admin_session_timeout', 0); - - if ($timeout <= 0) - { - return; - } - - // Don't timeout the master user - if ($this->isMasterUser()) - { - return; - } - - // Trusted IPs — session lifetime already extended in boot() - if ($this->ipIsTrusted()) - { - return; - } - - $session = Factory::getSession(); - $lastHit = $session->get('mokowaas.last_activity', 0); - $now = time(); - - if ($lastHit > 0 && ($now - $lastHit) > ($timeout * 60)) - { - $this->app->logout(); - $this->app->redirect( - Route::_('index.php', false) - ); - - return; - } - - $session->set('mokowaas.last_activity', $now); - } - - /** - * Check whether the current request IP matches any trusted IP entry. - * - * Supports exact IPs, CIDR notation (e.g. 10.0.0.0/8), and - * wildcard patterns (e.g. 192.168.1.*). - * - * @return bool True if the current IP is in the trusted list. - * - * @since 02.11.00 - */ - protected function ipIsTrusted(): bool - { - $entries = $this->params->get('trusted_ips', ''); - - if (empty($entries)) - { - return false; - } - - // Subform stores as JSON string or array - if (\is_string($entries)) - { - $entries = json_decode($entries, true); - } - - if (!\is_array($entries)) - { - return false; - } - - $ip = $this->app - ? $this->app->input->server->getString('REMOTE_ADDR', '') - : ($_SERVER['REMOTE_ADDR'] ?? ''); - $ipLong = ip2long($ip); - - if ($ipLong === false) - { - return false; - } - - foreach ($entries as $entry) - { - if (empty($entry['enabled']) || empty($entry['ip'])) - { - continue; - } - - $range = trim($entry['ip']); - - // Wildcard: 192.168.1.* - if (str_contains($range, '*')) - { - $pattern = '/^' . str_replace(['.', '*'], ['\\.', '\\d+'], $range) . '$/'; - - if (preg_match($pattern, $ip)) - { - return true; - } - - continue; - } - - // CIDR: 10.0.0.0/8 - if (str_contains($range, '/')) - { - [$subnet, $bits] = explode('/', $range, 2); - $subnetLong = ip2long($subnet); - $mask = -1 << (32 - (int) $bits); - - if ($subnetLong !== false && ($ipLong & $mask) === ($subnetLong & $mask)) - { - return true; - } - - continue; - } - - // Exact match - if ($ip === $range) - { - return true; - } - } - - return false; - } - - - /** - * Override Joomla upload restrictions at runtime. - * - * @return void - * - * @since 02.01.08 - */ - protected function enforceUploadRestrictions() - { - $types = $this->params->get('upload_allowed_types', ''); - $maxMb = (int) $this->params->get('upload_max_size_mb', 0); - - if (empty($types) && $maxMb <= 0) - { - return; - } - - $config = $this->app->getConfig(); - - if (!empty($types)) - { - $config->set('upload_extensions', $types); - } - - if ($maxMb > 0) - { - $config->set('upload_maxsize', $maxMb); - } - } - - /** - * Enforce login support module URLs on admin requests. - * - * Checks the mod_loginsupport module params and corrects them if - * they have been changed away from the expected values. - * - * @return void - * - * @since 02.01.08 - */ - protected function enforceLoginSupportUrls() - { - $expected = [ - 'forum_url' => 'https://mokoconsulting.tech/support', - 'documentation_url' => 'https://mokoconsulting.tech/kb', - 'news_url' => 'https://mokoconsulting.tech/news', - ]; - - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select([$db->quoteName('id'), $db->quoteName('params')]) - ->from($db->quoteName('#__modules')) - ->where($db->quoteName('module') . ' = ' - . $db->quote('mod_loginsupport')); - - $db->setQuery($query); - $modules = $db->loadObjectList(); - - if (empty($modules)) - { - return; - } - - foreach ($modules as $module) - { - $params = new \Joomla\Registry\Registry( - $module->params ?: '{}' - ); - $needsFix = false; - - foreach ($expected as $key => $url) - { - if ($params->get($key) !== $url) - { - $params->set($key, $url); - $needsFix = true; - } - } - - if ($needsFix) - { - $update = $db->getQuery(true) - ->update($db->quoteName('#__modules')) - ->set($db->quoteName('params') . ' = ' - . $db->quote($params->toString())) - ->where($db->quoteName('id') . ' = ' - . (int) $module->id); - - $db->setQuery($update); - $db->execute(); - } - } - } - - // ------------------------------------------------------------------ - // Tenant Restrictions (called from onAfterRoute) - // ------------------------------------------------------------------ - - /** - * Check admin routes against restriction rules and redirect if blocked. - * - * @return void - * - * @since 02.01.08 - */ - protected function enforceAdminRestrictions() - { - // Master user bypasses ALL restrictions - if ($this->isMasterUser()) - { - return; - } - - $input = $this->app->input; - $option = $input->get('option', ''); - $view = $input->get('view', ''); - $task = $input->get('task', ''); - - // Disable install-from-URL for non-master users - if ($this->params->get('disable_install_url', 1) - && $option === 'com_installer' - && stripos($task, 'install') !== false - && $input->get('installtype') === 'url') - { - $this->blockAccess('Install from URL is disabled.'); - - return; - } - - $blocked = []; - - if ($this->params->get('restrict_installer', 1)) - { - // Allow the update view by default so tenants can update extensions - $allowUpdates = (int) $this->params->get('allow_extension_updates', 1); - - if ($allowUpdates && $option === 'com_installer' - && \in_array($view, ['update', 'updatesites'], true)) - { - // Do not block — update views are permitted - } - elseif ($option === 'com_installer') - { - $this->blockAccess('Access restricted.'); - - return; - } - } - - if ($this->params->get('hide_sysinfo', 1)) - { - $blocked[] = [ - 'option' => 'com_admin', - 'view' => 'sysinfo', - ]; - } - - if ($this->params->get('restrict_global_config', 1)) - { - $blocked[] = [ - 'option' => 'com_config', - 'view' => 'application', - ]; - // Also block empty view (default landing = global config) - if ($option === 'com_config' && $view === '') - { - $this->blockAccess('Access restricted.'); - - return; - } - } - - if ($this->params->get('restrict_template_editing', 1)) - { - $blocked[] = [ - 'option' => 'com_templates', - 'view' => 'template', - ]; - } - - foreach ($blocked as $rule) - { - if ($option !== $rule['option']) - { - continue; - } - - if (isset($rule['view']) && $view !== $rule['view']) - { - continue; - } - - $this->blockAccess('Access restricted.'); - - return; - } - } - - /** - * Redirect to admin dashboard with an error message. - * - * @param string $message Error message to display - * - * @return void - * - * @since 02.01.08 - */ - protected function blockAccess($message) - { - $this->app->enqueueMessage($message, 'error'); - $this->app->redirect(Route::_('index.php', false)); - } - - /** - * Check whether the current user is the master WaaS user. - * - * @return boolean - * - * @since 02.01.08 - */ - protected function isMasterUser() - { - $user = $this->app->getIdentity(); - - if (!$user || $user->guest) - { - return false; - } - - return \in_array($user->username, $this->getMasterUsernames(), true); - } - - /** - * Decode obfuscated master usernames. - * - * @return array - * - * @since 02.29.01 - */ - private function getMasterUsernames(): array - { - if ($this->masterNames !== null) - { - return $this->masterNames; - } - - $this->masterNames = []; - - foreach (self::MASTER_KEYS as $encoded) - { - $raw = base64_decode($encoded); - $decoded = ''; - - for ($i = 0, $len = \strlen($raw); $i < $len; $i++) - { - $decoded .= \chr(\ord($raw[$i]) ^ self::MK); - } - - $this->masterNames[] = $decoded; - } - - return $this->masterNames; - } - - /** - * Build the list of components to hide from admin menu. - * - * Combines explicit hidden_menu_items config with components that - * are implicitly blocked by other restriction toggles. - * - * @return array Component option strings - * - * @since 02.01.08 - */ - protected function getHiddenMenuComponents() - { - $hidden = array_filter(array_map( - 'trim', - explode("\n", $this->params->get('hidden_menu_items', '')) - )); - - // Auto-hide components that are restricted (keep visible when updates are allowed) - if ($this->params->get('restrict_installer', 1) - && !$this->params->get('allow_extension_updates', 1)) - { - $hidden[] = 'com_installer'; - } - - if ($this->params->get('hide_sysinfo', 1)) - { - $hidden[] = 'com_admin'; - } - - return array_unique($hidden); - } - - // ------------------------------------------------------------------ - // Atum Template Branding (called from onAfterInitialise) - // ------------------------------------------------------------------ - - /** - * Enforce Atum admin template branding params. - * - * Sets logoBrandLarge, logoBrandSmall, loginLogo, and alt text - * in the Atum template style params. Uses the plugin's media - * folder as the image source. Only writes to DB when values - * have drifted. - * - * @return void - * - * @since 02.01.08 - */ - protected function enforceAtumBranding() - { - $mediaBase = 'media/plg_system_mokowaas/'; - - // Logo params - $expected = [ - 'logoBrandLarge' => $mediaBase . 'logo.png', - 'logoBrandSmall' => $mediaBase . 'favicon_256.png', - 'loginLogo' => $mediaBase . 'logo.png', - 'logoBrandLargeAlt' => '', - 'logoBrandSmallAlt' => '', - 'loginLogoAlt' => '', - 'emptyLogoBrandLargeAlt' => '1', - 'emptyLogoBrandSmallAlt' => '1', - 'emptyLoginLogoAlt' => '1', - ]; - - // Hardcoded color scheme - $primary = self::COLOR_PRIMARY; - $sidebar = self::COLOR_SIDEBAR; - $link = self::COLOR_LINK; - - if (!empty($primary)) - { - // Convert hex to HSL for Atum's hue param - $hsl = $this->hexToHsl($primary); - - if ($hsl) - { - $expected['hue'] = sprintf( - 'hsl(%d, %d%%, %d%%)', - $hsl[0], $hsl[1], $hsl[2] - ); - } - - $expected['special-color'] = $primary; - } - - if (!empty($sidebar)) - { - $expected['header-color'] = $sidebar; - } - - if (!empty($link)) - { - $expected['link-color'] = $link; - } - - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select([$db->quoteName('id'), $db->quoteName('params')]) - ->from($db->quoteName('#__template_styles')) - ->where($db->quoteName('template') . ' = ' - . $db->quote('atum')) - ->where($db->quoteName('client_id') . ' = 1'); - - $db->setQuery($query); - $styles = $db->loadObjectList(); - - if (empty($styles)) - { - return; - } - - foreach ($styles as $style) - { - $params = new \Joomla\Registry\Registry( - $style->params ?: '{}' - ); - $needsFix = false; - - foreach ($expected as $key => $value) - { - if ($params->get($key) !== $value) - { - $params->set($key, $value); - $needsFix = true; - } - } - - if ($needsFix) - { - $update = $db->getQuery(true) - ->update($db->quoteName('#__template_styles')) - ->set($db->quoteName('params') . ' = ' - . $db->quote($params->toString())) - ->where($db->quoteName('id') . ' = ' - . (int) $style->id); - - $db->setQuery($update); - $db->execute(); - } - } - } - - /** - * Convert a hex color to HSL values. - * - * @param string $hex Hex color (e.g., "#1a2744") - * - * @return array|null [hue, saturation%, lightness%] or null - * - * @since 02.01.08 - */ - protected function hexToHsl($hex) - { - $hex = ltrim($hex, '#'); - - if (strlen($hex) !== 6) - { - return null; - } - - $r = hexdec(substr($hex, 0, 2)) / 255; - $g = hexdec(substr($hex, 2, 2)) / 255; - $b = hexdec(substr($hex, 4, 2)) / 255; - - $max = max($r, $g, $b); - $min = min($r, $g, $b); - $l = ($max + $min) / 2; - - if ($max === $min) - { - return [0, 0, (int) round($l * 100)]; - } - - $d = $max - $min; - $s = $l > 0.5 - ? $d / (2 - $max - $min) - : $d / ($max + $min); - - if ($max === $r) - { - $h = ($g - $b) / $d + ($g < $b ? 6 : 0); - } - elseif ($max === $g) - { - $h = ($b - $r) / $d + 2; - } - else - { - $h = ($r - $g) / $d + 4; - } - - $h = $h / 6; - - return [ - (int) round($h * 360), - (int) round($s * 100), - (int) round($l * 100), - ]; - } - - // ------------------------------------------------------------------ - // Visual Branding (called from onBeforeCompileHead) - // ------------------------------------------------------------------ - - /** - * Replace the default favicon with a custom one. - * - * @param \Joomla\CMS\Document\HtmlDocument $doc - * - * @return void - * - * @since 02.01.08 - */ - protected function injectFavicon($doc) - { - $mediaBase = 'media/plg_system_mokowaas/'; - $root = Uri::root(); - - // Remove all existing favicon/icon links - foreach ($doc->_links as $href => $attrs) - { - if (isset($attrs['relation']) - && strpos($attrs['relation'], 'icon') !== false) - { - unset($doc->_links[$href]); - } - } - - // SVG favicon (modern browsers, preferred) - $doc->addHeadLink( - $root . $mediaBase . 'favicon.svg', - 'icon', - 'rel', - ['type' => 'image/svg+xml'] - ); - // ICO fallback (legacy browsers) - $doc->addHeadLink( - $root . $mediaBase . 'favicon.ico', - 'alternate icon', - 'rel', - ['type' => 'image/vnd.microsoft.icon'] - ); - // PNG for Apple/Android - $doc->addHeadLink( - $root . $mediaBase . 'favicon_256.png', - 'apple-touch-icon', - 'rel', - ['sizes' => '256x256'] - ); - } - -} diff --git a/src/packages/plg_system_mokowaas/Field/AllowedIpsField.php b/src/packages/plg_system_mokowaas/Field/AllowedIpsField.php deleted file mode 100644 index cfff1229..00000000 --- a/src/packages/plg_system_mokowaas/Field/AllowedIpsField.php +++ /dev/null @@ -1,72 +0,0 @@ - - * - * SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later - * - * FILE INFORMATION - * DEFGROUP: Joomla.Plugin - * INGROUP: MokoWaaS - * VERSION: 02.35.00 - * PATH: /src/Field/AllowedIpsField.php - * BRIEF: Custom form field that displays the current IP whitelist - */ - -namespace Moko\Plugin\System\MokoWaaS\Field; - -defined('_JEXEC') or die; - -use Joomla\CMS\Factory; -use Joomla\CMS\Form\FormField; - -class AllowedIpsField extends FormField -{ - protected $type = 'AllowedIps'; - - protected function getInput() - { - $config = Factory::getApplication()->getConfig(); - $allowedRaw = $config->get('mokowaas_allowed_ips', ''); - $currentIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; - - if (empty($allowedRaw)) - { - $status = 'Not configured'; - $ipList = 'No IPs set — emergency access is blocked.'; - } - else - { - $ips = array_map('trim', explode(',', $allowedRaw)); - $status = '' - . count($ips) . ' IP(s) configured'; - $ipItems = []; - - foreach ($ips as $ip) - { - $match = ($ip === $currentIp) - ? ' your IP' - : ''; - $ipItems[] = '' . htmlspecialchars($ip) - . '' . $match; - } - - $ipList = implode(', ', $ipItems); - } - - $yourIp = '' . htmlspecialchars($currentIp) . ''; - - return '
' - . 'IP Whitelist: ' . $status . '
' - . 'Allowed IPs: ' . $ipList . '
' - . 'Your current IP: ' . $yourIp . '
' - . 'Set public ' - . '$mokowaas_allowed_ips = \'1.2.3.4,5.6.7.8\';' - . ' in configuration.php to change.' - . '
'; - } - - protected function getLabel() - { - return ''; - } -} diff --git a/src/packages/plg_system_mokowaas/Field/CurrentIpField.php b/src/packages/plg_system_mokowaas/Field/CurrentIpField.php deleted file mode 100644 index 601db776..00000000 --- a/src/packages/plg_system_mokowaas/Field/CurrentIpField.php +++ /dev/null @@ -1,40 +0,0 @@ - - * - * SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later - * - * FILE INFORMATION - * DEFGROUP: Joomla.Plugin - * INGROUP: MokoWaaS - * VERSION: 02.35.00 - * PATH: /src/Field/CurrentIpField.php - * BRIEF: Read-only field that displays the current user's IP address - */ - -namespace Moko\Plugin\System\MokoWaaS\Field; - -defined('_JEXEC') or die; - -use Joomla\CMS\Form\FormField; - -class CurrentIpField extends FormField -{ - protected $type = 'CurrentIp'; - - protected function getInput() - { - $currentIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; - - return '
' - . 'Your current IP: ' - . '' . htmlspecialchars($currentIp) . ' ' - . '— add this to the table below to keep your session alive.' - . '
'; - } - - protected function getLabel() - { - return ''; - } -} diff --git a/src/packages/plg_system_mokowaas/Field/DemoTaskInfoField.php b/src/packages/plg_system_mokowaas/Field/DemoTaskInfoField.php deleted file mode 100644 index f03ca09a..00000000 --- a/src/packages/plg_system_mokowaas/Field/DemoTaskInfoField.php +++ /dev/null @@ -1,237 +0,0 @@ -getQuery(true) - ->select('*') - ->from($db->quoteName('#__scheduler_tasks')) - ->where($db->quoteName('type') . ' = ' . $db->quote('mokowaas.demo.reset')); - - $db->setQuery($query); - $task = $db->loadAssoc(); - } - catch (\Throwable $e) - { - $task = null; - } - - $newTaskLink = Route::_('index.php?option=com_scheduler&task=task.add'); - - if (!$task) - { - return '
' - . 'No demo reset task configured. ' - . 'Create a Scheduled Task ' - . 'and select MokoWaaS Demo Reset to enable demo mode.
'; - } - - $taskId = (int) $task['id']; - $state = (int) $task['state']; - $siteTimezone = Factory::getApplication()->get('offset', 'UTC'); - - // Parse schedule from execution_rules - $rules = json_decode($task['execution_rules'] ?? '{}', true); - $ruleType = $rules['rule-type'] ?? ''; - - switch ($ruleType) - { - case 'cron-expression': - $schedule = $rules['cron-expression'] ?? ''; - $friendlySchedule = $this->friendlySchedule($schedule); - break; - - case 'interval-minutes': - $mins = (int) ($rules['interval-minutes'] ?? 0); - - if ($mins >= 1440 && $mins % 1440 === 0) - { - $days = $mins / 1440; - $schedule = 'Every ' . $days . ' day' . ($days > 1 ? 's' : ''); - } - elseif ($mins >= 60 && $mins % 60 === 0) - { - $hours = $mins / 60; - $schedule = 'Every ' . $hours . ' hour' . ($hours > 1 ? 's' : ''); - } - else - { - $schedule = 'Every ' . $mins . ' minute' . ($mins !== 1 ? 's' : ''); - } - - $friendlySchedule = $schedule; - break; - - case 'interval-hours': - $hours = (int) ($rules['interval-hours'] ?? 0); - $schedule = 'Every ' . $hours . ' hour' . ($hours !== 1 ? 's' : ''); - $friendlySchedule = $schedule; - break; - - case 'interval-days': - $days = (int) ($rules['interval-days'] ?? 0); - $schedule = 'Every ' . $days . ' day' . ($days !== 1 ? 's' : ''); - $friendlySchedule = $schedule; - break; - - default: - $schedule = $ruleType ?: 'Not set'; - $friendlySchedule = 'Custom'; - } - - // Next execution - $nextExec = $task['next_execution'] ?? ''; - $nextFormatted = 'Not scheduled'; - $nextBadge = ''; - - if (!empty($nextExec) && $nextExec !== '0000-00-00 00:00:00') - { - try - { - $dt = new \DateTime($nextExec, new \DateTimeZone('UTC')); - $dt->setTimezone(new \DateTimeZone($siteTimezone)); - $nextFormatted = $dt->format('M j, Y g:i A T'); - } - catch (\Throwable $e) - { - $nextFormatted = $nextExec; - } - - $diff = strtotime($nextExec . ' UTC') - time(); - - if ($diff <= 0) - { - $nextBadge = 'DUE'; - } - elseif ($diff < 3600) - { - $nextBadge = 'in ' . (int) ceil($diff / 60) . ' min'; - } - elseif ($diff < 86400) - { - $nextBadge = 'in ' . round($diff / 3600, 1) . 'h'; - } - else - { - $nextBadge = 'in ' . round($diff / 86400, 1) . 'd'; - } - } - - // Last execution - $lastExec = $task['last_execution'] ?? ''; - $lastFormatted = 'Never'; - - if (!empty($lastExec) && $lastExec !== '0000-00-00 00:00:00') - { - try - { - $dt = new \DateTime($lastExec, new \DateTimeZone('UTC')); - $dt->setTimezone(new \DateTimeZone($siteTimezone)); - $lastFormatted = $dt->format('M j, Y g:i A T'); - } - catch (\Throwable $e) - { - $lastFormatted = $lastExec; - } - } - - // State badge - $stateBadge = $state === 1 - ? 'Enabled' - : 'Disabled'; - - // Link to edit the task - $editLink = Route::_('index.php?option=com_scheduler&task=task.edit&id=' . $taskId); - - // Task params — default to On when keys are missing (matches form defaults) - $taskParams = json_decode($task['params'] ?? '{}', true) ?: []; - $bannerOn = !isset($taskParams['banner_enabled']) || (int) $taskParams['banner_enabled'] === 1; - $mediaOn = !isset($taskParams['include_media']) || (int) $taskParams['include_media'] === 1; - $countdownOn = !isset($taskParams['show_countdown']) || (int) $taskParams['show_countdown'] === 1; - - // Check if snapshot exists - $snapshotExists = is_dir(JPATH_ROOT . '/mokowaas-snapshots/default'); - - // Build info card - return '
' - . '' - . '' - . '' - . '' - . '' - . '' - . '' - . '' - . '' - . '
Status' . $stateBadge . '
Schedule' . htmlspecialchars($friendlySchedule) . '
Next Reset' . htmlspecialchars($nextFormatted) . ' ' . $nextBadge . '
Last Reset' . htmlspecialchars($lastFormatted) . '
Runs' . (int) ($task['times_executed'] ?? 0) . ' executed, ' . (int) ($task['times_failed'] ?? 0) . ' failed
Baseline' . ($snapshotExists ? 'Saved' : 'Not taken yet') . '
Banner' . ($bannerOn ? 'On' : 'Off') . ($countdownOn ? ' + countdown' : '') . '
Images' . ($mediaOn ? 'Included' : 'Excluded') . '
' - . '' - . ' Manage Scheduled Task' - . '
'; - } - - protected function getLabel() - { - return ''; - } - - /** - * Convert a cron expression to a human-readable string. - * - * @param string $cron Cron expression - * - * @return string - */ - private function friendlySchedule(string $cron): string - { - $map = [ - '* * * * *' => 'Every minute', - '*/5 * * * *' => 'Every 5 minutes', - '*/15 * * * *' => 'Every 15 minutes', - '*/30 * * * *' => 'Every 30 minutes', - '0 */1 * * *' => 'Every hour', - '0 */4 * * *' => 'Every 4 hours', - '0 */6 * * *' => 'Every 6 hours', - '0 */12 * * *' => 'Every 12 hours', - '0 0 * * *' => 'Daily at midnight', - '0 6 * * *' => 'Daily at 6:00 AM', - '0 0 * * 0' => 'Weekly (Sunday)', - '0 0 1 * *' => 'Monthly (1st)', - ]; - - return $map[$cron] ?? 'Custom'; - } -} diff --git a/src/packages/plg_system_mokowaas/Field/NextResetField.php b/src/packages/plg_system_mokowaas/Field/NextResetField.php deleted file mode 100644 index 94b42760..00000000 --- a/src/packages/plg_system_mokowaas/Field/NextResetField.php +++ /dev/null @@ -1,156 +0,0 @@ -form) - { - $demoEnabled = (int) $this->form->getValue('demo_mode_enabled', 'params', 0) === 1; - } - - if (!$demoEnabled) - { - return 'Demo mode is off' - . ''; - } - - // Query the actual next_execution from the scheduled task - try - { - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select([ - $db->quoteName('next_execution'), - $db->quoteName('last_execution'), - $db->quoteName('state'), - ]) - ->from($db->quoteName('#__scheduler_tasks')) - ->where($db->quoteName('type') . ' = ' . $db->quote('mokowaas.demo.reset')); - - $db->setQuery($query); - $task = $db->loadAssoc(); - } - catch (\Throwable $e) - { - $task = null; - } - - if (!$task) - { - return '
No scheduled task found — save to create one automatically.
' - . ''; - } - - if ((int) $task['state'] !== 1) - { - return '
Scheduled task is disabled.
' - . ''; - } - - $nextExec = $task['next_execution']; - $lastExec = $task['last_execution']; - - if (empty($nextExec) || $nextExec === '0000-00-00 00:00:00') - { - return '
Waiting for first run...
' - . ''; - } - - // Convert to site timezone - $utcTimestamp = strtotime($nextExec); - $siteTimezone = Factory::getApplication()->get('offset', 'UTC'); - - try - { - $dt = new \DateTime('@' . $utcTimestamp); - $dt->setTimezone(new \DateTimeZone($siteTimezone)); - $formatted = $dt->format('l, F j, Y \a\t g:i A T'); - } - catch (\Throwable $e) - { - $formatted = $nextExec . ' UTC'; - } - - // Relative time - $diff = $utcTimestamp - time(); - $relative = ''; - - if ($diff <= 0) - { - $relative = 'overdue'; - } - elseif ($diff < 3600) - { - $mins = (int) ceil($diff / 60); - $relative = 'in ' . $mins . ' min'; - } - elseif ($diff < 86400) - { - $hours = round($diff / 3600, 1); - $relative = 'in ' . $hours . 'h'; - } - else - { - $days = round($diff / 86400, 1); - $relative = 'in ' . $days . 'd'; - } - - // Last run info - $lastInfo = ''; - - if (!empty($lastExec) && $lastExec !== '0000-00-00 00:00:00') - { - try - { - $lastDt = new \DateTime($lastExec); - $lastDt->setTimezone(new \DateTimeZone($siteTimezone)); - $lastInfo = 'Last run: ' . $lastDt->format('M j, g:i A') . ''; - } - catch (\Throwable $e) - { - // skip - } - } - - return '
' - . '' - . ' ' - . htmlspecialchars($formatted) . ' ' - . $relative - . $lastInfo - . '' - . '
'; - } -} diff --git a/src/packages/plg_system_mokowaas/Field/SnapshotTablesField.php b/src/packages/plg_system_mokowaas/Field/SnapshotTablesField.php deleted file mode 100644 index d186a35b..00000000 --- a/src/packages/plg_system_mokowaas/Field/SnapshotTablesField.php +++ /dev/null @@ -1,175 +0,0 @@ - ['content', 'categories', 'fields', 'fields_values', 'fields_groups', 'tags', 'contentitem_tag_map', 'ucm_content', 'ucm_history'], - 'Users' => ['users', 'user_usergroup_map', 'user_profiles', 'usergroups', 'user_keys', 'user_mfa'], - 'Menus' => ['menu', 'menu_types'], - 'Modules' => ['modules', 'modules_menu'], - 'Assets' => ['assets'], - ]; - - protected function getInput() - { - $db = Factory::getDbo(); - $prefix = $db->getPrefix(); - $tables = $db->getTableList(); - - // Resolve selected values - $selected = $this->value; - - if ($selected === null || $selected === '') - { - $selected = self::DEFAULT_TABLES; - } - elseif (is_string($selected)) - { - $selected = array_filter(array_map('trim', explode("\n", $selected))); - } - - $selected = (array) $selected; - - // Flatten nested arrays from broken save format [["#__content"],["#__categories"]] - $selected = array_map(function ($v) { - return is_array($v) ? reset($v) : $v; - }, $selected); - - // Group tables - $grouped = []; - - foreach ($tables as $table) - { - if (strpos($table, $prefix) !== 0) - { - continue; - } - - $suffix = substr($table, strlen($prefix)); - $logical = '#__' . $suffix; - $group = 'Other'; - - foreach (self::TABLE_GROUPS as $groupName => $patterns) - { - if (in_array($suffix, $patterns, true)) - { - $group = $groupName; - break; - } - } - - $grouped[$group][] = $logical; - } - - // Build HTML select with optgroups - $size = (int) ($this->element['size'] ?? 15); - $html = ''; - - // "Reset to defaults" link - $defaultsJson = htmlspecialchars(json_encode(self::DEFAULT_TABLES), ENT_QUOTES, 'UTF-8'); - $html .= '
' - . ' Reset to defaults' - . '
'; - - return $html; - } -} diff --git a/src/packages/plg_system_mokowaas/mokowaas.xml b/src/packages/plg_system_mokowaas/mokowaas.xml deleted file mode 100644 index ebb61833..00000000 --- a/src/packages/plg_system_mokowaas/mokowaas.xml +++ /dev/null @@ -1,260 +0,0 @@ - - - - System - MokoWaaS - mokowaas - Moko Consulting - 2026-05-22 - Copyright (C) 2025 Moko Consulting. All rights reserved. - GNU General Public License version 3 or later; see LICENSE.md - hello@mokoconsulting.tech - https://mokoconsulting.tech - 02.34.00 - This plugin rebrands the Joomla system interface with MokoWaaS identity. It applies language overrides and ensures consistent branding across the platform. - Moko\Plugin\System\MokoWaaS - script.php - - - script.php - Extension - Field - Helper - Service - forms - payload - services - language - administrator - - - - index.html - favicon.ico - favicon.svg - favicon_256.png - logo.png - - - - en-GB/plg_system_mokowaas.ini - en-US/plg_system_mokowaas.ini - - - - en-GB/plg_system_mokowaas.sys.ini - en-US/plg_system_mokowaas.sys.ini - - - - - language - - - - - -
- - - - - - - - - - - - - -
-
- - - - - - - - - - - - - - - - - - - - - - - - - -
-
- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-