Compare commits
57 Commits
main
...
development
| Author | SHA1 | Date | |
|---|---|---|---|
| 87ca92d9fc | |||
| 71da84bc7d | |||
| d26b980f43 | |||
| 5f07e31aaf | |||
| ed5614886c | |||
| 9c2474471a | |||
| bdbbf6d2a8 | |||
| 119a6a37b7 | |||
| 06535d6e97 | |||
| cdda8fc048 | |||
| 40e215eac4 | |||
| e95f294803 | |||
| 83153ce299 | |||
| 0aa22db2da | |||
| 07960256c1 | |||
| 9f456adf80 | |||
| c676f0d5d8 | |||
| 61b01e3d7a | |||
| a7f81e533b | |||
| 763d2e28d5 | |||
| ff069d7e95 | |||
| c57f24c664 | |||
| fa918e9bf6 | |||
| 3b30007ea2 | |||
| 8b9fff7282 | |||
| e2c15b5ca2 | |||
| d59939a89c | |||
| 9a5421c0fd | |||
| 82ea88773b | |||
| 370cab8444 | |||
| 4349b20e34 | |||
| 7234d977b8 | |||
| 0e5c7f9396 | |||
| efcdcdcfce | |||
| b0ea119b55 | |||
| 127aea5e5b | |||
| 3047327d2e | |||
| 370505d4a2 | |||
| 45077671fa | |||
| 93f9a0f4a2 | |||
| fbb467a832 | |||
| 86a93837f6 | |||
| 57534eec9c | |||
| c999cc67c4 | |||
| cc3d0df2c2 | |||
| b61e453433 | |||
| 510d3f1f7d | |||
| c1aa9d5213 | |||
| 05be465f96 | |||
| 0183a8dd3e | |||
| a4d4a39b97 | |||
| d2ba5d7123 | |||
| f52df1912d | |||
| 4e797a5f74 | |||
| 6aee7353b9 | |||
| 82c3e96759 | |||
| 6f84af130d |
@@ -9,7 +9,7 @@
|
|||||||
<display-name>Package - MokoSuite</display-name>
|
<display-name>Package - MokoSuite</display-name>
|
||||||
<org>MokoConsulting</org>
|
<org>MokoConsulting</org>
|
||||||
<description>White-label identity, security hardening, and tenant restriction layer for Suite-managed Joomla environments</description>
|
<description>White-label identity, security hardening, and tenant restriction layer for Suite-managed Joomla environments</description>
|
||||||
<version>02.34.50</version>
|
<version>02.34.79</version>
|
||||||
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
|
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
|
||||||
</identity>
|
</identity>
|
||||||
<governance>
|
<governance>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: moko-platform.Automation
|
# INGROUP: moko-platform.Automation
|
||||||
# VERSION: 02.34.50
|
# VERSION: 02.34.79
|
||||||
# BRIEF: Auto-create feature branch when an issue is opened
|
# BRIEF: Auto-create feature branch when an issue is opened
|
||||||
|
|
||||||
name: "Universal: Issue Branch"
|
name: "Universal: Issue Branch"
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Gitea.Workflow
|
||||||
|
# INGROUP: MokoPlatform.Universal
|
||||||
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
|
# PATH: /.mokogitea/workflows/rc-revert.yml
|
||||||
|
# VERSION: 09.23.00
|
||||||
|
# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge
|
||||||
|
|
||||||
|
name: "RC Revert"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [closed]
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
revert:
|
||||||
|
name: Rename rc/ back to dev/
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: >-
|
||||||
|
github.event.pull_request.merged == false &&
|
||||||
|
startsWith(github.event.pull_request.head.ref, 'rc/')
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Rename branch
|
||||||
|
run: |
|
||||||
|
BRANCH="${{ github.event.pull_request.head.ref }}"
|
||||||
|
SUFFIX="${BRANCH#rc/}"
|
||||||
|
DEV_BRANCH="dev/${SUFFIX}"
|
||||||
|
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
|
||||||
|
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||||
|
|
||||||
|
# Create dev/ branch from rc/ branch
|
||||||
|
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"new_branch_name\": \"${DEV_BRANCH}\", \"old_branch_name\": \"${BRANCH}\"}" \
|
||||||
|
"${API}" 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ "$STATUS" = "201" ]; then
|
||||||
|
echo "Created branch: ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Delete rc/ branch
|
||||||
|
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
|
||||||
|
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${API}/${ENCODED}" 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ "$STATUS" = "204" ]; then
|
||||||
|
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "::warning::Failed to delete ${BRANCH} (HTTP ${STATUS})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "### RC Reverted" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "${BRANCH} → ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||||
+14
-1
@@ -14,7 +14,7 @@
|
|||||||
INGROUP: MokoSuite.Documentation
|
INGROUP: MokoSuite.Documentation
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
||||||
PATH: ./CHANGELOG.md
|
PATH: ./CHANGELOG.md
|
||||||
VERSION: 02.34.50
|
VERSION: 02.34.79
|
||||||
BRIEF: Version history using `Keep a Changelog`
|
BRIEF: Version history using `Keep a Changelog`
|
||||||
-->
|
-->
|
||||||
|
|
||||||
@@ -23,6 +23,19 @@
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- plg_system_mokosuite_dbip — IP geolocation plugin using DB-IP MMDB databases (CDN auto-download, local file mode, bundled MaxMind reader)
|
||||||
|
- Admin sidebar menu restructure — each Moko component gets its own collapsible section, com_mokosuitehq pinned first
|
||||||
|
- rc-revert workflow for release candidate rollbacks
|
||||||
|
- Ntfy push notifications for ticket events and security alerts (#205) — configurable server/topic/token
|
||||||
|
- Canned responses admin UI with edit modal, category filter, drag-and-drop reorder (#138)
|
||||||
|
- Ticket categories drag-and-drop reorder (#139)
|
||||||
|
- File attachments on tickets and replies (#141) — upload/download/delete with type and size validation
|
||||||
|
- Satisfaction ratings on resolved tickets (#140) — 1-5 star widget with optional feedback
|
||||||
|
- Helpdesk REST API (#142) — GET/POST/PATCH tickets, POST replies, filters, pagination
|
||||||
|
- Component config options UI (#149) — general, notification (email + ntfy), helpdesk settings
|
||||||
|
- Automation rule visual builder (#137) — condition/action dropdowns, edit existing, reorder, XSS-safe DOM
|
||||||
|
- Admin login and failed login security notifications (#147)
|
||||||
|
- Automation engine with Joomla event triggers (#151) — user_login, user_register, content_save, extension_install, behavior modes (append/always_new/skip_if_open), create_ticket action
|
||||||
- RSA-signed heartbeat authentication — private key in monitor plugin manifest, public key on MokoSuiteHQ
|
- RSA-signed heartbeat authentication — private key in monitor plugin manifest, public key on MokoSuiteHQ
|
||||||
- Monitor plugin base_url set via manifest (hidden from admin UI), propagated via update server
|
- Monitor plugin base_url set via manifest (hidden from admin UI), propagated via update server
|
||||||
- Send Heartbeat button on health token field for manual heartbeat testing
|
- Send Heartbeat button on health token field for manual heartbeat testing
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@
|
|||||||
DEFGROUP: Joomla.Plugin
|
DEFGROUP: Joomla.Plugin
|
||||||
INGROUP: MokoSuite.Documentation
|
INGROUP: MokoSuite.Documentation
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
||||||
VERSION: 02.34.50
|
VERSION: 02.34.79
|
||||||
PATH: ./CODE_OF_CONDUCT.md
|
PATH: ./CODE_OF_CONDUCT.md
|
||||||
BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default
|
BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default
|
||||||
-->
|
-->
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@
|
|||||||
DEFGROUP: mokoconsulting-tech.MokoSuiteBrand
|
DEFGROUP: mokoconsulting-tech.MokoSuiteBrand
|
||||||
INGROUP: MokoStandards.Governance
|
INGROUP: MokoStandards.Governance
|
||||||
REPO: https://github.com/mokoconsulting-tech/MokoSuiteBrand
|
REPO: https://github.com/mokoconsulting-tech/MokoSuiteBrand
|
||||||
VERSION: 02.34.50
|
VERSION: 02.34.79
|
||||||
PATH: /GOVERNANCE.md
|
PATH: /GOVERNANCE.md
|
||||||
BRIEF: Project governance rules, roles, and decision process for MokoSuiteBrand
|
BRIEF: Project governance rules, roles, and decision process for MokoSuiteBrand
|
||||||
-->
|
-->
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@
|
|||||||
INGROUP: MokoSuite.Documentation
|
INGROUP: MokoSuite.Documentation
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
||||||
PATH: ./LICENSE.md
|
PATH: ./LICENSE.md
|
||||||
VERSION: 02.34.50
|
VERSION: 02.34.79
|
||||||
BRIEF: Project license (GPL-3.0-or-later)
|
BRIEF: Project license (GPL-3.0-or-later)
|
||||||
-->
|
-->
|
||||||
GNU GENERAL PUBLIC LICENSE
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
DEFGROUP: Joomla.Plugin
|
DEFGROUP: Joomla.Plugin
|
||||||
INGROUP: MokoSuite
|
INGROUP: MokoSuite
|
||||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuite
|
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuite
|
||||||
VERSION: 02.34.50
|
VERSION: 02.34.79
|
||||||
PATH: /README.md
|
PATH: /README.md
|
||||||
BRIEF: MokoSuite platform plugin for Joomla
|
BRIEF: MokoSuite platform plugin for Joomla
|
||||||
-->
|
-->
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ DEFGROUP: [PROJECT_NAME]
|
|||||||
INGROUP: [PROJECT_NAME].Documentation
|
INGROUP: [PROJECT_NAME].Documentation
|
||||||
REPO: [REPOSITORY_URL]
|
REPO: [REPOSITORY_URL]
|
||||||
PATH: /SECURITY.md
|
PATH: /SECURITY.md
|
||||||
VERSION: 02.34.50
|
VERSION: 02.34.79
|
||||||
BRIEF: Security vulnerability reporting and handling policy
|
BRIEF: Security vulnerability reporting and handling policy
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
|||||||
@@ -11,13 +11,13 @@
|
|||||||
INGROUP: MokoSuite.Build
|
INGROUP: MokoSuite.Build
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
||||||
FILE: build-guide.md
|
FILE: build-guide.md
|
||||||
VERSION: 02.34.50
|
VERSION: 02.34.79
|
||||||
PATH: /docs/guides/
|
PATH: /docs/guides/
|
||||||
BRIEF: Build and packaging guide for the MokoSuite system plugin
|
BRIEF: Build and packaging guide for the MokoSuite system plugin
|
||||||
NOTE: Defines environment setup, repository layout, packaging rules, and release preparation
|
NOTE: Defines environment setup, repository layout, packaging rules, and release preparation
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# MokoSuite Build Guide (VERSION: 02.34.50)
|
# MokoSuite Build Guide (VERSION: 02.34.79)
|
||||||
|
|
||||||
## 1. Purpose
|
## 1. Purpose
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
DEFGROUP: Joomla.Plugin
|
DEFGROUP: Joomla.Plugin
|
||||||
INGROUP: MokoSuite.Guides
|
INGROUP: MokoSuite.Guides
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
||||||
VERSION: 02.34.50
|
VERSION: 02.34.79
|
||||||
PATH: /docs/guides/configuration-guide.md
|
PATH: /docs/guides/configuration-guide.md
|
||||||
BRIEF: Configuration guide for the MokoSuite system plugin
|
BRIEF: Configuration guide for the MokoSuite system plugin
|
||||||
NOTE: Defines plugin parameters, expected behaviors, and recommended defaults
|
NOTE: Defines plugin parameters, expected behaviors, and recommended defaults
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# MokoSuite Configuration Guide (VERSION: 02.34.50)
|
# MokoSuite Configuration Guide (VERSION: 02.34.79)
|
||||||
|
|
||||||
## 1. Objective
|
## 1. Objective
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
DEFGROUP: Joomla.Plugin
|
DEFGROUP: Joomla.Plugin
|
||||||
INGROUP: MokoSuite.Guides
|
INGROUP: MokoSuite.Guides
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
||||||
VERSION: 02.34.50
|
VERSION: 02.34.79
|
||||||
PATH: /docs/guides/installation-guide.md
|
PATH: /docs/guides/installation-guide.md
|
||||||
BRIEF: Installation guide for the MokoSuite system plugin
|
BRIEF: Installation guide for the MokoSuite system plugin
|
||||||
NOTE: First document in the guide set
|
NOTE: First document in the guide set
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# MokoSuite Installation Guide (VERSION: 02.34.50)
|
# MokoSuite Installation Guide (VERSION: 02.34.79)
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
DEFGROUP: Joomla.Plugin
|
DEFGROUP: Joomla.Plugin
|
||||||
INGROUP: MokoSuite.Guides
|
INGROUP: MokoSuite.Guides
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
||||||
VERSION: 02.34.50
|
VERSION: 02.34.79
|
||||||
PATH: /docs/guides/operations-guide.md
|
PATH: /docs/guides/operations-guide.md
|
||||||
BRIEF: Operational guide for administering and managing the MokoSuite system plugin
|
BRIEF: Operational guide for administering and managing the MokoSuite system plugin
|
||||||
NOTE: Defines lifecycle, responsibilities, and operational behaviors
|
NOTE: Defines lifecycle, responsibilities, and operational behaviors
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# MokoSuite Operations Guide (VERSION: 02.34.50)
|
# MokoSuite Operations Guide (VERSION: 02.34.79)
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
DEFGROUP: Joomla.Plugin
|
DEFGROUP: Joomla.Plugin
|
||||||
INGROUP: MokoSuite.Guides
|
INGROUP: MokoSuite.Guides
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
||||||
VERSION: 02.34.50
|
VERSION: 02.34.79
|
||||||
PATH: /docs/guides/rollback-and-recovery-guide.md
|
PATH: /docs/guides/rollback-and-recovery-guide.md
|
||||||
BRIEF: Rollback and recovery guide for restoring stable operation after plugin related incidents
|
BRIEF: Rollback and recovery guide for restoring stable operation after plugin related incidents
|
||||||
NOTE: Completes the core guide set for Suite plugin governance
|
NOTE: Completes the core guide set for Suite plugin governance
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# MokoSuite Rollback and Recovery Guide (VERSION: 02.34.50)
|
# MokoSuite Rollback and Recovery Guide (VERSION: 02.34.79)
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,13 @@
|
|||||||
DEFGROUP: Joomla.Plugin
|
DEFGROUP: Joomla.Plugin
|
||||||
INGROUP: MokoSuite.Guides
|
INGROUP: MokoSuite.Guides
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
||||||
VERSION: 02.34.50
|
VERSION: 02.34.79
|
||||||
PATH: /docs/guides/testing-guide.md
|
PATH: /docs/guides/testing-guide.md
|
||||||
BRIEF: Testing guide for MokoSuite v02.01.08
|
BRIEF: Testing guide for MokoSuite v02.01.08
|
||||||
NOTE: Covers manual test procedures for language overrides, install/uninstall, and configuration
|
NOTE: Covers manual test procedures for language overrides, install/uninstall, and configuration
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# MokoSuite Testing Guide (VERSION: 02.34.50)
|
# MokoSuite Testing Guide (VERSION: 02.34.79)
|
||||||
|
|
||||||
## 1. Prerequisites
|
## 1. Prerequisites
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
DEFGROUP: Joomla.Plugin
|
DEFGROUP: Joomla.Plugin
|
||||||
INGROUP: MokoSuite.Guides
|
INGROUP: MokoSuite.Guides
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
||||||
VERSION: 02.34.50
|
VERSION: 02.34.79
|
||||||
PATH: /docs/guides/troubleshooting-guide.md
|
PATH: /docs/guides/troubleshooting-guide.md
|
||||||
BRIEF: Troubleshooting guide for diagnosing and resolving issues related to the MokoSuite plugin
|
BRIEF: Troubleshooting guide for diagnosing and resolving issues related to the MokoSuite plugin
|
||||||
NOTE: Designed for administrators and Suite operations teams
|
NOTE: Designed for administrators and Suite operations teams
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# MokoSuite Troubleshooting Guide (VERSION: 02.34.50)
|
# MokoSuite Troubleshooting Guide (VERSION: 02.34.79)
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
DEFGROUP: Joomla.Plugin
|
DEFGROUP: Joomla.Plugin
|
||||||
INGROUP: MokoSuite.Guides
|
INGROUP: MokoSuite.Guides
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
||||||
VERSION: 02.34.50
|
VERSION: 02.34.79
|
||||||
PATH: /docs/guides/upgrade-and-versioning-guide.md
|
PATH: /docs/guides/upgrade-and-versioning-guide.md
|
||||||
BRIEF: Guide for updating, versioning, and maintaining the MokoSuite plugin
|
BRIEF: Guide for updating, versioning, and maintaining the MokoSuite plugin
|
||||||
NOTE: Defines release flow, version rules, and upgrade validation
|
NOTE: Defines release flow, version rules, and upgrade validation
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# MokoSuite Upgrade and Versioning Guide (VERSION: 02.34.50)
|
# MokoSuite Upgrade and Versioning Guide (VERSION: 02.34.79)
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -10,13 +10,13 @@
|
|||||||
DEFGROUP: Joomla.Plugin
|
DEFGROUP: Joomla.Plugin
|
||||||
INGROUP: MokoSuite.Documentation
|
INGROUP: MokoSuite.Documentation
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
||||||
VERSION: 02.34.50
|
VERSION: 02.34.79
|
||||||
PATH: /docs/index.md
|
PATH: /docs/index.md
|
||||||
BRIEF: Master index of all documentation for the MokoSuite plugin
|
BRIEF: Master index of all documentation for the MokoSuite plugin
|
||||||
NOTE: Automatically maintained index for all guide canvases
|
NOTE: Automatically maintained index for all guide canvases
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# MokoSuite Documentation Index (VERSION: 02.34.50)
|
# MokoSuite Documentation Index (VERSION: 02.34.79)
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,12 @@
|
|||||||
INGROUP: MokoSuite
|
INGROUP: MokoSuite
|
||||||
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
REPO: https://github.com/mokoconsulting-tech/mokosuite
|
||||||
PATH: /docs/plugin-basic.md
|
PATH: /docs/plugin-basic.md
|
||||||
VERSION: 02.34.50
|
VERSION: 02.34.79
|
||||||
BRIEF: Baseline documentation for the MokoSuite system plugin
|
BRIEF: Baseline documentation for the MokoSuite system plugin
|
||||||
NOTE: Foundational reference for internal and external stakeholders
|
NOTE: Foundational reference for internal and external stakeholders
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# MokoSuite Plugin Overview (VERSION: 02.34.50)
|
# MokoSuite Plugin Overview (VERSION: 02.34.79)
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ DEFGROUP: MokoSuite.Documentation
|
|||||||
INGROUP: MokoStandards.Templates
|
INGROUP: MokoStandards.Templates
|
||||||
REPO: https://github.com/mokoconsulting-tech/MokoSuite
|
REPO: https://github.com/mokoconsulting-tech/MokoSuite
|
||||||
PATH: /docs/update-server.md
|
PATH: /docs/update-server.md
|
||||||
VERSION: 02.34.50
|
VERSION: 02.34.79
|
||||||
BRIEF: How this extension's Joomla update server file (update.xml) is managed
|
BRIEF: How this extension's Joomla update server file (update.xml) is managed
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
|||||||
@@ -50,14 +50,14 @@
|
|||||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomOpenGraph/raw/branch/dev/updates.xml</updateserver>
|
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomOpenGraph/raw/branch/dev/updates.xml</updateserver>
|
||||||
</extension>
|
</extension>
|
||||||
<extension>
|
<extension>
|
||||||
<name>MokoJoomBackup</name>
|
<name>MokoSuiteBackup</name>
|
||||||
<element>pkg_mokojoombackup</element>
|
<element>pkg_mokojoombackup</element>
|
||||||
<type>package</type>
|
<type>package</type>
|
||||||
<description>Automated backup system with Borg integration, scheduled tasks, and remote storage.</description>
|
<description>Full-site backup and restore for Joomla — database, files, and configuration.</description>
|
||||||
<icon>icon-archive</icon>
|
<icon>icon-archive</icon>
|
||||||
<category>Tools</category>
|
<category>Tools</category>
|
||||||
<article>https://mokoconsulting.tech/support/products/mokojoombackup</article>
|
<article>https://mokoconsulting.tech/support/products/mokosuitebackup</article>
|
||||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomBackup/raw/branch/dev/updates.xml</updateserver>
|
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/raw/branch/dev/updates.xml</updateserver>
|
||||||
</extension>
|
</extension>
|
||||||
<extension>
|
<extension>
|
||||||
<name>MokoJoomHero</name>
|
<name>MokoJoomHero</name>
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<config>
|
<config>
|
||||||
|
<fieldset name="general" label="General" description="General component settings.">
|
||||||
|
<field name="brand_name" type="text" default="MokoSuite"
|
||||||
|
label="Brand Name"
|
||||||
|
description="Displayed in the admin sidebar, dashboard, and emails."
|
||||||
|
hint="MokoSuite" />
|
||||||
|
<field name="support_email" type="email" default=""
|
||||||
|
label="Support Email"
|
||||||
|
description="Reply-to address for outbound notification emails."
|
||||||
|
hint="support@example.com" />
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<fieldset name="notifications" label="Email Notifications" description="Configure email recipients for ticket and security notifications.">
|
<fieldset name="notifications" label="Email Notifications" description="Configure email recipients for ticket and security notifications.">
|
||||||
<field name="admin_emails" type="text" default=""
|
<field name="admin_emails" type="text" default=""
|
||||||
label="Admin Email Addresses"
|
label="Admin Email Addresses"
|
||||||
@@ -16,6 +27,31 @@
|
|||||||
<option value="1">JYES</option>
|
<option value="1">JYES</option>
|
||||||
<option value="0">JNO</option>
|
<option value="0">JNO</option>
|
||||||
</field>
|
</field>
|
||||||
|
|
||||||
|
<field name="spacer_ntfy" type="spacer" label="Push Notifications (ntfy)" />
|
||||||
|
<field name="ntfy_enabled" type="radio" default="0"
|
||||||
|
label="Enable ntfy Push"
|
||||||
|
description="Send push notifications via ntfy for ticket and security events."
|
||||||
|
class="btn-group btn-group-yesno">
|
||||||
|
<option value="1">JYES</option>
|
||||||
|
<option value="0">JNO</option>
|
||||||
|
</field>
|
||||||
|
<field name="ntfy_server" type="url" default="https://ntfy.mokoconsulting.tech"
|
||||||
|
label="ntfy Server URL"
|
||||||
|
description="Full URL to your ntfy server."
|
||||||
|
showon="ntfy_enabled:1" />
|
||||||
|
<field name="ntfy_topic" type="text" default="mokosuite-tickets"
|
||||||
|
label="Ticket Topic"
|
||||||
|
description="ntfy topic name for helpdesk ticket notifications."
|
||||||
|
showon="ntfy_enabled:1" />
|
||||||
|
<field name="ntfy_security_topic" type="text" default="mokosuite-security"
|
||||||
|
label="Security Topic"
|
||||||
|
description="ntfy topic name for security alert notifications. Falls back to ticket topic if empty."
|
||||||
|
showon="ntfy_enabled:1" />
|
||||||
|
<field name="ntfy_token" type="password" default=""
|
||||||
|
label="ntfy Auth Token"
|
||||||
|
description="Bearer token for authenticated ntfy topics. Leave empty for public topics."
|
||||||
|
showon="ntfy_enabled:1" />
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset name="helpdesk" label="Helpdesk Settings" description="Default helpdesk behavior.">
|
<fieldset name="helpdesk" label="Helpdesk Settings" description="Default helpdesk behavior.">
|
||||||
@@ -33,6 +69,44 @@
|
|||||||
<option value="1">JYES</option>
|
<option value="1">JYES</option>
|
||||||
<option value="0">JNO</option>
|
<option value="0">JNO</option>
|
||||||
</field>
|
</field>
|
||||||
|
<field name="satisfaction_enabled" type="radio" default="1"
|
||||||
|
label="Satisfaction Ratings"
|
||||||
|
description="Show rating prompt on resolved tickets."
|
||||||
|
class="btn-group btn-group-yesno">
|
||||||
|
<option value="1">JYES</option>
|
||||||
|
<option value="0">JNO</option>
|
||||||
|
</field>
|
||||||
|
<field name="max_attachment_size" type="number" default="10"
|
||||||
|
label="Max Attachment Size (MB)"
|
||||||
|
description="Maximum upload size per file in megabytes." />
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset name="email_to_ticket" label="Email-to-Ticket (IMAP)" description="Create tickets from incoming emails via IMAP polling.">
|
||||||
|
<field name="imap_host" type="text" default=""
|
||||||
|
label="IMAP Server"
|
||||||
|
description="IMAP hostname (e.g. imap.gmail.com)"
|
||||||
|
hint="imap.gmail.com" />
|
||||||
|
<field name="imap_port" type="number" default="993"
|
||||||
|
label="Port"
|
||||||
|
description="IMAP port (993 for SSL, 143 for plain)" />
|
||||||
|
<field name="imap_ssl" type="radio" default="1"
|
||||||
|
label="Use SSL"
|
||||||
|
class="btn-group btn-group-yesno">
|
||||||
|
<option value="1">JYES</option>
|
||||||
|
<option value="0">JNO</option>
|
||||||
|
</field>
|
||||||
|
<field name="imap_user" type="text" default=""
|
||||||
|
label="Username"
|
||||||
|
description="IMAP login username or email address." />
|
||||||
|
<field name="imap_password" type="password" default=""
|
||||||
|
label="Password"
|
||||||
|
description="IMAP password or app-specific password." />
|
||||||
|
<field name="imap_folder" type="text" default="INBOX"
|
||||||
|
label="Inbox Folder"
|
||||||
|
description="IMAP folder to poll for new messages." />
|
||||||
|
<field name="imap_processed_folder" type="text" default="INBOX.Processed"
|
||||||
|
label="Processed Folder"
|
||||||
|
description="Move processed emails to this folder. Leave empty to just mark as read." />
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset name="permissions" label="COM_MOKOSUITE_ACL_TITLE"
|
<fieldset name="permissions" label="COM_MOKOSUITE_ACL_TITLE"
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ CREATE TABLE IF NOT EXISTS `#__mokosuite_tickets` (
|
|||||||
`sla_response_due` DATETIME DEFAULT NULL,
|
`sla_response_due` DATETIME DEFAULT NULL,
|
||||||
`sla_resolution_due` DATETIME DEFAULT NULL,
|
`sla_resolution_due` DATETIME DEFAULT NULL,
|
||||||
`sla_responded` TINYINT NOT NULL DEFAULT 0,
|
`sla_responded` TINYINT NOT NULL DEFAULT 0,
|
||||||
|
`satisfaction_rating` TINYINT UNSIGNED DEFAULT NULL,
|
||||||
|
`satisfaction_feedback` TEXT DEFAULT NULL,
|
||||||
|
`satisfaction_rated_at` DATETIME DEFAULT NULL,
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
KEY `idx_status` (`status`),
|
KEY `idx_status` (`status`),
|
||||||
KEY `idx_status_id` (`status_id`),
|
KEY `idx_status_id` (`status_id`),
|
||||||
@@ -111,15 +114,32 @@ CREATE TABLE IF NOT EXISTS `#__mokosuite_ticket_canned` (
|
|||||||
PRIMARY KEY (`id`)
|
PRIMARY KEY (`id`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `#__mokosuite_ticket_attachments` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`ticket_id` INT UNSIGNED NOT NULL,
|
||||||
|
`reply_id` INT UNSIGNED DEFAULT NULL,
|
||||||
|
`filename` VARCHAR(255) NOT NULL,
|
||||||
|
`filepath` VARCHAR(512) NOT NULL,
|
||||||
|
`filesize` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
`mimetype` VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
|
`uploaded_by` INT NOT NULL DEFAULT 0,
|
||||||
|
`created` DATETIME NOT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_ticket` (`ticket_id`),
|
||||||
|
KEY `idx_reply` (`reply_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `#__mokosuite_ticket_automation` (
|
CREATE TABLE IF NOT EXISTS `#__mokosuite_ticket_automation` (
|
||||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
`title` VARCHAR(255) NOT NULL,
|
`title` VARCHAR(255) NOT NULL,
|
||||||
`trigger_event` VARCHAR(50) NOT NULL DEFAULT 'ticket_created',
|
`trigger_event` VARCHAR(50) NOT NULL DEFAULT 'ticket_created',
|
||||||
`conditions` TEXT NOT NULL DEFAULT '[]',
|
`conditions` TEXT NOT NULL,
|
||||||
`actions` TEXT NOT NULL DEFAULT '[]',
|
`actions` TEXT NOT NULL,
|
||||||
|
`behavior` ENUM('append','always_new','skip_if_open') NOT NULL DEFAULT 'append',
|
||||||
`enabled` TINYINT NOT NULL DEFAULT 1,
|
`enabled` TINYINT NOT NULL DEFAULT 1,
|
||||||
`ordering` INT NOT NULL DEFAULT 0,
|
`ordering` INT NOT NULL DEFAULT 0,
|
||||||
PRIMARY KEY (`id`)
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_trigger` (`trigger_event`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `#__mokosuite_ticket_assignees` (
|
CREATE TABLE IF NOT EXISTS `#__mokosuite_ticket_assignees` (
|
||||||
|
|||||||
@@ -526,6 +526,19 @@ class DisplayController extends BaseController
|
|||||||
$this->jsonResponse(['success' => true, 'message' => 'Category deleted.']);
|
$this->jsonResponse(['success' => true, 'message' => 'Category deleted.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function reorderCategory()
|
||||||
|
{
|
||||||
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
if (!$this->checkAcl('mokosuite.tickets')) { $this->jsonForbidden(); }
|
||||||
|
$order = json_decode(Factory::getApplication()->getInput()->getRaw('order', '[]'), true);
|
||||||
|
if (!is_array($order)) { $this->jsonResponse(['success' => false, 'message' => 'Invalid order']); return; }
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
foreach ($order as $i => $id) {
|
||||||
|
$db->setQuery('UPDATE ' . $db->quoteName('#__mokosuite_ticket_categories') . ' SET ordering = ' . (int) $i . ' WHERE id = ' . (int) $id)->execute();
|
||||||
|
}
|
||||||
|
$this->jsonResponse(['success' => true, 'message' => 'Order saved.']);
|
||||||
|
}
|
||||||
|
|
||||||
public function saveCanned()
|
public function saveCanned()
|
||||||
{
|
{
|
||||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
@@ -553,6 +566,83 @@ class DisplayController extends BaseController
|
|||||||
$this->jsonResponse(['success' => true, 'message' => 'Canned response deleted.']);
|
$this->jsonResponse(['success' => true, 'message' => 'Canned response deleted.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function reorderCanned()
|
||||||
|
{
|
||||||
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
if (!$this->checkAcl('mokosuite.tickets')) { $this->jsonForbidden(); }
|
||||||
|
$order = json_decode(Factory::getApplication()->getInput()->getRaw('order', '[]'), true);
|
||||||
|
if (!is_array($order)) { $this->jsonResponse(['success' => false, 'message' => 'Invalid order']); return; }
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
foreach ($order as $i => $id) {
|
||||||
|
$db->setQuery('UPDATE ' . $db->quoteName('#__mokosuite_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('mokosuite.tickets')) { $this->jsonForbidden(); }
|
||||||
|
$input = Factory::getApplication()->getInput();
|
||||||
|
$ticketId = $input->getInt('ticket_id', 0);
|
||||||
|
$replyId = $input->getInt('reply_id', 0) ?: null;
|
||||||
|
if (!$ticketId) { $this->jsonResponse(['success' => false, 'message' => 'Missing ticket_id']); return; }
|
||||||
|
$files = $input->files->get('attachments', [], 'raw');
|
||||||
|
if (empty($files) || empty($files['name'])) { $this->jsonResponse(['success' => false, 'message' => 'No files uploaded']); return; }
|
||||||
|
$saved = \Moko\Component\MokoSuite\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('mokosuite.tickets')) { $this->jsonForbidden(); }
|
||||||
|
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuite_ticket_attachments')->where('id = ' . $id));
|
||||||
|
$att = $db->loadObject();
|
||||||
|
if (!$att) { throw new \RuntimeException('Attachment not found', 404); }
|
||||||
|
$path = \Moko\Component\MokoSuite\Administrator\Service\AttachmentService::getAbsolutePath($att);
|
||||||
|
if (!file_exists($path)) { throw new \RuntimeException('File not found', 404); }
|
||||||
|
$app = Factory::getApplication();
|
||||||
|
$app->setHeader('Content-Type', $att->mimetype ?: 'application/octet-stream');
|
||||||
|
$app->setHeader('Content-Disposition', 'attachment; filename="' . $att->filename . '"');
|
||||||
|
$app->setHeader('Content-Length', (string) filesize($path));
|
||||||
|
$app->sendHeaders();
|
||||||
|
readfile($path);
|
||||||
|
$app->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteAttachment()
|
||||||
|
{
|
||||||
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
if (!$this->checkAcl('mokosuite.tickets')) { $this->jsonForbidden(); }
|
||||||
|
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||||
|
$ok = \Moko\Component\MokoSuite\Administrator\Service\AttachmentService::delete($id);
|
||||||
|
$this->jsonResponse(['success' => $ok, 'message' => $ok ? 'Attachment deleted' : 'Not found']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rateTicket()
|
||||||
|
{
|
||||||
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
$input = Factory::getApplication()->getInput();
|
||||||
|
$ticketId = $input->getInt('ticket_id', 0);
|
||||||
|
$rating = $input->getInt('rating', 0);
|
||||||
|
$feedback = $input->getString('feedback', '');
|
||||||
|
if (!$ticketId || $rating < 1 || $rating > 5) {
|
||||||
|
$this->jsonResponse(['success' => false, 'message' => 'Invalid rating (1-5)']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$db->setQuery(
|
||||||
|
'UPDATE ' . $db->quoteName('#__mokosuite_tickets')
|
||||||
|
. ' SET satisfaction_rating = ' . $rating
|
||||||
|
. ', satisfaction_feedback = ' . $db->quote($feedback)
|
||||||
|
. ', satisfaction_rated_at = ' . $db->quote(Factory::getDate()->toSql())
|
||||||
|
. ' WHERE id = ' . $ticketId
|
||||||
|
)->execute();
|
||||||
|
$this->jsonResponse(['success' => true, 'message' => 'Thank you for your feedback!']);
|
||||||
|
}
|
||||||
|
|
||||||
public function saveAutomation()
|
public function saveAutomation()
|
||||||
{
|
{
|
||||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
@@ -564,6 +654,7 @@ class DisplayController extends BaseController
|
|||||||
'trigger_event' => $input->getString('trigger_event', 'ticket_created'),
|
'trigger_event' => $input->getString('trigger_event', 'ticket_created'),
|
||||||
'conditions' => $input->getRaw('conditions', '[]'),
|
'conditions' => $input->getRaw('conditions', '[]'),
|
||||||
'actions' => $input->getRaw('actions', '[]'),
|
'actions' => $input->getRaw('actions', '[]'),
|
||||||
|
'behavior' => $input->getString('behavior', 'append'),
|
||||||
'enabled' => 1,
|
'enabled' => 1,
|
||||||
'ordering' => 0,
|
'ordering' => 0,
|
||||||
];
|
];
|
||||||
@@ -594,6 +685,19 @@ class DisplayController extends BaseController
|
|||||||
$this->jsonResponse(['success' => true, 'message' => 'Rule updated.']);
|
$this->jsonResponse(['success' => true, 'message' => 'Rule updated.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function reorderAutomation()
|
||||||
|
{
|
||||||
|
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||||
|
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); }
|
||||||
|
$order = json_decode(Factory::getApplication()->getInput()->getRaw('order', '[]'), true);
|
||||||
|
if (!is_array($order)) { $this->jsonResponse(['success' => false, 'message' => 'Invalid order']); return; }
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
foreach ($order as $i => $id) {
|
||||||
|
$db->setQuery('UPDATE ' . $db->quoteName('#__mokosuite_ticket_automation') . ' SET ordering = ' . (int) $i . ' WHERE id = ' . (int) $id)->execute();
|
||||||
|
}
|
||||||
|
$this->jsonResponse(['success' => true, 'message' => 'Order saved.']);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================================================================
|
// ==================================================================
|
||||||
// Settings Import/Export (#132)
|
// Settings Import/Export (#132)
|
||||||
// ==================================================================
|
// ==================================================================
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoSuite
|
||||||
|
* @subpackage com_mokosuite
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoSuite\Administrator\Service;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\Filesystem\File;
|
||||||
|
use Joomla\CMS\Filesystem\Folder;
|
||||||
|
use Joomla\CMS\Log\Log;
|
||||||
|
|
||||||
|
class AttachmentService
|
||||||
|
{
|
||||||
|
private const STORAGE_DIR = JPATH_ROOT . '/media/com_mokosuite/attachments';
|
||||||
|
|
||||||
|
private const ALLOWED_EXTENSIONS = [
|
||||||
|
'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg',
|
||||||
|
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'txt', 'rtf',
|
||||||
|
'zip', 'gz', 'tar',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload file(s) for a ticket or reply.
|
||||||
|
*
|
||||||
|
* @param int $ticketId Ticket ID
|
||||||
|
* @param int|null $replyId Reply ID (null for ticket-level attachments)
|
||||||
|
* @param array $files $_FILES array entry (single or multi)
|
||||||
|
* @return array Saved attachment records
|
||||||
|
*/
|
||||||
|
public static function upload(int $ticketId, ?int $replyId, array $files): array
|
||||||
|
{
|
||||||
|
$saved = [];
|
||||||
|
|
||||||
|
// Normalize single file to array format
|
||||||
|
if (!is_array($files['name'])) {
|
||||||
|
$files = [
|
||||||
|
'name' => [$files['name']],
|
||||||
|
'type' => [$files['type']],
|
||||||
|
'tmp_name' => [$files['tmp_name']],
|
||||||
|
'error' => [$files['error']],
|
||||||
|
'size' => [$files['size']],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketDir = self::STORAGE_DIR . '/' . $ticketId;
|
||||||
|
|
||||||
|
if (!is_dir($ticketDir)) {
|
||||||
|
Folder::create($ticketDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = (int) Factory::getUser()->id;
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
|
||||||
|
for ($i = 0, $count = count($files['name']); $i < $count; $i++)
|
||||||
|
{
|
||||||
|
if ($files['error'][$i] !== UPLOAD_ERR_OK) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$originalName = File::makeSafe($files['name'][$i]);
|
||||||
|
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
|
||||||
|
|
||||||
|
// Validate extension
|
||||||
|
if (!in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
|
||||||
|
Log::add("Attachment rejected: disallowed extension .{$ext}", Log::WARNING, 'mokosuite');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate size
|
||||||
|
if ($files['size'][$i] > self::MAX_FILE_SIZE) {
|
||||||
|
Log::add("Attachment rejected: file too large ({$files['size'][$i]} bytes)", Log::WARNING, 'mokosuite');
|
||||||
|
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, 'mokosuite');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record = (object) [
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'reply_id' => $replyId,
|
||||||
|
'filename' => $originalName,
|
||||||
|
'filepath' => $ticketId . '/' . $storedName,
|
||||||
|
'filesize' => $files['size'][$i],
|
||||||
|
'mimetype' => $files['type'][$i],
|
||||||
|
'uploaded_by' => $userId,
|
||||||
|
'created' => Factory::getDate()->toSql(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$db->insertObject('#__mokosuite_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('#__mokosuite_ticket_attachments', 'a'))
|
||||||
|
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = a.uploaded_by')
|
||||||
|
->where($db->quoteName('a.ticket_id') . ' = ' . $ticketId)
|
||||||
|
->order('a.created ASC')
|
||||||
|
);
|
||||||
|
return $db->loadObjectList() ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the absolute filesystem path for an attachment.
|
||||||
|
*/
|
||||||
|
public static function getAbsolutePath(object $attachment): string
|
||||||
|
{
|
||||||
|
return self::STORAGE_DIR . '/' . $attachment->filepath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an attachment (file + DB record).
|
||||||
|
*/
|
||||||
|
public static function delete(int $attachmentId): bool
|
||||||
|
{
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$db->setQuery(
|
||||||
|
$db->getQuery(true)
|
||||||
|
->select('*')
|
||||||
|
->from('#__mokosuite_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('#__mokosuite_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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoSuite
|
||||||
|
* @subpackage com_mokosuite
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoSuite\Administrator\Service;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\Log\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automation rule engine — evaluates trigger/condition/action rules.
|
||||||
|
*
|
||||||
|
* Called from event hooks (system plugin, task plugin) whenever
|
||||||
|
* a triggering event occurs. Loads matching rules, checks conditions,
|
||||||
|
* and executes actions.
|
||||||
|
*
|
||||||
|
* @since 02.35.00
|
||||||
|
*/
|
||||||
|
class AutomationEngine
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Fire all matching rules for a given trigger event.
|
||||||
|
*
|
||||||
|
* @param string $triggerEvent Event name (ticket_created, user_login, etc.)
|
||||||
|
* @param array $context Context data (ticket object, user data, etc.)
|
||||||
|
*/
|
||||||
|
public static function fire(string $triggerEvent, array $context = []): void
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
$rules = self::getActiveRules($triggerEvent);
|
||||||
|
|
||||||
|
foreach ($rules as $rule)
|
||||||
|
{
|
||||||
|
$conditions = json_decode($rule->conditions, true) ?: [];
|
||||||
|
$actions = json_decode($rule->actions, true) ?: [];
|
||||||
|
|
||||||
|
if (self::evaluateConditions($conditions, $context))
|
||||||
|
{
|
||||||
|
self::executeActions($actions, $rule, $context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (\Throwable $e)
|
||||||
|
{
|
||||||
|
Log::add('Automation engine error: ' . $e->getMessage(), Log::ERROR, 'mokosuite');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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('#__mokosuite_ticket_automation')
|
||||||
|
->where($db->quoteName('trigger_event') . ' = ' . $db->quote($event))
|
||||||
|
->where($db->quoteName('enabled') . ' = 1')
|
||||||
|
->order('ordering ASC')
|
||||||
|
);
|
||||||
|
return $db->loadObjectList() ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate all conditions (AND logic).
|
||||||
|
*/
|
||||||
|
private static function evaluateConditions(array $conditions, array $context): bool
|
||||||
|
{
|
||||||
|
foreach ($conditions as $c)
|
||||||
|
{
|
||||||
|
$field = $c['field'] ?? '';
|
||||||
|
$op = $c['op'] ?? 'eq';
|
||||||
|
$expected = $c['value'] ?? '';
|
||||||
|
$actual = $context[$field] ?? '';
|
||||||
|
|
||||||
|
switch ($op)
|
||||||
|
{
|
||||||
|
case 'eq': if ((string) $actual !== (string) $expected) return false; break;
|
||||||
|
case 'neq': if ((string) $actual === (string) $expected) return false; break;
|
||||||
|
case 'gt': if ((float) $actual <= (float) $expected) return false; break;
|
||||||
|
case 'lt': if ((float) $actual >= (float) $expected) return false; break;
|
||||||
|
case 'in':
|
||||||
|
$values = array_map('trim', explode(',', $expected));
|
||||||
|
if (!in_array((string) $actual, $values, true)) return false;
|
||||||
|
break;
|
||||||
|
case 'not_in':
|
||||||
|
$values = array_map('trim', explode(',', $expected));
|
||||||
|
if (in_array((string) $actual, $values, true)) return false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute actions for a matched rule.
|
||||||
|
*/
|
||||||
|
private static function executeActions(array $actions, object $rule, array $context): void
|
||||||
|
{
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$ticketId = (int) ($context['ticket_id'] ?? $context['id'] ?? 0);
|
||||||
|
|
||||||
|
foreach ($actions as $action)
|
||||||
|
{
|
||||||
|
$type = $action['type'] ?? '';
|
||||||
|
$value = $action['value'] ?? '';
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
switch ($type)
|
||||||
|
{
|
||||||
|
case 'set_status':
|
||||||
|
if ($ticketId) {
|
||||||
|
$db->setQuery("UPDATE {$db->quoteName('#__mokosuite_tickets')} SET status = {$db->quote($value)}, modified = {$db->quote(Factory::getDate()->toSql())} WHERE id = {$ticketId}")->execute();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'set_priority':
|
||||||
|
if ($ticketId) {
|
||||||
|
$db->setQuery("UPDATE {$db->quoteName('#__mokosuite_tickets')} SET priority = {$db->quote($value)}, modified = {$db->quote(Factory::getDate()->toSql())} WHERE id = {$ticketId}")->execute();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'assign':
|
||||||
|
if ($ticketId) {
|
||||||
|
$db->setQuery("UPDATE {$db->quoteName('#__mokosuite_tickets')} SET assigned_to = {$db->quote($value)}, modified = {$db->quote(Factory::getDate()->toSql())} WHERE id = {$ticketId}")->execute();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'add_note':
|
||||||
|
if ($ticketId) {
|
||||||
|
$note = (object) [
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'user_id' => 0,
|
||||||
|
'body' => $value ?: '[Automation: ' . ($rule->title ?? '') . ']',
|
||||||
|
'is_internal' => 1,
|
||||||
|
'created' => Factory::getDate()->toSql(),
|
||||||
|
];
|
||||||
|
$db->insertObject('#__mokosuite_ticket_replies', $note);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'send_email':
|
||||||
|
NotificationService::securityAlert(
|
||||||
|
'automation',
|
||||||
|
'Automation: ' . ($rule->title ?? ''),
|
||||||
|
$value ?: 'Rule triggered for ticket #' . $ticketId
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'send_ntfy':
|
||||||
|
NotificationService::pushNtfySecurity(
|
||||||
|
'automation',
|
||||||
|
'Automation: ' . ($rule->title ?? ''),
|
||||||
|
$value ?: 'Rule triggered for ticket #' . $ticketId
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'close':
|
||||||
|
if ($ticketId) {
|
||||||
|
$db->setQuery("UPDATE {$db->quoteName('#__mokosuite_tickets')} SET status = 'closed', closed = {$db->quote(Factory::getDate()->toSql())}, modified = {$db->quote(Factory::getDate()->toSql())} WHERE id = {$ticketId}")->execute();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'create_ticket':
|
||||||
|
self::createTicketFromAutomation($rule, $context, $value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (\Throwable $e)
|
||||||
|
{
|
||||||
|
Log::add("Automation action {$type} failed: " . $e->getMessage(), Log::WARNING, 'mokosuite');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a ticket from automation (with behavior: append/always_new/skip_if_open).
|
||||||
|
*/
|
||||||
|
private static function createTicketFromAutomation(object $rule, array $context, string $subject): void
|
||||||
|
{
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$behavior = $rule->behavior ?? 'append';
|
||||||
|
$userId = (int) ($context['user_id'] ?? 0);
|
||||||
|
$catId = (int) ($context['category_id'] ?? 0);
|
||||||
|
|
||||||
|
if ($behavior !== 'always_new' && $userId > 0)
|
||||||
|
{
|
||||||
|
// Check for existing open ticket
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select('id')
|
||||||
|
->from('#__mokosuite_tickets')
|
||||||
|
->where('created_by = ' . $userId)
|
||||||
|
->where("status NOT IN ('closed', 'resolved')");
|
||||||
|
|
||||||
|
if ($catId > 0) {
|
||||||
|
$query->where('category_id = ' . $catId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->setQuery($query, 0, 1);
|
||||||
|
$existingId = (int) $db->loadResult();
|
||||||
|
|
||||||
|
if ($existingId > 0)
|
||||||
|
{
|
||||||
|
if ($behavior === 'skip_if_open') return;
|
||||||
|
|
||||||
|
// append — add reply to existing ticket
|
||||||
|
$reply = (object) [
|
||||||
|
'ticket_id' => $existingId,
|
||||||
|
'user_id' => 0,
|
||||||
|
'body' => $subject ?: '[Automation: ' . ($rule->title ?? '') . ']',
|
||||||
|
'is_internal' => 1,
|
||||||
|
'created' => Factory::getDate()->toSql(),
|
||||||
|
];
|
||||||
|
$db->insertObject('#__mokosuite_ticket_replies', $reply);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new ticket
|
||||||
|
$ticket = (object) [
|
||||||
|
'subject' => $subject ?: 'Automation: ' . ($rule->title ?? ''),
|
||||||
|
'body' => $context['body'] ?? '',
|
||||||
|
'status' => 'open',
|
||||||
|
'priority' => $context['priority'] ?? 'normal',
|
||||||
|
'category_id' => $catId ?: null,
|
||||||
|
'created_by' => $userId,
|
||||||
|
'created' => Factory::getDate()->toSql(),
|
||||||
|
];
|
||||||
|
$db->insertObject('#__mokosuite_tickets', $ticket, 'id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,6 +70,9 @@ class NotificationService
|
|||||||
Log::add('Notification send failed to ' . $email . ': ' . $e->getMessage(), Log::WARNING, 'mokosuite');
|
Log::add('Notification send failed to ' . $email . ': ' . $e->getMessage(), Log::WARNING, 'mokosuite');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push notification via ntfy
|
||||||
|
self::pushNtfy($event, $ticket, $subject);
|
||||||
}
|
}
|
||||||
catch (\Throwable $e)
|
catch (\Throwable $e)
|
||||||
{
|
{
|
||||||
@@ -332,6 +335,159 @@ class NotificationService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================================================================
|
||||||
|
// Ntfy Push Notifications (#205)
|
||||||
|
// ==================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a push notification via ntfy for ticket events.
|
||||||
|
*/
|
||||||
|
private static function pushNtfy(string $event, object $ticket, string $title): void
|
||||||
|
{
|
||||||
|
$config = self::getNotificationConfig();
|
||||||
|
$ntfyEnabled = $config['ntfy_enabled'] ?? '0';
|
||||||
|
|
||||||
|
if (!$ntfyEnabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ntfyServer = rtrim($config['ntfy_server'] ?? 'https://ntfy.mokoconsulting.tech', '/');
|
||||||
|
$ntfyTopic = $config['ntfy_topic'] ?? 'mokosuite-tickets';
|
||||||
|
$ntfyToken = $config['ntfy_token'] ?? '';
|
||||||
|
|
||||||
|
$tagMap = [
|
||||||
|
'ticket_created' => 'ticket,new',
|
||||||
|
'ticket_replied' => 'speech_balloon',
|
||||||
|
'status_changed' => 'arrows_counterclockwise',
|
||||||
|
'ticket_assigned' => 'bust_in_silhouette',
|
||||||
|
];
|
||||||
|
|
||||||
|
$priorityMap = [
|
||||||
|
'ticket_created' => '4',
|
||||||
|
'ticket_replied' => '3',
|
||||||
|
'status_changed' => '3',
|
||||||
|
'ticket_assigned' => '3',
|
||||||
|
];
|
||||||
|
|
||||||
|
$siteUrl = rtrim(Uri::root(), '/');
|
||||||
|
$ticketUrl = $siteUrl . '/administrator/index.php?option=com_mokosuite&view=ticket&id=' . ($ticket->id ?? 0);
|
||||||
|
|
||||||
|
$message = self::buildNtfyMessage($event, $ticket);
|
||||||
|
|
||||||
|
$headers = [
|
||||||
|
'Title: ' . $title,
|
||||||
|
'Priority: ' . ($priorityMap[$event] ?? '3'),
|
||||||
|
'Tags: ' . ($tagMap[$event] ?? 'ticket'),
|
||||||
|
'Click: ' . $ticketUrl,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($ntfyToken !== '')
|
||||||
|
{
|
||||||
|
$headers[] = 'Authorization: Bearer ' . $ntfyToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = $ntfyServer . '/' . $ntfyTopic;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt($ch, CURLOPT_POST, true);
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $message);
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
|
||||||
|
curl_exec($ch);
|
||||||
|
|
||||||
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($httpCode < 200 || $httpCode >= 300)
|
||||||
|
{
|
||||||
|
Log::add("Ntfy push failed (HTTP {$httpCode}) for event {$event}", Log::WARNING, 'mokosuite');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (\Throwable $e)
|
||||||
|
{
|
||||||
|
Log::add('Ntfy push error: ' . $e->getMessage(), Log::WARNING, 'mokosuite');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a short ntfy message body for ticket events.
|
||||||
|
*/
|
||||||
|
private static function buildNtfyMessage(string $event, object $ticket): string
|
||||||
|
{
|
||||||
|
$subject = $ticket->subject ?? 'Ticket #' . ($ticket->id ?? '?');
|
||||||
|
|
||||||
|
switch ($event)
|
||||||
|
{
|
||||||
|
case 'ticket_created':
|
||||||
|
$priority = ucfirst($ticket->priority ?? 'normal');
|
||||||
|
return "New ticket: {$subject}\nPriority: {$priority}";
|
||||||
|
|
||||||
|
case 'ticket_replied':
|
||||||
|
return "Reply on: {$subject}";
|
||||||
|
|
||||||
|
case 'status_changed':
|
||||||
|
$status = ucwords(str_replace('_', ' ', $ticket->status ?? ''));
|
||||||
|
return "Status → {$status}: {$subject}";
|
||||||
|
|
||||||
|
case 'ticket_assigned':
|
||||||
|
return "Assigned to you: {$subject}";
|
||||||
|
|
||||||
|
default:
|
||||||
|
return $subject;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a push notification via ntfy for security events.
|
||||||
|
*/
|
||||||
|
public static function pushNtfySecurity(string $event, string $title, string $body): void
|
||||||
|
{
|
||||||
|
$config = self::getNotificationConfig();
|
||||||
|
$ntfyEnabled = $config['ntfy_enabled'] ?? '0';
|
||||||
|
|
||||||
|
if (!$ntfyEnabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ntfyServer = rtrim($config['ntfy_server'] ?? 'https://ntfy.mokoconsulting.tech', '/');
|
||||||
|
$ntfyTopic = $config['ntfy_security_topic'] ?? $config['ntfy_topic'] ?? 'mokosuite-security';
|
||||||
|
$ntfyToken = $config['ntfy_token'] ?? '';
|
||||||
|
|
||||||
|
$headers = [
|
||||||
|
'Title: [Security] ' . $title,
|
||||||
|
'Priority: 5',
|
||||||
|
'Tags: warning,shield',
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($ntfyToken !== '')
|
||||||
|
{
|
||||||
|
$headers[] = 'Authorization: Bearer ' . $ntfyToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = $ntfyServer . '/' . $ntfyTopic;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt($ch, CURLOPT_POST, true);
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
|
||||||
|
curl_exec($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
}
|
||||||
|
catch (\Throwable $e)
|
||||||
|
{
|
||||||
|
Log::add('Ntfy security push error: ' . $e->getMessage(), Log::WARNING, 'mokosuite');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================================================================
|
// ==================================================================
|
||||||
// Security Event Notifications (#131)
|
// Security Event Notifications (#131)
|
||||||
// ==================================================================
|
// ==================================================================
|
||||||
@@ -407,6 +563,9 @@ class NotificationService
|
|||||||
Log::add('Security alert send failed: ' . $e->getMessage(), Log::WARNING, 'mokosuite');
|
Log::add('Security alert send failed: ' . $e->getMessage(), Log::WARNING, 'mokosuite');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also push via ntfy
|
||||||
|
self::pushNtfySecurity($event, $subject, $body);
|
||||||
}
|
}
|
||||||
catch (\Throwable $e)
|
catch (\Throwable $e)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class HtmlView extends BaseHtmlView
|
|||||||
protected $priorities = [];
|
protected $priorities = [];
|
||||||
protected $customFields = [];
|
protected $customFields = [];
|
||||||
protected $fieldValues = [];
|
protected $fieldValues = [];
|
||||||
|
protected $attachments = [];
|
||||||
|
|
||||||
public function display($tpl = null)
|
public function display($tpl = null)
|
||||||
{
|
{
|
||||||
@@ -43,6 +44,9 @@ class HtmlView extends BaseHtmlView
|
|||||||
$this->fieldValues = $model->getFieldValues($id);
|
$this->fieldValues = $model->getFieldValues($id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load attachments
|
||||||
|
$this->attachments = \Moko\Component\MokoSuite\Administrator\Service\AttachmentService::getForTicket($id);
|
||||||
|
|
||||||
if (!$this->ticket)
|
if (!$this->ticket)
|
||||||
{
|
{
|
||||||
Factory::getApplication()->enqueueMessage('Ticket not found.', 'error');
|
Factory::getApplication()->enqueueMessage('Ticket not found.', 'error');
|
||||||
|
|||||||
@@ -9,81 +9,110 @@ $token = Session::getFormToken();
|
|||||||
$saveUrl = Route::_('index.php?option=com_mokosuite&task=display.saveAutomation&format=json');
|
$saveUrl = Route::_('index.php?option=com_mokosuite&task=display.saveAutomation&format=json');
|
||||||
$deleteUrl = Route::_('index.php?option=com_mokosuite&task=display.deleteAutomation&format=json');
|
$deleteUrl = Route::_('index.php?option=com_mokosuite&task=display.deleteAutomation&format=json');
|
||||||
$toggleUrl = Route::_('index.php?option=com_mokosuite&task=display.toggleAutomation&format=json');
|
$toggleUrl = Route::_('index.php?option=com_mokosuite&task=display.toggleAutomation&format=json');
|
||||||
|
$reorderUrl = Route::_('index.php?option=com_mokosuite&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'];
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div id="mokosuite-automation">
|
<div id="mokosuite-automation">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h4><?php echo count($rules); ?> Automation Rules</h4>
|
<h4><?php echo count($rules); ?> Automation Rules</h4>
|
||||||
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newRuleModal">
|
<button type="button" class="btn btn-primary btn-sm" onclick="openRuleModal(0)">
|
||||||
<span class="icon-plus"></span> Add Rule
|
<span class="icon-plus"></span> Add Rule
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php foreach ($rules as $r): ?>
|
<div id="rules-list">
|
||||||
<?php $conditions = json_decode($r->conditions, true) ?: []; $actions = json_decode($r->actions, true) ?: []; ?>
|
<?php foreach ($rules as $r): ?>
|
||||||
<div class="card mb-2 <?php echo !$r->enabled ? 'opacity-50' : ''; ?>" data-id="<?php echo $r->id; ?>">
|
<?php $conditions = json_decode($r->conditions, true) ?: []; $actions = json_decode($r->actions, true) ?: []; ?>
|
||||||
<div class="card-body py-2">
|
<div class="card mb-2 rule-card <?php echo !$r->enabled ? 'opacity-50' : ''; ?>" data-id="<?php echo $r->id; ?>" draggable="true">
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
<div class="card-body py-2">
|
||||||
<div>
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="flex-grow-1" style="cursor:pointer;" onclick="openRuleModal(<?php echo $r->id; ?>)">
|
||||||
<div class="form-check form-switch">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<input type="checkbox" class="form-check-input rule-toggle" data-id="<?php echo $r->id; ?>" <?php echo $r->enabled ? 'checked' : ''; ?>>
|
<span class="icon-menu text-muted" style="cursor:grab;"></span>
|
||||||
|
<div class="form-check form-switch" onclick="event.stopPropagation();">
|
||||||
|
<input type="checkbox" class="form-check-input rule-toggle" data-id="<?php echo $r->id; ?>" <?php echo $r->enabled ? 'checked' : ''; ?>>
|
||||||
|
</div>
|
||||||
|
<strong><?php echo htmlspecialchars($r->title); ?></strong>
|
||||||
|
<span class="badge bg-secondary"><?php echo $triggerLabels[$r->trigger_event] ?? $r->trigger_event; ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted mt-1 ms-4">
|
||||||
|
<?php if (!empty($conditions)): ?>
|
||||||
|
<span class="text-primary">IF</span>
|
||||||
|
<?php foreach ($conditions as $i => $c): ?>
|
||||||
|
<?php echo $i > 0 ? ' AND ' : ''; ?><code><?php echo htmlspecialchars($c['field'] ?? ''); ?></code> <?php echo $conditionOps[$c['op'] ?? ''] ?? $c['op'] ?? ''; ?> <em><?php echo htmlspecialchars($c['value'] ?? ''); ?></em>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
<span class="text-success ms-1">THEN</span>
|
||||||
|
<?php foreach ($actions as $a): ?>
|
||||||
|
<code><?php echo htmlspecialchars($a['type'] ?? ''); ?></code><?php if (!empty($a['value'])): ?>=<em><?php echo htmlspecialchars(mb_substr($a['value'], 0, 30)); ?></em><?php endif; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
<strong><?php echo htmlspecialchars($r->title); ?></strong>
|
|
||||||
<span class="badge bg-secondary"><?php echo $triggerLabels[$r->trigger_event] ?? $r->trigger_event; ?></span>
|
|
||||||
</div>
|
|
||||||
<div class="small text-muted mt-1">
|
|
||||||
<span class="text-primary">IF</span>
|
|
||||||
<?php foreach ($conditions as $i => $c): ?>
|
|
||||||
<?php echo $i > 0 ? ' AND ' : ''; ?><?php echo htmlspecialchars($c['field'] ?? ''); ?> <?php echo htmlspecialchars($c['op'] ?? ''); ?> <?php echo htmlspecialchars($c['value'] ?? ''); ?>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<span class="text-success ms-2">THEN</span>
|
|
||||||
<?php foreach ($actions as $a): ?>
|
|
||||||
<?php echo htmlspecialchars($a['type'] ?? ''); ?>=<?php echo htmlspecialchars(mb_substr($a['value'] ?? '', 0, 30)); ?>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-rule" data-id="<?php echo $r->id; ?>" onclick="event.stopPropagation();">
|
||||||
|
<span class="icon-trash"></span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-rule" data-id="<?php echo $r->id; ?>">
|
|
||||||
<span class="icon-trash"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<?php endforeach; ?>
|
||||||
<?php endforeach; ?>
|
|
||||||
|
|
||||||
<?php if (empty($rules)): ?>
|
<?php if (empty($rules)): ?>
|
||||||
<div class="alert alert-info">No automation rules. Click "Add Rule" to create one.</div>
|
<div class="alert alert-info">No automation rules. Click "Add Rule" to create one.</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- New Rule Modal -->
|
<!-- Rule Modal -->
|
||||||
<div class="modal fade" id="newRuleModal" tabindex="-1">
|
<div class="modal fade" id="ruleModal" tabindex="-1">
|
||||||
<div class="modal-dialog modal-lg">
|
<div class="modal-dialog modal-lg">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header"><h5>Add Automation Rule</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
<div class="modal-header"><h5 id="ruleModalTitle">Add Automation Rule</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="mb-3">
|
<input type="hidden" id="rule-id" value="0">
|
||||||
<label class="form-label">Title</label>
|
<div class="row mb-3">
|
||||||
<input type="text" id="rule-title" class="form-control" required>
|
<div class="col-5">
|
||||||
</div>
|
<label class="form-label">Title</label>
|
||||||
<div class="mb-3">
|
<input type="text" id="rule-title" class="form-control" required>
|
||||||
<label class="form-label">Trigger</label>
|
</div>
|
||||||
<select id="rule-trigger" class="form-select">
|
<div class="col-4">
|
||||||
<?php foreach ($triggerLabels as $k => $v): ?><option value="<?php echo $k; ?>"><?php echo $v; ?></option><?php endforeach; ?>
|
<label class="form-label">Trigger</label>
|
||||||
</select>
|
<select id="rule-trigger" class="form-select">
|
||||||
</div>
|
<?php foreach ($triggerLabels as $k => $v): ?><option value="<?php echo $k; ?>"><?php echo $v; ?></option><?php endforeach; ?>
|
||||||
<div class="mb-3">
|
</select>
|
||||||
<label class="form-label">Conditions (JSON)</label>
|
</div>
|
||||||
<textarea id="rule-conditions" class="form-control font-monospace" rows="3" placeholder='[{"field":"status","op":"eq","value":"resolved"}]'></textarea>
|
<div class="col-3">
|
||||||
<small class="text-muted">Fields: status, priority, category_id, assigned_to, sla_responded, age_hours. Ops: eq, neq, gt, lt, in, not_in</small>
|
<label class="form-label">Behavior</label>
|
||||||
</div>
|
<select id="rule-behavior" class="form-select">
|
||||||
<div class="mb-3">
|
<option value="append">Append to existing</option>
|
||||||
<label class="form-label">Actions (JSON)</label>
|
<option value="always_new">Always new ticket</option>
|
||||||
<textarea id="rule-actions" class="form-control font-monospace" rows="3" placeholder='[{"type":"set_status","value":"closed"}]'></textarea>
|
<option value="skip_if_open">Skip if open</option>
|
||||||
<small class="text-muted">Types: set_status, set_priority, assign, add_note, send_email</small>
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label class="form-label">Conditions <small class="text-muted">(all must match)</small></label>
|
||||||
|
<div id="conditions-builder" class="mb-3"></div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary mb-3" onclick="addConditionRow()"><span class="icon-plus"></span> Add Condition</button>
|
||||||
|
|
||||||
|
<label class="form-label">Actions</label>
|
||||||
|
<div id="actions-builder" class="mb-3"></div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="addActionRow()"><span class="icon-plus"></span> Add Action</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
@@ -95,47 +124,174 @@ $triggerLabels = ['ticket_created' => 'On Ticket Created', 'ticket_replied' => '
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
var token = '<?php echo $token; ?>';
|
var tokenKey = '<?php echo $token; ?>';
|
||||||
|
var condFields = <?php echo json_encode($conditionFields); ?>;
|
||||||
|
var condOps = <?php echo json_encode($conditionOps); ?>;
|
||||||
|
var actTypes = <?php echo json_encode($actionTypes); ?>;
|
||||||
|
|
||||||
// Save new rule
|
// Rule data store for editing
|
||||||
|
var ruleData = {};
|
||||||
|
<?php foreach ($rules as $r): ?>
|
||||||
|
ruleData[<?php echo $r->id; ?>] = {
|
||||||
|
title: <?php echo json_encode($r->title); ?>,
|
||||||
|
trigger_event: <?php echo json_encode($r->trigger_event); ?>,
|
||||||
|
behavior: <?php echo json_encode($r->behavior ?? 'append'); ?>,
|
||||||
|
conditions: <?php echo $r->conditions ?: '[]'; ?>,
|
||||||
|
actions: <?php echo $r->actions ?: '[]'; ?>
|
||||||
|
};
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
// ── Builder helpers ─────────────────────────────────────────
|
||||||
|
function makeSelect(cls, options, selected) {
|
||||||
|
var sel = document.createElement('select');
|
||||||
|
sel.className = 'form-select ' + cls;
|
||||||
|
options.forEach(function(o) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = o.value;
|
||||||
|
opt.textContent = o.label;
|
||||||
|
if (o.value === selected) opt.selected = true;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
return sel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRemoveBtn() {
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'btn btn-outline-danger';
|
||||||
|
btn.innerHTML = '<span class="icon-minus"></span>';
|
||||||
|
btn.addEventListener('click', function() { this.parentNode.remove(); });
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addConditionRow = function(field, op, value) {
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.className = 'input-group input-group-sm mb-1';
|
||||||
|
div.appendChild(makeSelect('cond-field', condFields.map(function(f){return {value:f, label:f}}), field));
|
||||||
|
div.appendChild(makeSelect('cond-op', Object.keys(condOps).map(function(k){return {value:k, label:condOps[k]}}), op));
|
||||||
|
var inp = document.createElement('input');
|
||||||
|
inp.type = 'text'; inp.className = 'form-control cond-value'; inp.placeholder = 'value'; inp.value = value || '';
|
||||||
|
div.appendChild(inp);
|
||||||
|
div.appendChild(makeRemoveBtn());
|
||||||
|
document.getElementById('conditions-builder').appendChild(div);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addActionRow = function(type, value) {
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.className = 'input-group input-group-sm mb-1';
|
||||||
|
div.appendChild(makeSelect('act-type', actTypes.map(function(t){return {value:t, label:t}}), type));
|
||||||
|
var inp = document.createElement('input');
|
||||||
|
inp.type = 'text'; inp.className = 'form-control act-value'; inp.placeholder = 'value'; inp.value = value || '';
|
||||||
|
div.appendChild(inp);
|
||||||
|
div.appendChild(makeRemoveBtn());
|
||||||
|
document.getElementById('actions-builder').appendChild(div);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Open modal ──────────────────────────────────────────────
|
||||||
|
window.openRuleModal = function(id) {
|
||||||
|
document.getElementById('rule-id').value = id;
|
||||||
|
document.getElementById('conditions-builder').innerHTML = '';
|
||||||
|
document.getElementById('actions-builder').innerHTML = '';
|
||||||
|
|
||||||
|
if (id > 0 && ruleData[id]) {
|
||||||
|
document.getElementById('ruleModalTitle').textContent = 'Edit Automation Rule';
|
||||||
|
document.getElementById('rule-title').value = ruleData[id].title;
|
||||||
|
document.getElementById('rule-trigger').value = ruleData[id].trigger_event;
|
||||||
|
document.getElementById('rule-behavior').value = ruleData[id].behavior || 'append';
|
||||||
|
ruleData[id].conditions.forEach(function(c) { addConditionRow(c.field, c.op, c.value); });
|
||||||
|
ruleData[id].actions.forEach(function(a) { addActionRow(a.type, a.value); });
|
||||||
|
} else {
|
||||||
|
document.getElementById('ruleModalTitle').textContent = 'Add Automation Rule';
|
||||||
|
document.getElementById('rule-title').value = '';
|
||||||
|
document.getElementById('rule-trigger').value = 'ticket_created';
|
||||||
|
document.getElementById('rule-behavior').value = 'append';
|
||||||
|
addConditionRow();
|
||||||
|
addActionRow();
|
||||||
|
}
|
||||||
|
new bootstrap.Modal(document.getElementById('ruleModal')).show();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Save rule ───────────────────────────────────────────────
|
||||||
document.getElementById('btn-save-rule').addEventListener('click', function() {
|
document.getElementById('btn-save-rule').addEventListener('click', function() {
|
||||||
|
var conditions = [];
|
||||||
|
document.querySelectorAll('#conditions-builder .input-group').forEach(function(row) {
|
||||||
|
var f = row.querySelector('.cond-field').value;
|
||||||
|
var o = row.querySelector('.cond-op').value;
|
||||||
|
var v = row.querySelector('.cond-value').value;
|
||||||
|
if (f && v) conditions.push({field:f, op:o, value:v});
|
||||||
|
});
|
||||||
|
var actions = [];
|
||||||
|
document.querySelectorAll('#actions-builder .input-group').forEach(function(row) {
|
||||||
|
var t = row.querySelector('.act-type').value;
|
||||||
|
var v = row.querySelector('.act-value').value;
|
||||||
|
if (t) actions.push({type:t, value:v});
|
||||||
|
});
|
||||||
|
|
||||||
var fd = new FormData();
|
var fd = new FormData();
|
||||||
fd.append('id', '0');
|
fd.append('id', document.getElementById('rule-id').value);
|
||||||
fd.append('title', document.getElementById('rule-title').value);
|
fd.append('title', document.getElementById('rule-title').value);
|
||||||
fd.append('trigger_event', document.getElementById('rule-trigger').value);
|
fd.append('trigger_event', document.getElementById('rule-trigger').value);
|
||||||
fd.append('conditions', document.getElementById('rule-conditions').value || '[]');
|
fd.append('behavior', document.getElementById('rule-behavior').value);
|
||||||
fd.append('actions', document.getElementById('rule-actions').value || '[]');
|
fd.append('conditions', JSON.stringify(conditions));
|
||||||
fd.append(token, '1');
|
fd.append('actions', JSON.stringify(actions));
|
||||||
|
fd.append(tokenKey, '1');
|
||||||
fetch('<?php echo $saveUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
fetch('<?php echo $saveUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||||
.then(function(r){return r.json()})
|
.then(function(r){return r.json()})
|
||||||
.then(function(d){ if (d.success) location.reload(); else Joomla.renderMessages({error:[d.message]}); });
|
.then(function(d){ if (d.success) location.reload(); else Joomla.renderMessages({error:[d.message]}); });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Toggle rule
|
// ── Toggle ──────────────────────────────────────────────────
|
||||||
document.querySelectorAll('.rule-toggle').forEach(function(cb) {
|
document.querySelectorAll('.rule-toggle').forEach(function(cb) {
|
||||||
cb.addEventListener('change', function() {
|
cb.addEventListener('change', function() {
|
||||||
var fd = new FormData();
|
var fd = new FormData();
|
||||||
fd.append('id', this.dataset.id);
|
fd.append('id', this.dataset.id);
|
||||||
fd.append('enabled', this.checked ? '1' : '0');
|
fd.append('enabled', this.checked ? '1' : '0');
|
||||||
fd.append(token, '1');
|
fd.append(tokenKey, '1');
|
||||||
fetch('<?php echo $toggleUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
fetch('<?php echo $toggleUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||||
.then(function(r){return r.json()})
|
.then(function(r){return r.json()})
|
||||||
.then(function(d){ if (!d.success) Joomla.renderMessages({error:[d.message]}); });
|
.then(function(d){ if (!d.success) Joomla.renderMessages({error:[d.message]}); else this.closest('.card').classList.toggle('opacity-50', !this.checked); }.bind(this));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete rule
|
// ── Delete ──────────────────────────────────────────────────
|
||||||
document.querySelectorAll('.btn-delete-rule').forEach(function(btn) {
|
document.querySelectorAll('.btn-delete-rule').forEach(function(btn) {
|
||||||
btn.addEventListener('click', function() {
|
btn.addEventListener('click', function() {
|
||||||
if (!confirm('Delete this rule?')) return;
|
if (!confirm('Delete this rule?')) return;
|
||||||
var card = this.closest('.card');
|
var card = this.closest('.card');
|
||||||
var fd = new FormData();
|
var fd = new FormData();
|
||||||
fd.append('id', this.dataset.id);
|
fd.append('id', this.dataset.id);
|
||||||
fd.append(token, '1');
|
fd.append(tokenKey, '1');
|
||||||
fetch('<?php echo $deleteUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
fetch('<?php echo $deleteUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||||
.then(function(r){return r.json()})
|
.then(function(r){return r.json()})
|
||||||
.then(function(d){ if (d.success) card.remove(); else Joomla.renderMessages({error:[d.message]}); });
|
.then(function(d){ if (d.success) card.remove(); else Joomla.renderMessages({error:[d.message]}); });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Drag-and-drop reorder ───────────────────────────────────
|
||||||
|
var list = document.getElementById('rules-list');
|
||||||
|
var dragCard = null;
|
||||||
|
list.addEventListener('dragstart', function(e) {
|
||||||
|
dragCard = e.target.closest('.rule-card');
|
||||||
|
if (dragCard) dragCard.style.opacity = '0.5';
|
||||||
|
});
|
||||||
|
list.addEventListener('dragend', function() { if (dragCard) dragCard.style.opacity = ''; dragCard = null; });
|
||||||
|
list.addEventListener('dragover', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var target = e.target.closest('.rule-card');
|
||||||
|
if (target && target !== dragCard) {
|
||||||
|
var rect = target.getBoundingClientRect();
|
||||||
|
if ((e.clientY - rect.top) > rect.height / 2) target.parentNode.insertBefore(dragCard, target.nextSibling);
|
||||||
|
else target.parentNode.insertBefore(dragCard, target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
list.addEventListener('drop', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var ids = [];
|
||||||
|
document.querySelectorAll('.rule-card').forEach(function(c) { ids.push(c.dataset.id); });
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('order', JSON.stringify(ids));
|
||||||
|
fd.append(tokenKey, '1');
|
||||||
|
fetch('<?php echo $reorderUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -9,43 +9,71 @@ $categories = $this->categories;
|
|||||||
$token = Session::getFormToken();
|
$token = Session::getFormToken();
|
||||||
$saveUrl = Route::_('index.php?option=com_mokosuite&task=display.saveCanned&format=json');
|
$saveUrl = Route::_('index.php?option=com_mokosuite&task=display.saveCanned&format=json');
|
||||||
$deleteUrl = Route::_('index.php?option=com_mokosuite&task=display.deleteCanned&format=json');
|
$deleteUrl = Route::_('index.php?option=com_mokosuite&task=display.deleteCanned&format=json');
|
||||||
|
$reorderUrl = Route::_('index.php?option=com_mokosuite&task=display.reorderCanned&format=json');
|
||||||
|
|
||||||
|
// Build category map for filter display
|
||||||
|
$catMap = [0 => 'All Categories'];
|
||||||
|
foreach ($categories as $cat)
|
||||||
|
{
|
||||||
|
$catMap[$cat->id] = $cat->title;
|
||||||
|
}
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div id="mokosuite-canned">
|
<div id="mokosuite-canned">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h4><?php echo count($responses); ?> Canned Responses</h4>
|
<div class="d-flex align-items-center gap-3">
|
||||||
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newCannedModal">
|
<h4 class="mb-0"><?php echo count($responses); ?> Canned Responses</h4>
|
||||||
|
<select id="canned-filter-category" class="form-select form-select-sm" style="width:auto;">
|
||||||
|
<option value="">All Categories</option>
|
||||||
|
<?php foreach ($categories as $cat): ?>
|
||||||
|
<option value="<?php echo $cat->id; ?>"><?php echo htmlspecialchars($cat->title); ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" onclick="openCannedModal(0)">
|
||||||
<span class="icon-plus"></span> Add Response
|
<span class="icon-plus"></span> Add Response
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php foreach ($responses as $r): ?>
|
<div id="canned-list">
|
||||||
<div class="card mb-2" data-id="<?php echo $r->id; ?>">
|
<?php foreach ($responses as $r): ?>
|
||||||
<div class="card-body py-2">
|
<div class="card mb-2 canned-card" data-id="<?php echo $r->id; ?>" data-category="<?php echo (int) $r->category_id; ?>" style="cursor:grab;">
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
<div class="card-body py-2">
|
||||||
<div>
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<strong><?php echo htmlspecialchars($r->title); ?></strong>
|
<div class="flex-grow-1" style="cursor:pointer;" onclick="openCannedModal(<?php echo $r->id; ?>)">
|
||||||
<p class="text-muted small mb-0 mt-1"><?php echo htmlspecialchars(mb_substr($r->body, 0, 150)); ?></p>
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="icon-menu text-muted" style="cursor:grab;" title="Drag to reorder"></span>
|
||||||
|
<strong><?php echo htmlspecialchars($r->title); ?></strong>
|
||||||
|
<?php if (!empty($r->category_id) && isset($catMap[$r->category_id])): ?>
|
||||||
|
<span class="badge bg-secondary"><?php echo htmlspecialchars($catMap[$r->category_id]); ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small mb-0 mt-1 ms-4"><?php echo htmlspecialchars(mb_substr(strip_tags($r->body), 0, 150)); ?></p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-canned" data-id="<?php echo $r->id; ?>">
|
||||||
|
<span class="icon-trash"></span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-canned" data-id="<?php echo $r->id; ?>">
|
|
||||||
<span class="icon-trash"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<?php endforeach; ?>
|
||||||
<?php endforeach; ?>
|
|
||||||
|
|
||||||
<?php if (empty($responses)): ?>
|
<?php if (empty($responses)): ?>
|
||||||
<div class="alert alert-info">No canned responses yet. Click "Add Response" to create one.</div>
|
<div class="alert alert-info" id="canned-empty">No canned responses yet. Click "Add Response" to create one.</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- New Canned Modal -->
|
<!-- Canned Response Modal (create + edit) -->
|
||||||
<div class="modal fade" id="newCannedModal" tabindex="-1">
|
<div class="modal fade" id="cannedModal" tabindex="-1">
|
||||||
<div class="modal-dialog modal-lg">
|
<div class="modal-dialog modal-lg">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header"><h5>Add Canned Response</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
<div class="modal-header">
|
||||||
|
<h5 id="cannedModalTitle">Add Canned Response</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
<input type="hidden" id="canned-id" value="0">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Title</label>
|
<label class="form-label">Title</label>
|
||||||
<input type="text" id="canned-title" class="form-control" required>
|
<input type="text" id="canned-title" class="form-control" required>
|
||||||
@@ -53,7 +81,7 @@ $deleteUrl = Route::_('index.php?option=com_mokosuite&task=display.deleteCanned
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Category (optional)</label>
|
<label class="form-label">Category (optional)</label>
|
||||||
<select id="canned-category" class="form-select">
|
<select id="canned-category" class="form-select">
|
||||||
<option value="">All categories</option>
|
<option value="">No category</option>
|
||||||
<?php foreach ($categories as $cat): ?>
|
<?php foreach ($categories as $cat): ?>
|
||||||
<option value="<?php echo $cat->id; ?>"><?php echo htmlspecialchars($cat->title); ?></option>
|
<option value="<?php echo $cat->id; ?>"><?php echo htmlspecialchars($cat->title); ?></option>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
@@ -61,7 +89,7 @@ $deleteUrl = Route::_('index.php?option=com_mokosuite&task=display.deleteCanned
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Response Text</label>
|
<label class="form-label">Response Text</label>
|
||||||
<textarea id="canned-body" class="form-control" rows="6" required></textarea>
|
<textarea id="canned-body" class="form-control" rows="8" required></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@@ -74,15 +102,46 @@ $deleteUrl = Route::_('index.php?option=com_mokosuite&task=display.deleteCanned
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
var token = '<?php echo $token; ?>';
|
var tokenKey = '<?php echo $token; ?>';
|
||||||
|
|
||||||
|
// ── Response data store (for edit modal) ────────────────────
|
||||||
|
var responseData = {};
|
||||||
|
<?php foreach ($responses as $r): ?>
|
||||||
|
responseData[<?php echo $r->id; ?>] = {
|
||||||
|
title: <?php echo json_encode($r->title); ?>,
|
||||||
|
body: <?php echo json_encode($r->body); ?>,
|
||||||
|
category_id: <?php echo json_encode($r->category_id ?? ''); ?>
|
||||||
|
};
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
// ── Open modal for create (id=0) or edit ────────────────────
|
||||||
|
window.openCannedModal = function(id) {
|
||||||
|
document.getElementById('canned-id').value = id;
|
||||||
|
if (id > 0 && responseData[id]) {
|
||||||
|
document.getElementById('cannedModalTitle').textContent = 'Edit Canned Response';
|
||||||
|
document.getElementById('canned-title').value = responseData[id].title;
|
||||||
|
document.getElementById('canned-body').value = responseData[id].body;
|
||||||
|
document.getElementById('canned-category').value = responseData[id].category_id || '';
|
||||||
|
} else {
|
||||||
|
document.getElementById('cannedModalTitle').textContent = 'Add Canned Response';
|
||||||
|
document.getElementById('canned-title').value = '';
|
||||||
|
document.getElementById('canned-body').value = '';
|
||||||
|
document.getElementById('canned-category').value = '';
|
||||||
|
}
|
||||||
|
new bootstrap.Modal(document.getElementById('cannedModal')).show();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Save (create or update) ─────────────────────────────────
|
||||||
document.getElementById('btn-save-canned').addEventListener('click', function() {
|
document.getElementById('btn-save-canned').addEventListener('click', function() {
|
||||||
|
var title = document.getElementById('canned-title').value.trim();
|
||||||
|
if (!title) { Joomla.renderMessages({error:['Title is required']}); return; }
|
||||||
|
|
||||||
var fd = new FormData();
|
var fd = new FormData();
|
||||||
fd.append('id', '0');
|
fd.append('id', document.getElementById('canned-id').value);
|
||||||
fd.append('title', document.getElementById('canned-title').value);
|
fd.append('title', title);
|
||||||
fd.append('body', document.getElementById('canned-body').value);
|
fd.append('body', document.getElementById('canned-body').value);
|
||||||
fd.append('category_id', document.getElementById('canned-category').value);
|
fd.append('category_id', document.getElementById('canned-category').value);
|
||||||
fd.append(token, '1');
|
fd.append(tokenKey, '1');
|
||||||
fetch('<?php echo $saveUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
fetch('<?php echo $saveUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||||
.then(function(r){return r.json()})
|
.then(function(r){return r.json()})
|
||||||
.then(function(d){
|
.then(function(d){
|
||||||
@@ -91,17 +150,78 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Delete ──────────────────────────────────────────────────
|
||||||
document.querySelectorAll('.btn-delete-canned').forEach(function(btn) {
|
document.querySelectorAll('.btn-delete-canned').forEach(function(btn) {
|
||||||
btn.addEventListener('click', function() {
|
btn.addEventListener('click', function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
if (!confirm('Delete this canned response?')) return;
|
if (!confirm('Delete this canned response?')) return;
|
||||||
var card = this.closest('.card');
|
var card = this.closest('.card');
|
||||||
var fd = new FormData();
|
var fd = new FormData();
|
||||||
fd.append('id', this.dataset.id);
|
fd.append('id', this.dataset.id);
|
||||||
fd.append(token, '1');
|
fd.append(tokenKey, '1');
|
||||||
fetch('<?php echo $deleteUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
fetch('<?php echo $deleteUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||||
.then(function(r){return r.json()})
|
.then(function(r){return r.json()})
|
||||||
.then(function(d){ if (d.success) card.remove(); else Joomla.renderMessages({error:[d.message]}); });
|
.then(function(d){ if (d.success) card.remove(); else Joomla.renderMessages({error:[d.message]}); });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Category filter ─────────────────────────────────────────
|
||||||
|
document.getElementById('canned-filter-category').addEventListener('change', function() {
|
||||||
|
var catId = this.value;
|
||||||
|
document.querySelectorAll('.canned-card').forEach(function(card) {
|
||||||
|
if (!catId || card.dataset.category === catId) {
|
||||||
|
card.style.display = '';
|
||||||
|
} else {
|
||||||
|
card.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Drag-and-drop reorder ───────────────────────────────────
|
||||||
|
var list = document.getElementById('canned-list');
|
||||||
|
var dragCard = null;
|
||||||
|
|
||||||
|
list.addEventListener('dragstart', function(e) {
|
||||||
|
dragCard = e.target.closest('.canned-card');
|
||||||
|
if (dragCard) {
|
||||||
|
dragCard.style.opacity = '0.5';
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
list.addEventListener('dragend', function() {
|
||||||
|
if (dragCard) dragCard.style.opacity = '';
|
||||||
|
dragCard = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
list.addEventListener('dragover', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var target = e.target.closest('.canned-card');
|
||||||
|
if (target && target !== dragCard) {
|
||||||
|
var rect = target.getBoundingClientRect();
|
||||||
|
var after = (e.clientY - rect.top) > rect.height / 2;
|
||||||
|
if (after) {
|
||||||
|
target.parentNode.insertBefore(dragCard, target.nextSibling);
|
||||||
|
} else {
|
||||||
|
target.parentNode.insertBefore(dragCard, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
list.addEventListener('drop', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
// Persist new order
|
||||||
|
var ids = [];
|
||||||
|
document.querySelectorAll('.canned-card').forEach(function(c) { ids.push(c.dataset.id); });
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('order', JSON.stringify(ids));
|
||||||
|
fd.append(tokenKey, '1');
|
||||||
|
fetch('<?php echo $reorderUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make cards draggable
|
||||||
|
document.querySelectorAll('.canned-card').forEach(function(card) {
|
||||||
|
card.setAttribute('draggable', 'true');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ $users = $this->users;
|
|||||||
$token = Session::getFormToken();
|
$token = Session::getFormToken();
|
||||||
$saveUrl = Route::_('index.php?option=com_mokosuite&task=display.saveCategory&format=json');
|
$saveUrl = Route::_('index.php?option=com_mokosuite&task=display.saveCategory&format=json');
|
||||||
$deleteUrl = Route::_('index.php?option=com_mokosuite&task=display.deleteCategory&format=json');
|
$deleteUrl = Route::_('index.php?option=com_mokosuite&task=display.deleteCategory&format=json');
|
||||||
|
$reorderUrl = Route::_('index.php?option=com_mokosuite&task=display.reorderCategory&format=json');
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div id="mokosuite-categories">
|
<div id="mokosuite-categories">
|
||||||
@@ -22,10 +23,11 @@ $deleteUrl = Route::_('index.php?option=com_mokosuite&task=display.deleteCatego
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-striped mb-0" id="cat-table">
|
<table class="table table-striped mb-0" id="cat-table">
|
||||||
<thead><tr><th>Title</th><th>SLA Response</th><th>SLA Resolution</th><th>Auto-Assign</th><th>Active</th><th></th></tr></thead>
|
<thead><tr><th style="width:30px"></th><th>Title</th><th>SLA Response</th><th>SLA Resolution</th><th>Auto-Assign</th><th>Active</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($categories as $c): ?>
|
<?php foreach ($categories as $c): ?>
|
||||||
<tr data-id="<?php echo $c->id; ?>">
|
<tr data-id="<?php echo $c->id; ?>" draggable="true">
|
||||||
|
<td><span class="icon-menu text-muted" style="cursor:grab;"></span></td>
|
||||||
<td><input type="text" class="form-control form-control-sm cat-field" data-field="title" value="<?php echo htmlspecialchars($c->title); ?>"></td>
|
<td><input type="text" class="form-control form-control-sm cat-field" data-field="title" value="<?php echo htmlspecialchars($c->title); ?>"></td>
|
||||||
<td><input type="number" class="form-control form-control-sm cat-field" data-field="sla_response_minutes" value="<?php echo $c->sla_response_minutes; ?>" style="width:80px"> min</td>
|
<td><input type="number" class="form-control form-control-sm cat-field" data-field="sla_response_minutes" value="<?php echo $c->sla_response_minutes; ?>" style="width:80px"> min</td>
|
||||||
<td><input type="number" class="form-control form-control-sm cat-field" data-field="sla_resolution_minutes" value="<?php echo $c->sla_resolution_minutes; ?>" style="width:80px"> min</td>
|
<td><input type="number" class="form-control form-control-sm cat-field" data-field="sla_resolution_minutes" value="<?php echo $c->sla_resolution_minutes; ?>" style="width:80px"> min</td>
|
||||||
@@ -122,5 +124,39 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
});
|
});
|
||||||
tr.querySelector('input').focus();
|
tr.querySelector('input').focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Drag-and-drop reorder
|
||||||
|
var tbody = document.querySelector('#cat-table tbody');
|
||||||
|
var dragRow = null;
|
||||||
|
|
||||||
|
tbody.addEventListener('dragstart', function(e) {
|
||||||
|
dragRow = e.target.closest('tr');
|
||||||
|
if (dragRow) dragRow.style.opacity = '0.5';
|
||||||
|
});
|
||||||
|
tbody.addEventListener('dragend', function() {
|
||||||
|
if (dragRow) dragRow.style.opacity = '';
|
||||||
|
dragRow = null;
|
||||||
|
});
|
||||||
|
tbody.addEventListener('dragover', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var target = e.target.closest('tr');
|
||||||
|
if (target && target !== dragRow) {
|
||||||
|
var rect = target.getBoundingClientRect();
|
||||||
|
if ((e.clientY - rect.top) > rect.height / 2) {
|
||||||
|
target.parentNode.insertBefore(dragRow, target.nextSibling);
|
||||||
|
} else {
|
||||||
|
target.parentNode.insertBefore(dragRow, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tbody.addEventListener('drop', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var ids = [];
|
||||||
|
tbody.querySelectorAll('tr[data-id]').forEach(function(r) { if (r.dataset.id !== '0') ids.push(r.dataset.id); });
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('order', JSON.stringify(ids));
|
||||||
|
fd.append(token, '1');
|
||||||
|
fetch('<?php echo $reorderUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -8,6 +8,17 @@ use Joomla\CMS\Session\Session;
|
|||||||
$t = $this->ticket;
|
$t = $this->ticket;
|
||||||
$canned = $this->cannedResponses;
|
$canned = $this->cannedResponses;
|
||||||
$token = Session::getFormToken();
|
$token = Session::getFormToken();
|
||||||
|
$attachments = $this->attachments;
|
||||||
|
$downloadUrl = Route::_('index.php?option=com_mokosuite&task=display.downloadAttachment');
|
||||||
|
$uploadUrl = Route::_('index.php?option=com_mokosuite&task=display.uploadAttachment&format=json');
|
||||||
|
$deleteAttUrl = Route::_('index.php?option=com_mokosuite&task=display.deleteAttachment&format=json');
|
||||||
|
|
||||||
|
// Group attachments by reply_id (null = ticket-level)
|
||||||
|
$attByReply = [];
|
||||||
|
foreach ($attachments as $att) {
|
||||||
|
$key = $att->reply_id ?? 0;
|
||||||
|
$attByReply[$key][] = $att;
|
||||||
|
}
|
||||||
|
|
||||||
$statuses = $this->statuses ?? [];
|
$statuses = $this->statuses ?? [];
|
||||||
$priorities = $this->priorities ?? [];
|
$priorities = $this->priorities ?? [];
|
||||||
@@ -25,7 +36,21 @@ $priorities = $this->priorities ?? [];
|
|||||||
</div>
|
</div>
|
||||||
<span class="badge bg-dark">Original</span>
|
<span class="badge bg-dark">Original</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body"><?php echo nl2br($this->escape($t->body)); ?></div>
|
<div class="card-body">
|
||||||
|
<?php echo nl2br($this->escape($t->body)); ?>
|
||||||
|
<?php if (!empty($attByReply[0])): ?>
|
||||||
|
<hr>
|
||||||
|
<div class="small">
|
||||||
|
<strong>Attachments:</strong>
|
||||||
|
<?php foreach ($attByReply[0] as $att): ?>
|
||||||
|
<a href="<?php echo $downloadUrl . '&id=' . $att->id; ?>" class="d-inline-block me-3">
|
||||||
|
<span class="icon-download"></span> <?php echo $this->escape($att->filename); ?>
|
||||||
|
<span class="text-muted">(<?php echo \Moko\Component\MokoSuite\Administrator\Service\AttachmentService::formatSize($att->filesize); ?>)</span>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Replies -->
|
<!-- Replies -->
|
||||||
@@ -40,7 +65,21 @@ $priorities = $this->priorities ?? [];
|
|||||||
<span class="badge bg-warning text-dark">Internal Note</span>
|
<span class="badge bg-warning text-dark">Internal Note</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body"><?php echo nl2br($this->escape($reply->body)); ?></div>
|
<div class="card-body">
|
||||||
|
<?php echo nl2br($this->escape($reply->body)); ?>
|
||||||
|
<?php if (!empty($attByReply[$reply->id])): ?>
|
||||||
|
<hr>
|
||||||
|
<div class="small">
|
||||||
|
<strong>Attachments:</strong>
|
||||||
|
<?php foreach ($attByReply[$reply->id] as $att): ?>
|
||||||
|
<a href="<?php echo $downloadUrl . '&id=' . $att->id; ?>" class="d-inline-block me-3">
|
||||||
|
<span class="icon-download"></span> <?php echo $this->escape($att->filename); ?>
|
||||||
|
<span class="text-muted">(<?php echo \Moko\Component\MokoSuite\Administrator\Service\AttachmentService::formatSize($att->filesize); ?>)</span>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
|
||||||
@@ -59,6 +98,10 @@ $priorities = $this->priorities ?? [];
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<textarea id="reply-body" class="form-control mb-2" rows="5" placeholder="Type your reply..."></textarea>
|
<textarea id="reply-body" class="form-control mb-2" rows="5" placeholder="Type your reply..."></textarea>
|
||||||
|
<div class="mb-2">
|
||||||
|
<input type="file" id="reply-attachments" class="form-control form-control-sm" multiple
|
||||||
|
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.zip">
|
||||||
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="button" class="btn btn-primary" id="btn-reply"
|
<button type="button" class="btn btn-primary" id="btn-reply"
|
||||||
data-url="<?php echo Route::_('index.php?option=com_mokosuite&task=display.addTicketReply&format=json'); ?>"
|
data-url="<?php echo Route::_('index.php?option=com_mokosuite&task=display.addTicketReply&format=json'); ?>"
|
||||||
@@ -145,6 +188,45 @@ $priorities = $this->priorities ?? [];
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Satisfaction Rating -->
|
||||||
|
<?php
|
||||||
|
$isClosed = in_array($t->status, ['resolved', 'closed'], true);
|
||||||
|
$hasRating = !empty($t->satisfaction_rating);
|
||||||
|
?>
|
||||||
|
<?php if ($hasRating): ?>
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header"><strong>Satisfaction</strong></div>
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="mb-1">
|
||||||
|
<?php for ($s = 1; $s <= 5; $s++): ?>
|
||||||
|
<span style="font-size:1.5rem;color:<?php echo $s <= $t->satisfaction_rating ? '#f5a623' : '#dee2e6'; ?>;">★</span>
|
||||||
|
<?php endfor; ?>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small"><?php echo $t->satisfaction_rating; ?>/5</div>
|
||||||
|
<?php if (!empty($t->satisfaction_feedback)): ?>
|
||||||
|
<p class="small mt-2 mb-0"><?php echo $this->escape($t->satisfaction_feedback); ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php elseif ($isClosed): ?>
|
||||||
|
<div class="card mb-3" id="rating-card">
|
||||||
|
<div class="card-header"><strong>Rate this Support</strong></div>
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="mb-2" id="star-rating">
|
||||||
|
<?php for ($s = 1; $s <= 5; $s++): ?>
|
||||||
|
<span class="star-btn" data-value="<?php echo $s; ?>" style="font-size:2rem;cursor:pointer;color:#dee2e6;">★</span>
|
||||||
|
<?php endfor; ?>
|
||||||
|
</div>
|
||||||
|
<textarea id="rating-feedback" class="form-control form-control-sm mb-2" rows="2" placeholder="Optional feedback..."></textarea>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" id="btn-rate"
|
||||||
|
data-url="<?php echo Route::_('index.php?option=com_mokosuite&task=display.rateTicket&format=json'); ?>"
|
||||||
|
data-ticket="<?php echo $t->id; ?>" data-token="<?php echo $token; ?>" disabled>
|
||||||
|
Submit Rating
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<!-- Status actions -->
|
<!-- Status actions -->
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-header"><strong>Actions</strong></div>
|
<div class="card-header"><strong>Actions</strong></div>
|
||||||
@@ -190,22 +272,39 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reply buttons
|
// Reply buttons (with attachment upload)
|
||||||
document.querySelectorAll('#btn-reply, #btn-internal').forEach(function(btn) {
|
document.querySelectorAll('#btn-reply, #btn-internal').forEach(function(btn) {
|
||||||
btn.addEventListener('click', function() {
|
btn.addEventListener('click', function() {
|
||||||
var body = document.getElementById('reply-body').value.trim();
|
var body = document.getElementById('reply-body').value.trim();
|
||||||
if (!body) return;
|
var fileInput = document.getElementById('reply-attachments');
|
||||||
|
if (!body && (!fileInput || !fileInput.files.length)) return;
|
||||||
var el = this;
|
var el = this;
|
||||||
el.disabled = true;
|
el.disabled = true;
|
||||||
var fd = new FormData();
|
var fd = new FormData();
|
||||||
fd.append('ticket_id', el.dataset.ticket);
|
fd.append('ticket_id', el.dataset.ticket);
|
||||||
fd.append('body', body);
|
fd.append('body', body || '(attachment)');
|
||||||
fd.append('is_internal', el.dataset.internal || '0');
|
fd.append('is_internal', el.dataset.internal || '0');
|
||||||
fd.append(el.dataset.token, '1');
|
fd.append(el.dataset.token, '1');
|
||||||
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||||
.then(function(r){return r.json()})
|
.then(function(r){return r.json()})
|
||||||
.then(function(d){ if(d.success) location.reload(); else Joomla.renderMessages({error:[d.message]}); })
|
.then(function(d){
|
||||||
.finally(function(){ el.disabled = false; });
|
if (!d.success) { Joomla.renderMessages({error:[d.message]}); el.disabled = false; return; }
|
||||||
|
// Upload attachments if any
|
||||||
|
if (fileInput && fileInput.files.length > 0) {
|
||||||
|
var afd = new FormData();
|
||||||
|
afd.append('ticket_id', el.dataset.ticket);
|
||||||
|
if (d.reply_id) afd.append('reply_id', d.reply_id);
|
||||||
|
for (var i = 0; i < fileInput.files.length; i++) {
|
||||||
|
afd.append('attachments[' + i + ']', fileInput.files[i]);
|
||||||
|
}
|
||||||
|
afd.append(el.dataset.token, '1');
|
||||||
|
fetch('<?php echo $uploadUrl; ?>', {method:'POST', body:afd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||||
|
.then(function(){ location.reload(); });
|
||||||
|
} else {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(){ el.disabled = false; });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -224,5 +323,42 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
.finally(function(){ el.disabled = false; });
|
.finally(function(){ el.disabled = false; });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
// Star rating
|
||||||
|
var selectedRating = 0;
|
||||||
|
document.querySelectorAll('.star-btn').forEach(function(star) {
|
||||||
|
star.addEventListener('mouseenter', function() {
|
||||||
|
var val = parseInt(this.dataset.value);
|
||||||
|
document.querySelectorAll('.star-btn').forEach(function(s) {
|
||||||
|
s.style.color = parseInt(s.dataset.value) <= val ? '#f5a623' : '#dee2e6';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
star.addEventListener('mouseleave', function() {
|
||||||
|
document.querySelectorAll('.star-btn').forEach(function(s) {
|
||||||
|
s.style.color = parseInt(s.dataset.value) <= selectedRating ? '#f5a623' : '#dee2e6';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
star.addEventListener('click', function() {
|
||||||
|
selectedRating = parseInt(this.dataset.value);
|
||||||
|
document.getElementById('btn-rate').disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var rateBtn = document.getElementById('btn-rate');
|
||||||
|
if (rateBtn) {
|
||||||
|
rateBtn.addEventListener('click', function() {
|
||||||
|
if (!selectedRating) return;
|
||||||
|
var el = this;
|
||||||
|
el.disabled = true;
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('ticket_id', el.dataset.ticket);
|
||||||
|
fd.append('rating', selectedRating);
|
||||||
|
fd.append('feedback', document.getElementById('rating-feedback').value);
|
||||||
|
fd.append(el.dataset.token, '1');
|
||||||
|
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||||
|
.then(function(r){return r.json()})
|
||||||
|
.then(function(d){ if(d.success) location.reload(); else Joomla.renderMessages({error:[d.message]}); })
|
||||||
|
.finally(function(){ el.disabled = false; });
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package MokoSuite
|
||||||
|
* @subpackage com_mokosuite
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Component\MokoSuite\Api\Controller;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\MVC\Controller\BaseController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helpdesk Tickets REST API controller.
|
||||||
|
*
|
||||||
|
* GET /api/index.php/v1/mokosuite/tickets - list tickets
|
||||||
|
* GET /api/index.php/v1/mokosuite/tickets/{id} - get single ticket with replies
|
||||||
|
* POST /api/index.php/v1/mokosuite/tickets - create ticket
|
||||||
|
* PATCH /api/index.php/v1/mokosuite/tickets/{id} - update ticket fields
|
||||||
|
* POST /api/index.php/v1/mokosuite/tickets/{id}/reply - add reply
|
||||||
|
*
|
||||||
|
* @since 02.35.00
|
||||||
|
*/
|
||||||
|
class TicketsController extends BaseController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* GET /tickets — list tickets with optional filters.
|
||||||
|
*/
|
||||||
|
public function displayList(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth('core.manage', 'com_mokosuite');
|
||||||
|
|
||||||
|
$app = Factory::getApplication();
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$input = $app->getInput();
|
||||||
|
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select('t.*, s.title AS status_title, p.title AS priority_title, c.title AS category_title, u.name AS created_by_name')
|
||||||
|
->from($db->quoteName('#__mokosuite_tickets', 't'))
|
||||||
|
->leftJoin($db->quoteName('#__mokosuite_ticket_statuses', 's') . ' ON s.id = t.status_id')
|
||||||
|
->leftJoin($db->quoteName('#__mokosuite_ticket_priorities', 'p') . ' ON p.id = t.priority_id')
|
||||||
|
->leftJoin($db->quoteName('#__mokosuite_ticket_categories', 'c') . ' ON c.id = t.category_id')
|
||||||
|
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
|
||||||
|
->order('t.created DESC');
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
$status = $input->getString('status', '');
|
||||||
|
if ($status) {
|
||||||
|
$query->where($db->quoteName('t.status') . ' = ' . $db->quote($status));
|
||||||
|
}
|
||||||
|
|
||||||
|
$categoryId = $input->getInt('category_id', 0);
|
||||||
|
if ($categoryId) {
|
||||||
|
$query->where($db->quoteName('t.category_id') . ' = ' . $categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$assignedTo = $input->getInt('assigned_to', 0);
|
||||||
|
if ($assignedTo) {
|
||||||
|
$query->where($db->quoteName('t.assigned_to') . ' = ' . $assignedTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = min($input->getInt('limit', 25), 100);
|
||||||
|
$offset = $input->getInt('offset', 0);
|
||||||
|
$db->setQuery($query, $offset, $limit);
|
||||||
|
|
||||||
|
$tickets = $db->loadObjectList() ?: [];
|
||||||
|
|
||||||
|
// Total count
|
||||||
|
$countQuery = $db->getQuery(true)->select('COUNT(*)')->from('#__mokosuite_tickets');
|
||||||
|
$db->setQuery($countQuery);
|
||||||
|
$total = (int) $db->loadResult();
|
||||||
|
|
||||||
|
$this->sendJson(200, [
|
||||||
|
'tickets' => $tickets,
|
||||||
|
'total' => $total,
|
||||||
|
'limit' => $limit,
|
||||||
|
'offset' => $offset,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /tickets/{id} — single ticket with replies and attachments.
|
||||||
|
*/
|
||||||
|
public function displayItem(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth('core.manage', 'com_mokosuite');
|
||||||
|
|
||||||
|
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
|
||||||
|
// Ticket
|
||||||
|
$db->setQuery(
|
||||||
|
$db->getQuery(true)
|
||||||
|
->select('t.*, s.title AS status_title, p.title AS priority_title, c.title AS category_title, u.name AS created_by_name')
|
||||||
|
->from($db->quoteName('#__mokosuite_tickets', 't'))
|
||||||
|
->leftJoin($db->quoteName('#__mokosuite_ticket_statuses', 's') . ' ON s.id = t.status_id')
|
||||||
|
->leftJoin($db->quoteName('#__mokosuite_ticket_priorities', 'p') . ' ON p.id = t.priority_id')
|
||||||
|
->leftJoin($db->quoteName('#__mokosuite_ticket_categories', 'c') . ' ON c.id = t.category_id')
|
||||||
|
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
|
||||||
|
->where('t.id = ' . $id)
|
||||||
|
);
|
||||||
|
$ticket = $db->loadObject();
|
||||||
|
|
||||||
|
if (!$ticket) {
|
||||||
|
$this->sendJson(404, ['error' => 'Ticket not found']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replies
|
||||||
|
$db->setQuery(
|
||||||
|
$db->getQuery(true)
|
||||||
|
->select('r.*, u.name AS user_name')
|
||||||
|
->from($db->quoteName('#__mokosuite_ticket_replies', 'r'))
|
||||||
|
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id')
|
||||||
|
->where('r.ticket_id = ' . $id)
|
||||||
|
->order('r.created ASC')
|
||||||
|
);
|
||||||
|
$ticket->replies = $db->loadObjectList() ?: [];
|
||||||
|
|
||||||
|
// Attachments
|
||||||
|
$ticket->attachments = \Moko\Component\MokoSuite\Administrator\Service\AttachmentService::getForTicket($id);
|
||||||
|
|
||||||
|
$this->sendJson(200, $ticket);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /tickets — create a new ticket.
|
||||||
|
*/
|
||||||
|
public function create(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth('core.manage', 'com_mokosuite');
|
||||||
|
|
||||||
|
$input = Factory::getApplication()->getInput();
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
|
||||||
|
$subject = $input->getString('subject', '');
|
||||||
|
$body = $input->getRaw('body', '');
|
||||||
|
|
||||||
|
if (empty($subject)) {
|
||||||
|
$this->sendJson(400, ['error' => 'Subject is required']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticket = (object) [
|
||||||
|
'subject' => $subject,
|
||||||
|
'body' => $body,
|
||||||
|
'status' => 'open',
|
||||||
|
'status_id' => $input->getInt('status_id', 0) ?: null,
|
||||||
|
'priority' => $input->getString('priority', 'normal'),
|
||||||
|
'priority_id' => $input->getInt('priority_id', 0) ?: null,
|
||||||
|
'category_id' => $input->getInt('category_id', 0) ?: null,
|
||||||
|
'created_by' => (int) Factory::getUser()->id,
|
||||||
|
'assigned_to' => $input->getInt('assigned_to', 0) ?: null,
|
||||||
|
'created' => Factory::getDate()->toSql(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$db->insertObject('#__mokosuite_tickets', $ticket, 'id');
|
||||||
|
|
||||||
|
// Trigger notification
|
||||||
|
\Moko\Component\MokoSuite\Administrator\Service\NotificationService::notify('ticket_created', $ticket);
|
||||||
|
|
||||||
|
$this->sendJson(201, ['id' => (int) $ticket->id, 'message' => 'Ticket created']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /tickets/{id} — update ticket fields.
|
||||||
|
*/
|
||||||
|
public function update(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth('core.manage', 'com_mokosuite');
|
||||||
|
|
||||||
|
$input = Factory::getApplication()->getInput();
|
||||||
|
$id = $input->getInt('id', 0);
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
|
||||||
|
$fields = [];
|
||||||
|
$updatable = ['status', 'status_id', 'priority', 'priority_id', 'category_id', 'assigned_to'];
|
||||||
|
|
||||||
|
foreach ($updatable as $field) {
|
||||||
|
$value = $input->get($field, null, 'raw');
|
||||||
|
if ($value !== null) {
|
||||||
|
$fields[$field] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($fields)) {
|
||||||
|
$this->sendJson(400, ['error' => 'No fields to update']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sets = [];
|
||||||
|
foreach ($fields as $k => $v) {
|
||||||
|
$sets[] = $db->quoteName($k) . ' = ' . $db->quote($v);
|
||||||
|
}
|
||||||
|
$sets[] = 'modified = ' . $db->quote(Factory::getDate()->toSql());
|
||||||
|
|
||||||
|
$db->setQuery('UPDATE ' . $db->quoteName('#__mokosuite_tickets') . ' SET ' . implode(', ', $sets) . ' WHERE id = ' . $id)->execute();
|
||||||
|
|
||||||
|
$this->sendJson(200, ['id' => $id, 'message' => 'Ticket updated', 'updated' => array_keys($fields)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /tickets/{id}/reply — add a reply.
|
||||||
|
*/
|
||||||
|
public function reply(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth('core.manage', 'com_mokosuite');
|
||||||
|
|
||||||
|
$input = Factory::getApplication()->getInput();
|
||||||
|
$ticketId = $input->getInt('id', 0);
|
||||||
|
$body = $input->getRaw('body', '');
|
||||||
|
|
||||||
|
if (!$ticketId || empty($body)) {
|
||||||
|
$this->sendJson(400, ['error' => 'ticket_id and body are required']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
|
||||||
|
$reply = (object) [
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'user_id' => (int) Factory::getUser()->id,
|
||||||
|
'body' => $body,
|
||||||
|
'is_internal' => $input->getInt('is_internal', 0),
|
||||||
|
'created' => Factory::getDate()->toSql(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$db->insertObject('#__mokosuite_ticket_replies', $reply, 'id');
|
||||||
|
|
||||||
|
// Notify
|
||||||
|
$db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuite_tickets')->where('id = ' . $ticketId));
|
||||||
|
$ticket = $db->loadObject();
|
||||||
|
if ($ticket) {
|
||||||
|
\Moko\Component\MokoSuite\Administrator\Service\NotificationService::notify('ticket_replied', $ticket, ['reply_body' => $body]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sendJson(201, ['reply_id' => (int) $reply->id, 'message' => 'Reply added']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function requireAuth(string $action, string $asset): void
|
||||||
|
{
|
||||||
|
$user = Factory::getUser();
|
||||||
|
if (!$user->authorise($action, $asset)) {
|
||||||
|
$this->sendJson(403, ['error' => 'Not authorized']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendJson(int $code, $payload): void
|
||||||
|
{
|
||||||
|
$app = Factory::getApplication();
|
||||||
|
$app->setHeader('Content-Type', 'application/json', true);
|
||||||
|
$app->setHeader('Status', (string) $code, true);
|
||||||
|
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
$app->close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.34.50-dev</version>
|
<version>02.34.79-dev</version>
|
||||||
<description>MokoSuite admin dashboard and REST API. Provides a control panel for managing MokoSuite feature plugins, site health monitoring, and remote management endpoints.</description>
|
<description>MokoSuite admin dashboard and REST API. Provides a control panel for managing MokoSuite feature plugins, site health monitoring, and remote management endpoints.</description>
|
||||||
|
|
||||||
<namespace path="src">Moko\Component\MokoSuite</namespace>
|
<namespace path="src">Moko\Component\MokoSuite</namespace>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.34.50-dev</version>
|
<version>02.34.79-dev</version>
|
||||||
<description>MOD_MOKOSUITE_CACHE_DESC</description>
|
<description>MOD_MOKOSUITE_CACHE_DESC</description>
|
||||||
<namespace path="src">Moko\Module\MokoSuiteCache</namespace>
|
<namespace path="src">Moko\Module\MokoSuiteCache</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -16,11 +16,16 @@ $tempUrl = 'index.php?option=com_mokosuite&task=clearTemp&format=json';
|
|||||||
?>
|
?>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.mokosuite-cleaner { display:flex; align-items:center; gap:0; padding:0 0.25rem; }
|
.mokosuite-cleaner { display:flex; align-items:center; gap:0; padding:0 0.25rem; line-height:2rem; }
|
||||||
.mokosuite-cleaner-label { font-size:0.8rem; color:var(--template-text-dark,#495057); white-space:nowrap; padding-inline-end:0.35rem; }
|
.mokosuite-cleaner-label { font-size:0.8rem; color:var(--template-text-dark,#495057); white-space:nowrap; padding-inline-end:0.35rem; line-height:2rem; }
|
||||||
.mokosuite-cleaner-btn { cursor:pointer; padding:0.2rem 0.5rem; font-size:0.8rem; border-radius:3px; text-decoration:none; color:var(--template-text-dark,#495057); transition:background 0.15s; white-space:nowrap; }
|
.mokosuite-cleaner-btn { cursor:pointer; padding:0.2rem 0.5rem; font-size:0.8rem; border-radius:3px; text-decoration:none; color:var(--template-text-dark,#495057); transition:background 0.15s; white-space:nowrap; line-height:2rem; }
|
||||||
.mokosuite-cleaner-btn:hover { background:rgba(0,0,0,0.08); color:var(--template-text-dark,#212529); text-decoration:none; }
|
.mokosuite-cleaner-btn:hover { background:rgba(0,0,0,0.08); color:var(--template-text-dark,#212529); text-decoration:none; }
|
||||||
.mokosuite-cleaner-sep { color:var(--template-text-dark,#adb5bd); padding:0 0.1rem; font-size:0.8rem; }
|
.mokosuite-cleaner-btn .icon-bolt,
|
||||||
|
.mokosuite-cleaner-btn .icon-trash,
|
||||||
|
.mokosuite-cleaner-btn .icon-check,
|
||||||
|
.mokosuite-cleaner-btn .icon-times,
|
||||||
|
.mokosuite-cleaner-btn .icon-spinner { display:inline-block; width:1em; text-align:center; vertical-align:middle; }
|
||||||
|
.mokosuite-cleaner-sep { color:var(--template-text-dark,#adb5bd); padding:0 0.1rem; font-size:0.8rem; line-height:2rem; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="header-item-content mokosuite-cleaner">
|
<div class="header-item-content mokosuite-cleaner">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.34.50-dev</version>
|
<version>02.34.79-dev</version>
|
||||||
<description>MOD_MOKOSUITE_CATEGORIES_DESC</description>
|
<description>MOD_MOKOSUITE_CATEGORIES_DESC</description>
|
||||||
<namespace path="src">Moko\Module\MokoSuiteCategories</namespace>
|
<namespace path="src">Moko\Module\MokoSuiteCategories</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.34.50-dev</version>
|
<version>02.34.79-dev</version>
|
||||||
<description>MOD_MOKOSUITE_CPANEL_DESC</description>
|
<description>MOD_MOKOSUITE_CPANEL_DESC</description>
|
||||||
<namespace path="src">Moko\Module\MokoSuiteCpanel</namespace>
|
<namespace path="src">Moko\Module\MokoSuiteCpanel</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ $healthOk = $healthOk ?? true;
|
|||||||
$counts = $counts ?? (object) ['articles' => 0, 'users' => 0, 'extensions' => 0, 'updates' => 0];
|
$counts = $counts ?? (object) ['articles' => 0, 'users' => 0, 'extensions' => 0, 'updates' => 0];
|
||||||
$disk = $disk ?? (object) ['free_mb' => null, 'total_mb' => null];
|
$disk = $disk ?? (object) ['free_mb' => null, 'total_mb' => null];
|
||||||
$currentIp = $currentIp ?? '';
|
$currentIp = $currentIp ?? '';
|
||||||
$collapsed = $params->get('collapsed', 1);
|
$collapsed = $params->get('collapsed', 0);
|
||||||
$showHealth = $params->get('show_health', 1);
|
$showHealth = $params->get('show_health', 1);
|
||||||
$showStats = $params->get('show_stats', 1);
|
$showStats = $params->get('show_stats', 1);
|
||||||
$showDisk = $params->get('show_disk', 1);
|
$showDisk = $params->get('show_disk', 1);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.34.50-dev</version>
|
<version>02.34.79-dev</version>
|
||||||
<description>MokoSuite admin sidebar menu — renders a dedicated MokoSuite section in the admin menu before Joomla's default menu.</description>
|
<description>MokoSuite admin sidebar menu — renders a dedicated MokoSuite section in the admin menu before Joomla's default menu.</description>
|
||||||
<namespace path="src">Moko\Module\MokoSuiteMenu</namespace>
|
<namespace path="src">Moko\Module\MokoSuiteMenu</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
/**
|
/**
|
||||||
* MokoSuite Admin Sidebar Menu
|
* MokoSuite Admin Sidebar Menu
|
||||||
*
|
*
|
||||||
* Renders MokoSuite static views first, then auto-discovers installed
|
* Each installed Moko component gets its own top-level collapsible section.
|
||||||
* Moko components from #__menu and renders their submenu items as
|
* com_mokosuitehq is always pinned first. com_mokosuite uses static views
|
||||||
* nested MetisMenu collapsible sections.
|
* as children. All other components auto-discover their submenu items.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
defined('_JEXEC') or die;
|
||||||
@@ -17,8 +17,8 @@ $app = Factory::getApplication();
|
|||||||
$currentOption = $app->getInput()->get('option', '');
|
$currentOption = $app->getInput()->get('option', '');
|
||||||
$currentView = $app->getInput()->get('view', '');
|
$currentView = $app->getInput()->get('view', '');
|
||||||
|
|
||||||
// ── Static MokoSuite views ────────────────────────────────────────────
|
// ── Static views for com_mokosuite ──────────────────────────────────
|
||||||
$mokosuiteItems = [
|
$mokosuiteStaticViews = [
|
||||||
['icon' => 'icon-cogs', 'title' => 'Dashboard', 'link' => 'index.php?option=com_mokosuite'],
|
['icon' => 'icon-cogs', 'title' => 'Dashboard', 'link' => 'index.php?option=com_mokosuite'],
|
||||||
['icon' => 'fa-solid fa-handshake-angle', 'title' => 'Helpdesk', 'link' => 'index.php?option=com_mokosuite&view=tickets'],
|
['icon' => 'fa-solid fa-handshake-angle', 'title' => 'Helpdesk', 'link' => 'index.php?option=com_mokosuite&view=tickets'],
|
||||||
['icon' => 'icon-puzzle-piece', 'title' => 'Extensions', 'link' => 'index.php?option=com_mokosuite&view=extensions'],
|
['icon' => 'icon-puzzle-piece', 'title' => 'Extensions', 'link' => 'index.php?option=com_mokosuite&view=extensions'],
|
||||||
@@ -30,27 +30,25 @@ $mokosuiteItems = [
|
|||||||
['icon' => 'icon-power-off', 'title' => 'Feature Plugins', 'link' => 'index.php?option=com_plugins&filter[folder]=system&filter[search]=mokosuite'],
|
['icon' => 'icon-power-off', 'title' => 'Feature Plugins', 'link' => 'index.php?option=com_plugins&filter[folder]=system&filter[search]=mokosuite'],
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Auto-discover Moko component menus from #__menu ──────────────────
|
// ── Auto-discover all Moko components from #__menu ──────────────────
|
||||||
$mokoComponents = [];
|
$mokoComponents = [];
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
||||||
|
|
||||||
// Find all Moko component menu items (exclude com_mokosuite — handled above)
|
|
||||||
$db->setQuery(
|
$db->setQuery(
|
||||||
"SELECT m.id, m.title, m.link, m.level, m.parent_id, m.img, e.element"
|
"SELECT m.id, m.title, m.link, m.level, m.parent_id, m.img, e.element"
|
||||||
. " FROM " . $db->quoteName('#__menu') . " m"
|
. " FROM " . $db->quoteName('#__menu') . " m"
|
||||||
. " LEFT JOIN " . $db->quoteName('#__extensions') . " e ON m.component_id = e.extension_id"
|
. " LEFT JOIN " . $db->quoteName('#__extensions') . " e ON m.component_id = e.extension_id"
|
||||||
. " WHERE m.client_id = 1 AND m.level >= 1 AND m.published = 1"
|
. " WHERE m.client_id = 1 AND m.level >= 1 AND m.published = 1"
|
||||||
. " AND e.element LIKE 'com_moko%'"
|
. " AND e.element LIKE 'com_moko%'"
|
||||||
. " AND e.element != 'com_mokosuite'"
|
|
||||||
. " AND e.enabled = 1"
|
. " AND e.enabled = 1"
|
||||||
. " ORDER BY e.element, m.level, m.lft"
|
. " ORDER BY e.element, m.level, m.lft"
|
||||||
);
|
);
|
||||||
$menuItems = $db->loadObjectList() ?: [];
|
$menuItems = $db->loadObjectList() ?: [];
|
||||||
|
|
||||||
// Load sys.ini language files for discovered components
|
// Load language files for discovered components
|
||||||
$lang = Factory::getLanguage();
|
$lang = Factory::getLanguage();
|
||||||
$loadedLangs = [];
|
$loadedLangs = [];
|
||||||
foreach ($menuItems as $m)
|
foreach ($menuItems as $m)
|
||||||
@@ -92,100 +90,112 @@ catch (\Throwable $e)
|
|||||||
// Silent — menu works without auto-discovered components
|
// Silent — menu works without auto-discovered components
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Determine active state ───────────────────────────────────────────
|
// Override com_mokosuite children with static views
|
||||||
$mokosuiteActive = ($currentOption === 'com_mokosuite');
|
if (isset($mokoComponents['com_mokosuite']))
|
||||||
$anyMokoActive = $mokosuiteActive;
|
|
||||||
|
|
||||||
foreach ($mokoComponents as $comp)
|
|
||||||
{
|
{
|
||||||
$parsed = [];
|
$mokoComponents['com_mokosuite']['children'] = $mokosuiteStaticViews;
|
||||||
parse_str(parse_url($comp['link'], PHP_URL_QUERY) ?? '', $parsed);
|
$mokoComponents['com_mokosuite']['icon'] = 'icon-shield-alt';
|
||||||
if (($parsed['option'] ?? '') === $currentOption)
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// com_mokosuite not in admin menu — add it manually
|
||||||
|
$mokoComponents['com_mokosuite'] = [
|
||||||
|
'id' => 0,
|
||||||
|
'title' => 'MokoSuite',
|
||||||
|
'link' => 'index.php?option=com_mokosuite',
|
||||||
|
'icon' => 'icon-shield-alt',
|
||||||
|
'element' => 'com_mokosuite',
|
||||||
|
'children' => $mokosuiteStaticViews,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sort: com_mokosuitehq first, then alphabetical by title ─────────
|
||||||
|
$hq = null;
|
||||||
|
$rest = [];
|
||||||
|
|
||||||
|
foreach ($mokoComponents as $key => $comp)
|
||||||
|
{
|
||||||
|
if ($key === 'com_mokosuitehq')
|
||||||
{
|
{
|
||||||
$anyMokoActive = true;
|
$hq = $comp;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
$rest[$key] = $comp;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$topClass = 'item parent item-level-1' . ($anyMokoActive ? ' mm-active' : '');
|
usort($rest, fn($a, $b) => strcasecmp($a['title'], $b['title']));
|
||||||
$topCollapse = 'collapse-level-1 mm-collapse' . ($anyMokoActive ? ' mm-show' : '');
|
|
||||||
|
$sorted = [];
|
||||||
|
if ($hq !== null)
|
||||||
|
{
|
||||||
|
$sorted[] = $hq;
|
||||||
|
}
|
||||||
|
foreach ($rest as $comp)
|
||||||
|
{
|
||||||
|
$sorted[] = $comp;
|
||||||
|
}
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.sidebar-wrapper .item-level-1 > a { padding-inline-start: 1.5rem; }
|
.sidebar-wrapper .mokosuite-ext-item > a { padding-inline-start: 1.5rem; }
|
||||||
.sidebar-wrapper .mokosuite-menu-item > a { padding-inline-start: 2rem; }
|
.sidebar-wrapper .mokosuite-ext-child > a { padding-inline-start: 2.5rem; }
|
||||||
.sidebar-wrapper .mokosuite-menu-child > a { padding-inline-start: 2.5rem; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<ul class="nav flex-column main-nav">
|
<ul class="nav flex-column main-nav">
|
||||||
<li class="<?php echo $topClass; ?>">
|
<?php foreach ($sorted as $comp): ?>
|
||||||
<a class="has-arrow" href="#" aria-label="MokoSuite">
|
<?php
|
||||||
<span class="icon-shield-alt" aria-hidden="true"></span>
|
$compParsed = [];
|
||||||
<span class="sidebar-item-title">MokoSuite</span>
|
parse_str(parse_url($comp['link'], PHP_URL_QUERY) ?? '', $compParsed);
|
||||||
|
$compOption = $compParsed['option'] ?? '';
|
||||||
|
$compActive = ($compOption === $currentOption);
|
||||||
|
|
||||||
|
// For com_mokosuite static children, also check the plugins filter link
|
||||||
|
if (!$compActive && $comp['element'] === 'com_mokosuite' && $currentOption === 'com_plugins')
|
||||||
|
{
|
||||||
|
$compActive = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasChildren = !empty($comp['children']);
|
||||||
|
$liClass = 'item mokosuite-ext-item' . ($hasChildren ? ' parent item-level-1' : '') . ($compActive ? ' mm-active' : '');
|
||||||
|
$aClass = ($hasChildren ? 'has-arrow' : 'no-dropdown') . ($compActive ? ' mm-active' : '');
|
||||||
|
$childCollapse = 'collapse-level-1 mm-collapse' . ($compActive ? ' mm-show' : '');
|
||||||
|
?>
|
||||||
|
<li class="<?php echo $liClass; ?>">
|
||||||
|
<a class="<?php echo $aClass; ?>" href="<?php echo $hasChildren ? '#' : Route::_($comp['link']); ?>"<?php echo ($compActive && !$hasChildren) ? ' aria-current="page"' : ''; ?>>
|
||||||
|
<span class="<?php echo $comp['icon']; ?>" aria-hidden="true" style="display:inline-block!important;width:1.25em;text-align:center;margin-inline-end:0.4em;"></span>
|
||||||
|
<span class="sidebar-item-title"><?php echo $comp['title']; ?></span>
|
||||||
</a>
|
</a>
|
||||||
<ul class="<?php echo $topCollapse; ?>" style="padding-inline-start:0.5rem;">
|
<?php if ($hasChildren): ?>
|
||||||
|
<ul class="<?php echo $childCollapse; ?>" style="padding-inline-start:0.5rem;">
|
||||||
<?php // ── MokoSuite static items ── ?>
|
<?php foreach ($comp['children'] as $child): ?>
|
||||||
<?php foreach ($mokosuiteItems as $item): ?>
|
|
||||||
<?php
|
<?php
|
||||||
$active = false;
|
$childParsed = [];
|
||||||
$parsed = [];
|
parse_str(parse_url($child['link'], PHP_URL_QUERY) ?? '', $childParsed);
|
||||||
parse_str(parse_url($item['link'], PHP_URL_QUERY) ?? '', $parsed);
|
$childOption = $childParsed['option'] ?? '';
|
||||||
if (($parsed['option'] ?? '') === $currentOption)
|
$childView = $childParsed['view'] ?? '';
|
||||||
|
|
||||||
|
$childActive = false;
|
||||||
|
if ($childOption === $currentOption)
|
||||||
{
|
{
|
||||||
$active = empty($parsed['view'])
|
$childActive = empty($childView)
|
||||||
? ($currentView === '' || $currentView === 'dashboard')
|
? ($currentView === '' || $currentView === 'dashboard')
|
||||||
: ($currentView === ($parsed['view'] ?? ''));
|
: ($currentView === $childView);
|
||||||
}
|
}
|
||||||
$liClass = 'item mokosuite-menu-item' . ($active ? ' mm-active' : '');
|
|
||||||
$aClass = 'no-dropdown' . ($active ? ' mm-active' : '');
|
$childLiClass = 'item mokosuite-ext-child' . ($childActive ? ' mm-active' : '');
|
||||||
|
$childAClass = 'no-dropdown' . ($childActive ? ' mm-active' : '');
|
||||||
?>
|
?>
|
||||||
<li class="<?php echo $liClass; ?>">
|
<li class="<?php echo $childLiClass; ?>">
|
||||||
<a class="<?php echo $aClass; ?>" href="<?php echo Route::_($item['link']); ?>"<?php echo $active ? ' aria-current="page"' : ''; ?>>
|
<a class="<?php echo $childAClass; ?>" href="<?php echo Route::_($child['link']); ?>"<?php echo $childActive ? ' aria-current="page"' : ''; ?>>
|
||||||
<span class="<?php echo $item['icon']; ?>" aria-hidden="true" style="display:inline-block!important;width:1.25em;text-align:center;margin-inline-end:0.4em;"></span>
|
<span class="<?php echo $child['icon']; ?>" aria-hidden="true" style="display:inline-block!important;width:1.25em;text-align:center;margin-inline-end:0.4em;"></span>
|
||||||
<span class="sidebar-item-title"><?php echo $item['title']; ?></span>
|
<span class="sidebar-item-title"><?php echo $child['title']; ?></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
|
||||||
<?php // ── Auto-discovered Moko components with submenus ── ?>
|
|
||||||
<?php foreach ($mokoComponents as $comp): ?>
|
|
||||||
<?php
|
|
||||||
$compParsed = [];
|
|
||||||
parse_str(parse_url($comp['link'], PHP_URL_QUERY) ?? '', $compParsed);
|
|
||||||
$compActive = ($compParsed['option'] ?? '') === $currentOption;
|
|
||||||
$hasChildren = !empty($comp['children']);
|
|
||||||
$compLiClass = 'item mokosuite-menu-item' . ($hasChildren ? ' parent' : '') . ($compActive ? ' mm-active' : '');
|
|
||||||
$compAClass = ($hasChildren ? 'has-arrow' : 'no-dropdown') . ($compActive ? ' mm-active' : '');
|
|
||||||
$childCollapse = 'collapse-level-2 mm-collapse' . ($compActive ? ' mm-show' : '');
|
|
||||||
?>
|
|
||||||
<li class="<?php echo $compLiClass; ?>">
|
|
||||||
<a class="<?php echo $compAClass; ?>" href="<?php echo $hasChildren ? '#' : Route::_($comp['link']); ?>"<?php echo ($compActive && !$hasChildren) ? ' aria-current="page"' : ''; ?>>
|
|
||||||
<span class="<?php echo $comp['icon']; ?>" aria-hidden="true" style="display:inline-block!important;width:1.25em;text-align:center;margin-inline-end:0.4em;"></span>
|
|
||||||
<span class="sidebar-item-title"><?php echo $comp['title']; ?></span>
|
|
||||||
</a>
|
|
||||||
<?php if ($hasChildren): ?>
|
|
||||||
<ul class="<?php echo $childCollapse; ?>" style="padding-inline-start:0.75rem;">
|
|
||||||
<?php foreach ($comp['children'] as $child): ?>
|
|
||||||
<?php
|
|
||||||
$childParsed = [];
|
|
||||||
parse_str(parse_url($child['link'], PHP_URL_QUERY) ?? '', $childParsed);
|
|
||||||
$childActive = ($childParsed['option'] ?? '') === $currentOption
|
|
||||||
&& ($childParsed['view'] ?? '') === $currentView;
|
|
||||||
$childLiClass = 'item mokosuite-menu-child' . ($childActive ? ' mm-active' : '');
|
|
||||||
$childAClass = 'no-dropdown' . ($childActive ? ' mm-active' : '');
|
|
||||||
?>
|
|
||||||
<li class="<?php echo $childLiClass; ?>">
|
|
||||||
<a class="<?php echo $childAClass; ?>" href="<?php echo Route::_($child['link']); ?>"<?php echo $childActive ? ' aria-current="page"' : ''; ?>>
|
|
||||||
<span class="<?php echo $child['icon']; ?>" aria-hidden="true" style="display:inline-block!important;width:1.25em;text-align:center;margin-inline-end:0.4em;"></span>
|
|
||||||
<span class="sidebar-item-title"><?php echo $child['title']; ?></span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</ul>
|
|
||||||
<?php endif; ?>
|
|
||||||
</li>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
|
|
||||||
</ul>
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
</li>
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
* DEFGROUP: Joomla.Plugin
|
* DEFGROUP: Joomla.Plugin
|
||||||
* INGROUP: MokoSuite
|
* INGROUP: MokoSuite
|
||||||
* REPO: https://github.com/mokoconsulting-tech/mokosuite
|
* REPO: https://github.com/mokoconsulting-tech/mokosuite
|
||||||
* VERSION: 02.34.50
|
* VERSION: 02.34.79
|
||||||
* PATH: /src/Extension/MokoSuite.php
|
* PATH: /src/Extension/MokoSuite.php
|
||||||
* NOTE: Core system plugin for MokoSuite admin tools suite
|
* NOTE: Core system plugin for MokoSuite admin tools suite
|
||||||
*/
|
*/
|
||||||
@@ -163,6 +163,7 @@ class MokoSuite extends CMSPlugin implements BootableExtensionInterface
|
|||||||
{
|
{
|
||||||
$this->handleOneTimeLogin();
|
$this->handleOneTimeLogin();
|
||||||
$this->checkSetupRequired();
|
$this->checkSetupRequired();
|
||||||
|
$this->ensureAdminModulesActive();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2157,19 +2158,238 @@ class MokoSuite extends CMSPlugin implements BootableExtensionInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Download Key Preservation
|
// Admin Module Self-Healing
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preserve download keys across Joomla extension updates.
|
* Ensure MokoSuite admin modules are published with correct positions.
|
||||||
*
|
*
|
||||||
* Joomla's installer can wipe the extra_query column (which holds
|
* Runs once per session to self-heal if modules were accidentally
|
||||||
* download keys / dlid) when rebuilding or reinstalling update sites.
|
* unpublished or had their position cleared.
|
||||||
* This method keeps a backup of all non-empty extra_query values and
|
|
||||||
* restores any that get cleared.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*
|
|
||||||
* @since 02.34.12
|
|
||||||
*/
|
*/
|
||||||
|
private function ensureAdminModulesActive(): void
|
||||||
|
{
|
||||||
|
$session = \Joomla\CMS\Factory::getSession();
|
||||||
|
|
||||||
|
if ($session->get('mokosuite.modules_checked', false))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$session->set('mokosuite.modules_checked', true);
|
||||||
|
|
||||||
|
$modules = [
|
||||||
|
'mod_mokosuite_cpanel' => ['position' => 'top', 'title' => 'MokoSuite', 'access' => 6, 'ordering' => 0],
|
||||||
|
'mod_mokosuite_menu' => ['position' => 'menu', 'title' => 'MokoSuite Menu', 'access' => 3, 'ordering' => 0],
|
||||||
|
'mod_mokosuite_cache' => ['position' => 'status', 'title' => 'MokoSuite Cache Cleaner', 'access' => 3, 'ordering' => 0],
|
||||||
|
];
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
$db = \Joomla\CMS\Factory::getDbo();
|
||||||
|
$app = \Joomla\CMS\Factory::getApplication();
|
||||||
|
|
||||||
|
foreach ($modules as $element => $config)
|
||||||
|
{
|
||||||
|
// Check if extension is installed
|
||||||
|
$db->setQuery(
|
||||||
|
$db->getQuery(true)
|
||||||
|
->select('extension_id')
|
||||||
|
->from('#__extensions')
|
||||||
|
->where('element = ' . $db->quote($element))
|
||||||
|
->where('type = ' . $db->quote('module'))
|
||||||
|
);
|
||||||
|
if (!(int) $db->loadResult()) continue;
|
||||||
|
|
||||||
|
// Find existing module instance
|
||||||
|
$db->setQuery(
|
||||||
|
$db->getQuery(true)
|
||||||
|
->select('id, published, position')
|
||||||
|
->from('#__modules')
|
||||||
|
->where('module = ' . $db->quote($element))
|
||||||
|
->where('client_id = 1')
|
||||||
|
->setLimit(1)
|
||||||
|
);
|
||||||
|
$mod = $db->loadObject();
|
||||||
|
|
||||||
|
$model = $app->bootComponent('com_modules')
|
||||||
|
->getMVCFactory()
|
||||||
|
->createModel('Module', 'Administrator', ['ignore_request' => true]);
|
||||||
|
|
||||||
|
if ($mod)
|
||||||
|
{
|
||||||
|
// Check if repair needed
|
||||||
|
$needsFix = (int) $mod->published !== 1 || $mod->position !== $config['position'];
|
||||||
|
|
||||||
|
if (!$needsFix)
|
||||||
|
{
|
||||||
|
$db->setQuery(
|
||||||
|
$db->getQuery(true)
|
||||||
|
->select('COUNT(*)')
|
||||||
|
->from('#__modules_menu')
|
||||||
|
->where('moduleid = ' . (int) $mod->id)
|
||||||
|
);
|
||||||
|
$needsFix = (int) $db->loadResult() === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($needsFix)
|
||||||
|
{
|
||||||
|
$data = $model->getItem($mod->id)->getProperties();
|
||||||
|
$data['published'] = 1;
|
||||||
|
$data['position'] = $config['position'];
|
||||||
|
$data['ordering'] = $config['ordering'] ?? 0;
|
||||||
|
$data['assignment'] = 0;
|
||||||
|
$model->save($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure module is first in its position
|
||||||
|
$db->setQuery(
|
||||||
|
$db->getQuery(true)
|
||||||
|
->select('MIN(' . $db->quoteName('ordering') . ')')
|
||||||
|
->from('#__modules')
|
||||||
|
->where($db->quoteName('position') . ' = ' . $db->quote($config['position']))
|
||||||
|
->where($db->quoteName('client_id') . ' = 1')
|
||||||
|
->where($db->quoteName('id') . ' != ' . (int) $mod->id)
|
||||||
|
);
|
||||||
|
$minOther = $db->loadResult();
|
||||||
|
|
||||||
|
if ($minOther !== null)
|
||||||
|
{
|
||||||
|
// Re-read current ordering for this module
|
||||||
|
$db->setQuery(
|
||||||
|
$db->getQuery(true)
|
||||||
|
->select($db->quoteName('ordering'))
|
||||||
|
->from('#__modules')
|
||||||
|
->where($db->quoteName('id') . ' = ' . (int) $mod->id)
|
||||||
|
);
|
||||||
|
$currentOrdering = (int) $db->loadResult();
|
||||||
|
|
||||||
|
if ($currentOrdering >= (int) $minOther)
|
||||||
|
{
|
||||||
|
$newOrdering = (int) $minOther - 1;
|
||||||
|
$data = $model->getItem($mod->id)->getProperties();
|
||||||
|
$data['ordering'] = $newOrdering;
|
||||||
|
$data['assignment'] = 0;
|
||||||
|
$model->save($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Module instance deleted — recreate it
|
||||||
|
$data = [
|
||||||
|
'title' => $config['title'],
|
||||||
|
'module' => $element,
|
||||||
|
'position' => $config['position'],
|
||||||
|
'published' => 1,
|
||||||
|
'access' => $config['access'],
|
||||||
|
'ordering' => $config['ordering'] ?? 0,
|
||||||
|
'showtitle' => 0,
|
||||||
|
'client_id' => 1,
|
||||||
|
'language' => '*',
|
||||||
|
'params' => '{}',
|
||||||
|
'assignment' => 0,
|
||||||
|
];
|
||||||
|
$model->save($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (\Throwable $e)
|
||||||
|
{
|
||||||
|
// Silent — don't break the admin if self-heal fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Automation Engine Event Hooks (#151)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire automation rules for user registration.
|
||||||
|
*/
|
||||||
|
public function onUserAfterSave($user, $isnew, $success, $msg): void
|
||||||
|
{
|
||||||
|
if (!$isnew || !$success) return;
|
||||||
|
|
||||||
|
class_exists(\Moko\Component\MokoSuite\Administrator\Service\AutomationEngine::class, true) && \Moko\Component\MokoSuite\Administrator\Service\AutomationEngine::fire('user_register', [
|
||||||
|
'user_id' => (int) ($user['id'] ?? 0),
|
||||||
|
'username' => $user['username'] ?? '',
|
||||||
|
'email' => $user['email'] ?? '',
|
||||||
|
'name' => $user['name'] ?? '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire automation rules on article save.
|
||||||
|
*/
|
||||||
|
public function onContentAfterSave($context, $article, $isNew): void
|
||||||
|
{
|
||||||
|
if ($context !== 'com_content.article') return;
|
||||||
|
|
||||||
|
class_exists(\Moko\Component\MokoSuite\Administrator\Service\AutomationEngine::class, true) && \Moko\Component\MokoSuite\Administrator\Service\AutomationEngine::fire('content_save', [
|
||||||
|
'article_id' => (int) ($article->id ?? 0),
|
||||||
|
'title' => $article->title ?? '',
|
||||||
|
'is_new' => $isNew ? '1' : '0',
|
||||||
|
'catid' => (int) ($article->catid ?? 0),
|
||||||
|
'user_id' => (int) ($article->modified_by ?? $article->created_by ?? 0),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Security Event Notifications (#147)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify on successful admin login.
|
||||||
|
*/
|
||||||
|
public function onUserAfterLogin($options): void
|
||||||
|
{
|
||||||
|
if (!($options['user'] ?? null)) return;
|
||||||
|
$user = $options['user'];
|
||||||
|
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||||
|
$name = $user->username ?? $user->name ?? 'unknown';
|
||||||
|
|
||||||
|
// Fire automation for any login
|
||||||
|
class_exists(\Moko\Component\MokoSuite\Administrator\Service\AutomationEngine::class, true) && \Moko\Component\MokoSuite\Administrator\Service\AutomationEngine::fire('user_login', [
|
||||||
|
'user_id' => (int) ($user->id ?? 0),
|
||||||
|
'username' => $name,
|
||||||
|
'ip' => $ip,
|
||||||
|
'client' => $this->app->isClient('administrator') ? 'admin' : 'site',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Security notification for backend logins only
|
||||||
|
if (!$this->app->isClient('administrator')) return;
|
||||||
|
|
||||||
|
class_exists(\Moko\Component\MokoSuite\Administrator\Service\NotificationService::class, true) && \Moko\Component\MokoSuite\Administrator\Service\NotificationService::securityAlert(
|
||||||
|
'admin_login',
|
||||||
|
"Admin login: {$name}",
|
||||||
|
"User: {$name}\nIP: {$ip}\nTime: " . gmdate('Y-m-d H:i:s') . " UTC"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track failed login attempts and notify after threshold.
|
||||||
|
*/
|
||||||
|
public function onUserLoginFailure($response): void
|
||||||
|
{
|
||||||
|
if (!$this->app->isClient('administrator')) return;
|
||||||
|
|
||||||
|
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||||
|
$username = $response['username'] ?? 'unknown';
|
||||||
|
|
||||||
|
// Track in session — notify after 3 failures from same IP
|
||||||
|
$session = \Joomla\CMS\Factory::getSession();
|
||||||
|
$key = 'mokosuite.login_failures.' . md5($ip);
|
||||||
|
$count = (int) $session->get($key, 0) + 1;
|
||||||
|
$session->set($key, $count);
|
||||||
|
|
||||||
|
if ($count >= 3 && $count % 3 === 0)
|
||||||
|
{
|
||||||
|
class_exists(\Moko\Component\MokoSuite\Administrator\Service\NotificationService::class, true) && \Moko\Component\MokoSuite\Administrator\Service\NotificationService::securityAlert(
|
||||||
|
'login_failure',
|
||||||
|
"Failed login attempts: {$count} from {$ip}",
|
||||||
|
"Username: {$username}\nIP: {$ip}\nAttempts: {$count}\nTime: " . gmdate('Y-m-d H:i:s') . " UTC"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* FILE INFORMATION
|
* FILE INFORMATION
|
||||||
* DEFGROUP: Joomla.Plugin
|
* DEFGROUP: Joomla.Plugin
|
||||||
* INGROUP: MokoSuite
|
* INGROUP: MokoSuite
|
||||||
* VERSION: 02.34.50
|
* VERSION: 02.34.79
|
||||||
* PATH: /src/Field/CopyableTokenField.php
|
* PATH: /src/Field/CopyableTokenField.php
|
||||||
* BRIEF: Read-only token field with a copy-to-clipboard button
|
* BRIEF: Read-only token field with a copy-to-clipboard button
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
<license>GNU General Public License version 3 or later; see LICENSE.md</license>
|
<license>GNU General Public License version 3 or later; see LICENSE.md</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.34.50-dev</version>
|
<version>02.34.79-dev</version>
|
||||||
<description>MokoSuite core system plugin — coordinates feature plugins, heartbeat, health checks, and admin customizations.</description>
|
<description>MokoSuite core system plugin — coordinates feature plugins, heartbeat, health checks, and admin customizations.</description>
|
||||||
<namespace path=".">Moko\Plugin\System\MokoSuite</namespace>
|
<namespace path=".">Moko\Plugin\System\MokoSuite</namespace>
|
||||||
<scriptfile>script.php</scriptfile>
|
<scriptfile>script.php</scriptfile>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
* DEFGROUP: Joomla.Plugin
|
* DEFGROUP: Joomla.Plugin
|
||||||
* INGROUP: MokoSuite
|
* INGROUP: MokoSuite
|
||||||
* REPO: https://github.com/mokoconsulting-tech/mokosuite
|
* REPO: https://github.com/mokoconsulting-tech/mokosuite
|
||||||
* VERSION: 02.34.50
|
* VERSION: 02.34.79
|
||||||
* PATH: /src/script.php
|
* PATH: /src/script.php
|
||||||
* BRIEF: Installation script for MokoSuite plugin
|
* BRIEF: Installation script for MokoSuite plugin
|
||||||
* NOTE: Handles installation, update, and uninstallation tasks including language override deployment
|
* NOTE: Handles installation, update, and uninstallation tasks including language override deployment
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
* DEFGROUP: Joomla.Plugin
|
* DEFGROUP: Joomla.Plugin
|
||||||
* INGROUP: MokoSuite
|
* INGROUP: MokoSuite
|
||||||
* REPO: https://github.com/mokoconsulting-tech/mokosuite
|
* REPO: https://github.com/mokoconsulting-tech/mokosuite
|
||||||
* VERSION: 02.34.50
|
* VERSION: 02.34.79
|
||||||
* PATH: /src/services/provider.php
|
* PATH: /src/services/provider.php
|
||||||
* BRIEF: Service provider for dependency injection in Joomla 5.x
|
* BRIEF: Service provider for dependency injection in Joomla 5.x
|
||||||
* NOTE: Registers the plugin with Joomla's DI container
|
* NOTE: Registers the plugin with Joomla's DI container
|
||||||
|
|||||||
Binary file not shown.
+29
@@ -0,0 +1,29 @@
|
|||||||
|
; MokoSuite DB-IP Plugin
|
||||||
|
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
; License: GPL-3.0-or-later
|
||||||
|
; IP Geolocation by DB-IP — https://db-ip.com
|
||||||
|
|
||||||
|
PLG_SYSTEM_MOKOSUITE_DBIP="System - MokoSuite DB-IP"
|
||||||
|
PLG_SYSTEM_MOKOSUITE_DBIP_DESC="IP geolocation for MokoSuite using DB-IP Lite databases. Ships with country-level data; city-level data is downloaded from CDN or loaded from a local file."
|
||||||
|
|
||||||
|
PLG_SYSTEM_MOKOSUITE_DBIP_FIELDSET_BASIC="DB-IP Settings"
|
||||||
|
PLG_SYSTEM_MOKOSUITE_DBIP_FIELDSET_BASIC_DESC="Configure IP geolocation database source and level."
|
||||||
|
|
||||||
|
PLG_SYSTEM_MOKOSUITE_DBIP_SOURCE_LABEL="Database Source"
|
||||||
|
PLG_SYSTEM_MOKOSUITE_DBIP_SOURCE_DESC="CDN downloads the city database automatically from the configured URL. Local uses a MMDB file you provide on the server."
|
||||||
|
PLG_SYSTEM_MOKOSUITE_DBIP_SOURCE_CDN="CDN (auto-download)"
|
||||||
|
PLG_SYSTEM_MOKOSUITE_DBIP_SOURCE_LOCAL="Local file"
|
||||||
|
|
||||||
|
PLG_SYSTEM_MOKOSUITE_DBIP_DATABASE_LEVEL_LABEL="Database Level"
|
||||||
|
PLG_SYSTEM_MOKOSUITE_DBIP_DATABASE_LEVEL_DESC="Country is bundled (~8 MB). City provides region, city, and coordinates but requires a separate download (~125 MB)."
|
||||||
|
PLG_SYSTEM_MOKOSUITE_DBIP_DATABASE_COUNTRY="Country (bundled)"
|
||||||
|
PLG_SYSTEM_MOKOSUITE_DBIP_DATABASE_CITY="City (remote download)"
|
||||||
|
|
||||||
|
PLG_SYSTEM_MOKOSUITE_DBIP_AUTO_UPDATE_LABEL="Auto-Update Database"
|
||||||
|
PLG_SYSTEM_MOKOSUITE_DBIP_AUTO_UPDATE_DESC="Automatically download the latest city database monthly when an admin visits the backend."
|
||||||
|
|
||||||
|
PLG_SYSTEM_MOKOSUITE_DBIP_CDN_URL_LABEL="CDN Download URL"
|
||||||
|
PLG_SYSTEM_MOKOSUITE_DBIP_CDN_URL_DESC="URL to download the city-level MMDB file. Default points to the MokoConsulting geoip-data repository."
|
||||||
|
|
||||||
|
PLG_SYSTEM_MOKOSUITE_DBIP_LOCAL_PATH_LABEL="Local MMDB Path"
|
||||||
|
PLG_SYSTEM_MOKOSUITE_DBIP_LOCAL_PATH_DESC="Absolute path to a DB-IP MMDB file on the server (e.g. /home/user/dbip-city-lite.mmdb)."
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
; MokoSuite DB-IP Plugin (system strings)
|
||||||
|
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
; License: GPL-3.0-or-later
|
||||||
|
|
||||||
|
PLG_SYSTEM_MOKOSUITE_DBIP="System - MokoSuite DB-IP"
|
||||||
|
PLG_SYSTEM_MOKOSUITE_DBIP_DESC="IP geolocation for MokoSuite using DB-IP Lite databases."
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MaxMind\Db;
|
||||||
|
|
||||||
|
use MaxMind\Db\Reader\Decoder;
|
||||||
|
use MaxMind\Db\Reader\InvalidDatabaseException;
|
||||||
|
use MaxMind\Db\Reader\Metadata;
|
||||||
|
use MaxMind\Db\Reader\Util;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instances of this class provide a reader for the MaxMind DB format. IP
|
||||||
|
* addresses can be looked up using the get method.
|
||||||
|
*/
|
||||||
|
class Reader
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
private static $DATA_SECTION_SEPARATOR_SIZE = 16;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
private static $METADATA_START_MARKER = "\xAB\xCD\xEFMaxMind.com";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int<0, max>
|
||||||
|
*/
|
||||||
|
private static $METADATA_START_MARKER_LENGTH = 14;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
private static $METADATA_MAX_SIZE = 131072; // 128 * 1024 = 128KiB
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Decoder
|
||||||
|
*/
|
||||||
|
private $decoder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var resource
|
||||||
|
*/
|
||||||
|
private $fileHandle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
private $fileSize;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
private $ipV4Start;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Metadata
|
||||||
|
*/
|
||||||
|
private $metadata;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a Reader for the MaxMind DB format. The file passed to it must
|
||||||
|
* be a valid MaxMind DB file such as a DBIP database file.
|
||||||
|
*
|
||||||
|
* @param string $database the MaxMind DB file to use
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException for invalid database path or unknown arguments
|
||||||
|
* @throws InvalidDatabaseException
|
||||||
|
* if the database is invalid or there is an error reading
|
||||||
|
* from it
|
||||||
|
*/
|
||||||
|
public function __construct(string $database)
|
||||||
|
{
|
||||||
|
if (\func_num_args() !== 1) {
|
||||||
|
throw new \ArgumentCountError(
|
||||||
|
\sprintf('%s() expects exactly 1 parameter, %d given', __METHOD__, \func_num_args())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_dir($database)) {
|
||||||
|
// This matches the error that the C extension throws.
|
||||||
|
throw new InvalidDatabaseException(
|
||||||
|
"Error opening database file ($database). Is this a valid MaxMind DB file?"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fileHandle = @fopen($database, 'rb');
|
||||||
|
if ($fileHandle === false) {
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
"The file \"$database\" does not exist or is not readable."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$this->fileHandle = $fileHandle;
|
||||||
|
|
||||||
|
$fstat = fstat($fileHandle);
|
||||||
|
if ($fstat === false) {
|
||||||
|
throw new \UnexpectedValueException(
|
||||||
|
"Error determining the size of \"$database\"."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$this->fileSize = $fstat['size'];
|
||||||
|
|
||||||
|
$start = $this->findMetadataStart($database);
|
||||||
|
$metadataDecoder = new Decoder($this->fileHandle, $start);
|
||||||
|
[$metadataArray] = $metadataDecoder->decode($start);
|
||||||
|
$this->metadata = new Metadata($metadataArray);
|
||||||
|
$this->decoder = new Decoder(
|
||||||
|
$this->fileHandle,
|
||||||
|
$this->metadata->searchTreeSize + self::$DATA_SECTION_SEPARATOR_SIZE
|
||||||
|
);
|
||||||
|
$this->ipV4Start = $this->ipV4StartNode();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the record for the IP address.
|
||||||
|
*
|
||||||
|
* @param string $ipAddress the IP address to look up
|
||||||
|
*
|
||||||
|
* @throws \BadMethodCallException if this method is called on a closed database
|
||||||
|
* @throws \InvalidArgumentException if something other than a single IP address is passed to the method
|
||||||
|
* @throws InvalidDatabaseException
|
||||||
|
* if the database is invalid or there is an error reading
|
||||||
|
* from it
|
||||||
|
*
|
||||||
|
* @return mixed the record for the IP address
|
||||||
|
*/
|
||||||
|
public function get(string $ipAddress)
|
||||||
|
{
|
||||||
|
if (\func_num_args() !== 1) {
|
||||||
|
throw new \ArgumentCountError(
|
||||||
|
\sprintf('%s() expects exactly 1 parameter, %d given', __METHOD__, \func_num_args())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
[$record] = $this->getWithPrefixLen($ipAddress);
|
||||||
|
|
||||||
|
return $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the record for the IP address and its associated network prefix length.
|
||||||
|
*
|
||||||
|
* @param string $ipAddress the IP address to look up
|
||||||
|
*
|
||||||
|
* @throws \BadMethodCallException if this method is called on a closed database
|
||||||
|
* @throws \InvalidArgumentException if something other than a single IP address is passed to the method
|
||||||
|
* @throws InvalidDatabaseException
|
||||||
|
* if the database is invalid or there is an error reading
|
||||||
|
* from it
|
||||||
|
*
|
||||||
|
* @return array{0:mixed, 1:int} an array where the first element is the record and the
|
||||||
|
* second the network prefix length for the record
|
||||||
|
*/
|
||||||
|
public function getWithPrefixLen(string $ipAddress): array
|
||||||
|
{
|
||||||
|
if (\func_num_args() !== 1) {
|
||||||
|
throw new \ArgumentCountError(
|
||||||
|
\sprintf('%s() expects exactly 1 parameter, %d given', __METHOD__, \func_num_args())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!\is_resource($this->fileHandle)) {
|
||||||
|
throw new \BadMethodCallException(
|
||||||
|
'Attempt to read from a closed MaxMind DB.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[$pointer, $prefixLen] = $this->findAddressInTree($ipAddress);
|
||||||
|
if ($pointer === 0) {
|
||||||
|
return [null, $prefixLen];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$this->resolveDataPointer($pointer), $prefixLen];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0:int, 1:int}
|
||||||
|
*/
|
||||||
|
private function findAddressInTree(string $ipAddress): array
|
||||||
|
{
|
||||||
|
$packedAddr = @inet_pton($ipAddress);
|
||||||
|
if ($packedAddr === false) {
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
"The value \"$ipAddress\" is not a valid IP address."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawAddress = unpack('C*', $packedAddr);
|
||||||
|
if ($rawAddress === false) {
|
||||||
|
throw new InvalidDatabaseException(
|
||||||
|
'Could not unpack the unsigned char of the packed in_addr representation.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$bitCount = \count($rawAddress) * 8;
|
||||||
|
|
||||||
|
// The first node of the tree is always node 0, at the beginning of the
|
||||||
|
// value
|
||||||
|
$node = 0;
|
||||||
|
|
||||||
|
$metadata = $this->metadata;
|
||||||
|
|
||||||
|
// Check if we are looking up an IPv4 address in an IPv6 tree. If this
|
||||||
|
// is the case, we can skip over the first 96 nodes.
|
||||||
|
if ($metadata->ipVersion === 6) {
|
||||||
|
if ($bitCount === 32) {
|
||||||
|
$node = $this->ipV4Start;
|
||||||
|
}
|
||||||
|
} elseif ($metadata->ipVersion === 4 && $bitCount === 128) {
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
"Error looking up $ipAddress. You attempted to look up an"
|
||||||
|
. ' IPv6 address in an IPv4-only database.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$nodeCount = $metadata->nodeCount;
|
||||||
|
|
||||||
|
for ($i = 0; $i < $bitCount && $node < $nodeCount; ++$i) {
|
||||||
|
$tempBit = 0xFF & $rawAddress[($i >> 3) + 1];
|
||||||
|
$bit = 1 & ($tempBit >> 7 - ($i % 8));
|
||||||
|
|
||||||
|
$node = $this->readNode($node, $bit);
|
||||||
|
}
|
||||||
|
if ($node === $nodeCount) {
|
||||||
|
// Record is empty
|
||||||
|
return [0, $i];
|
||||||
|
}
|
||||||
|
if ($node > $nodeCount) {
|
||||||
|
// Record is a data pointer
|
||||||
|
return [$node, $i];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidDatabaseException(
|
||||||
|
'Invalid or corrupt database. Maximum search depth reached without finding a leaf node'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ipV4StartNode(): int
|
||||||
|
{
|
||||||
|
// If we have an IPv4 database, the start node is the first node
|
||||||
|
if ($this->metadata->ipVersion === 4) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$node = 0;
|
||||||
|
|
||||||
|
for ($i = 0; $i < 96 && $node < $this->metadata->nodeCount; ++$i) {
|
||||||
|
$node = $this->readNode($node, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $node;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readNode(int $nodeNumber, int $index): int
|
||||||
|
{
|
||||||
|
$baseOffset = $nodeNumber * $this->metadata->nodeByteSize;
|
||||||
|
|
||||||
|
switch ($this->metadata->recordSize) {
|
||||||
|
case 24:
|
||||||
|
$bytes = Util::read($this->fileHandle, $baseOffset + $index * 3, 3);
|
||||||
|
$rc = unpack('N', "\x00" . $bytes);
|
||||||
|
if ($rc === false) {
|
||||||
|
throw new InvalidDatabaseException(
|
||||||
|
'Could not unpack the unsigned long of the node.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
[, $node] = $rc;
|
||||||
|
|
||||||
|
return $node;
|
||||||
|
|
||||||
|
case 28:
|
||||||
|
$bytes = Util::read($this->fileHandle, $baseOffset + 3 * $index, 4);
|
||||||
|
if ($index === 0) {
|
||||||
|
$middle = (0xF0 & \ord($bytes[3])) >> 4;
|
||||||
|
} else {
|
||||||
|
$middle = 0x0F & \ord($bytes[0]);
|
||||||
|
}
|
||||||
|
$rc = unpack('N', \chr($middle) . substr($bytes, $index, 3));
|
||||||
|
if ($rc === false) {
|
||||||
|
throw new InvalidDatabaseException(
|
||||||
|
'Could not unpack the unsigned long of the node.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
[, $node] = $rc;
|
||||||
|
|
||||||
|
return $node;
|
||||||
|
|
||||||
|
case 32:
|
||||||
|
$bytes = Util::read($this->fileHandle, $baseOffset + $index * 4, 4);
|
||||||
|
$rc = unpack('N', $bytes);
|
||||||
|
if ($rc === false) {
|
||||||
|
throw new InvalidDatabaseException(
|
||||||
|
'Could not unpack the unsigned long of the node.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
[, $node] = $rc;
|
||||||
|
|
||||||
|
return $node;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new InvalidDatabaseException(
|
||||||
|
'Unknown record size: '
|
||||||
|
. $this->metadata->recordSize
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
private function resolveDataPointer(int $pointer)
|
||||||
|
{
|
||||||
|
$resolved = $pointer - $this->metadata->nodeCount
|
||||||
|
+ $this->metadata->searchTreeSize;
|
||||||
|
if ($resolved >= $this->fileSize) {
|
||||||
|
throw new InvalidDatabaseException(
|
||||||
|
"The MaxMind DB file's search tree is corrupt"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[$data] = $this->decoder->decode($resolved);
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This is an extremely naive but reasonably readable implementation. There
|
||||||
|
* are much faster algorithms (e.g., Boyer-Moore) for this if speed is ever
|
||||||
|
* an issue, but I suspect it won't be.
|
||||||
|
*/
|
||||||
|
private function findMetadataStart(string $filename): int
|
||||||
|
{
|
||||||
|
$handle = $this->fileHandle;
|
||||||
|
$fileSize = $this->fileSize;
|
||||||
|
$marker = self::$METADATA_START_MARKER;
|
||||||
|
$markerLength = self::$METADATA_START_MARKER_LENGTH;
|
||||||
|
|
||||||
|
$minStart = $fileSize - min(self::$METADATA_MAX_SIZE, $fileSize);
|
||||||
|
|
||||||
|
for ($offset = $fileSize - $markerLength; $offset >= $minStart; --$offset) {
|
||||||
|
if (fseek($handle, $offset) !== 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = fread($handle, $markerLength);
|
||||||
|
if ($value === $marker) {
|
||||||
|
return $offset + $markerLength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidDatabaseException(
|
||||||
|
"Error opening database file ($filename). "
|
||||||
|
. 'Is this a valid MaxMind DB file?'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws \InvalidArgumentException if arguments are passed to the method
|
||||||
|
* @throws \BadMethodCallException if the database has been closed
|
||||||
|
*
|
||||||
|
* @return Metadata object for the database
|
||||||
|
*/
|
||||||
|
public function metadata(): Metadata
|
||||||
|
{
|
||||||
|
if (\func_num_args()) {
|
||||||
|
throw new \ArgumentCountError(
|
||||||
|
\sprintf('%s() expects exactly 0 parameters, %d given', __METHOD__, \func_num_args())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not technically required, but this makes it consistent with
|
||||||
|
// C extension and it allows us to change our implementation later.
|
||||||
|
if (!\is_resource($this->fileHandle)) {
|
||||||
|
throw new \BadMethodCallException(
|
||||||
|
'Attempt to read from a closed MaxMind DB.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return clone $this->metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the MaxMind DB and returns resources to the system.
|
||||||
|
*
|
||||||
|
* @throws \Exception
|
||||||
|
* if an I/O error occurs
|
||||||
|
*/
|
||||||
|
public function close(): void
|
||||||
|
{
|
||||||
|
if (\func_num_args()) {
|
||||||
|
throw new \ArgumentCountError(
|
||||||
|
\sprintf('%s() expects exactly 0 parameters, %d given', __METHOD__, \func_num_args())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!\is_resource($this->fileHandle)) {
|
||||||
|
throw new \BadMethodCallException(
|
||||||
|
'Attempt to close a closed MaxMind DB.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fclose($this->fileHandle);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MaxMind\Db\Reader;
|
||||||
|
|
||||||
|
// @codingStandardsIgnoreLine
|
||||||
|
|
||||||
|
class Decoder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var resource
|
||||||
|
*/
|
||||||
|
private $fileStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
private $pointerBase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is only used for unit testing.
|
||||||
|
*
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
private $pointerTestHack;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
private $switchByteOrder;
|
||||||
|
|
||||||
|
private const _EXTENDED = 0;
|
||||||
|
private const _POINTER = 1;
|
||||||
|
private const _UTF8_STRING = 2;
|
||||||
|
private const _DOUBLE = 3;
|
||||||
|
private const _BYTES = 4;
|
||||||
|
private const _UINT16 = 5;
|
||||||
|
private const _UINT32 = 6;
|
||||||
|
private const _MAP = 7;
|
||||||
|
private const _INT32 = 8;
|
||||||
|
private const _UINT64 = 9;
|
||||||
|
private const _UINT128 = 10;
|
||||||
|
private const _ARRAY = 11;
|
||||||
|
// 12 is the container type
|
||||||
|
// 13 is the end marker type
|
||||||
|
private const _BOOLEAN = 14;
|
||||||
|
private const _FLOAT = 15;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param resource $fileStream
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
$fileStream,
|
||||||
|
int $pointerBase = 0,
|
||||||
|
bool $pointerTestHack = false
|
||||||
|
) {
|
||||||
|
$this->fileStream = $fileStream;
|
||||||
|
$this->pointerBase = $pointerBase;
|
||||||
|
|
||||||
|
$this->pointerTestHack = $pointerTestHack;
|
||||||
|
|
||||||
|
$this->switchByteOrder = $this->isPlatformLittleEndian();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<mixed>
|
||||||
|
*/
|
||||||
|
public function decode(int $offset): array
|
||||||
|
{
|
||||||
|
$ctrlByte = \ord(Util::read($this->fileStream, $offset, 1));
|
||||||
|
++$offset;
|
||||||
|
|
||||||
|
$type = $ctrlByte >> 5;
|
||||||
|
|
||||||
|
// Pointers are a special case, we don't read the next $size bytes, we
|
||||||
|
// use the size to determine the length of the pointer and then follow
|
||||||
|
// it.
|
||||||
|
if ($type === self::_POINTER) {
|
||||||
|
[$pointer, $offset] = $this->decodePointer($ctrlByte, $offset);
|
||||||
|
|
||||||
|
// for unit testing
|
||||||
|
if ($this->pointerTestHack) {
|
||||||
|
return [$pointer];
|
||||||
|
}
|
||||||
|
|
||||||
|
[$result] = $this->decode($pointer);
|
||||||
|
|
||||||
|
return [$result, $offset];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type === self::_EXTENDED) {
|
||||||
|
$nextByte = \ord(Util::read($this->fileStream, $offset, 1));
|
||||||
|
|
||||||
|
$type = $nextByte + 7;
|
||||||
|
|
||||||
|
if ($type < 8) {
|
||||||
|
throw new InvalidDatabaseException(
|
||||||
|
'Something went horribly wrong in the decoder. An extended type '
|
||||||
|
. 'resolved to a type number < 8 ('
|
||||||
|
. $type
|
||||||
|
. ')'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
++$offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$size, $offset] = $this->sizeFromCtrlByte($ctrlByte, $offset);
|
||||||
|
|
||||||
|
return $this->decodeByType($type, $offset, $size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int<0, max> $size
|
||||||
|
*
|
||||||
|
* @return array{0:mixed, 1:int}
|
||||||
|
*/
|
||||||
|
private function decodeByType(int $type, int $offset, int $size): array
|
||||||
|
{
|
||||||
|
switch ($type) {
|
||||||
|
case self::_MAP:
|
||||||
|
return $this->decodeMap($size, $offset);
|
||||||
|
|
||||||
|
case self::_ARRAY:
|
||||||
|
return $this->decodeArray($size, $offset);
|
||||||
|
|
||||||
|
case self::_BOOLEAN:
|
||||||
|
return [$this->decodeBoolean($size), $offset];
|
||||||
|
}
|
||||||
|
|
||||||
|
$newOffset = $offset + $size;
|
||||||
|
$bytes = Util::read($this->fileStream, $offset, $size);
|
||||||
|
|
||||||
|
switch ($type) {
|
||||||
|
case self::_BYTES:
|
||||||
|
case self::_UTF8_STRING:
|
||||||
|
return [$bytes, $newOffset];
|
||||||
|
|
||||||
|
case self::_DOUBLE:
|
||||||
|
$this->verifySize(8, $size);
|
||||||
|
|
||||||
|
return [$this->decodeDouble($bytes), $newOffset];
|
||||||
|
|
||||||
|
case self::_FLOAT:
|
||||||
|
$this->verifySize(4, $size);
|
||||||
|
|
||||||
|
return [$this->decodeFloat($bytes), $newOffset];
|
||||||
|
|
||||||
|
case self::_INT32:
|
||||||
|
return [$this->decodeInt32($bytes, $size), $newOffset];
|
||||||
|
|
||||||
|
case self::_UINT16:
|
||||||
|
case self::_UINT32:
|
||||||
|
case self::_UINT64:
|
||||||
|
case self::_UINT128:
|
||||||
|
return [$this->decodeUint($bytes, $size), $newOffset];
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new InvalidDatabaseException(
|
||||||
|
'Unknown or unexpected type: ' . $type
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function verifySize(int $expected, int $actual): void
|
||||||
|
{
|
||||||
|
if ($expected !== $actual) {
|
||||||
|
throw new InvalidDatabaseException(
|
||||||
|
"The MaxMind DB file's data section contains bad data (unknown data type or corrupt data)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0:array<mixed>, 1:int}
|
||||||
|
*/
|
||||||
|
private function decodeArray(int $size, int $offset): array
|
||||||
|
{
|
||||||
|
$array = [];
|
||||||
|
|
||||||
|
for ($i = 0; $i < $size; ++$i) {
|
||||||
|
[$value, $offset] = $this->decode($offset);
|
||||||
|
$array[] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$array, $offset];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decodeBoolean(int $size): bool
|
||||||
|
{
|
||||||
|
return $size !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decodeDouble(string $bytes): float
|
||||||
|
{
|
||||||
|
// This assumes IEEE 754 doubles, but most (all?) modern platforms
|
||||||
|
// use them.
|
||||||
|
$rc = unpack('E', $bytes);
|
||||||
|
if ($rc === false) {
|
||||||
|
throw new InvalidDatabaseException(
|
||||||
|
'Could not unpack a double value from the given bytes.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
[, $double] = $rc;
|
||||||
|
|
||||||
|
return $double;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decodeFloat(string $bytes): float
|
||||||
|
{
|
||||||
|
// This assumes IEEE 754 floats, but most (all?) modern platforms
|
||||||
|
// use them.
|
||||||
|
$rc = unpack('G', $bytes);
|
||||||
|
if ($rc === false) {
|
||||||
|
throw new InvalidDatabaseException(
|
||||||
|
'Could not unpack a float value from the given bytes.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
[, $float] = $rc;
|
||||||
|
|
||||||
|
return $float;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decodeInt32(string $bytes, int $size): int
|
||||||
|
{
|
||||||
|
switch ($size) {
|
||||||
|
case 0:
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
case 1:
|
||||||
|
case 2:
|
||||||
|
case 3:
|
||||||
|
$bytes = str_pad($bytes, 4, "\x00", \STR_PAD_LEFT);
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 4:
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new InvalidDatabaseException(
|
||||||
|
"The MaxMind DB file's data section contains bad data (unknown data type or corrupt data)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rc = unpack('l', $this->maybeSwitchByteOrder($bytes));
|
||||||
|
if ($rc === false) {
|
||||||
|
throw new InvalidDatabaseException(
|
||||||
|
'Could not unpack a 32bit integer value from the given bytes.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
[, $int] = $rc;
|
||||||
|
|
||||||
|
return $int;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0:array<string, mixed>, 1:int}
|
||||||
|
*/
|
||||||
|
private function decodeMap(int $size, int $offset): array
|
||||||
|
{
|
||||||
|
$map = [];
|
||||||
|
|
||||||
|
for ($i = 0; $i < $size; ++$i) {
|
||||||
|
[$key, $offset] = $this->decode($offset);
|
||||||
|
[$value, $offset] = $this->decode($offset);
|
||||||
|
$map[$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$map, $offset];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0:int, 1:int}
|
||||||
|
*/
|
||||||
|
private function decodePointer(int $ctrlByte, int $offset): array
|
||||||
|
{
|
||||||
|
$pointerSize = (($ctrlByte >> 3) & 0x3) + 1;
|
||||||
|
|
||||||
|
$buffer = Util::read($this->fileStream, $offset, $pointerSize);
|
||||||
|
$offset += $pointerSize;
|
||||||
|
|
||||||
|
switch ($pointerSize) {
|
||||||
|
case 1:
|
||||||
|
$packed = \chr($ctrlByte & 0x7) . $buffer;
|
||||||
|
$rc = unpack('n', $packed);
|
||||||
|
if ($rc === false) {
|
||||||
|
throw new InvalidDatabaseException(
|
||||||
|
'Could not unpack an unsigned short value from the given bytes (pointerSize is 1).'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
[, $pointer] = $rc;
|
||||||
|
$pointer += $this->pointerBase;
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 2:
|
||||||
|
$packed = "\x00" . \chr($ctrlByte & 0x7) . $buffer;
|
||||||
|
$rc = unpack('N', $packed);
|
||||||
|
if ($rc === false) {
|
||||||
|
throw new InvalidDatabaseException(
|
||||||
|
'Could not unpack an unsigned long value from the given bytes (pointerSize is 2).'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
[, $pointer] = $rc;
|
||||||
|
$pointer += $this->pointerBase + 2048;
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 3:
|
||||||
|
$packed = \chr($ctrlByte & 0x7) . $buffer;
|
||||||
|
|
||||||
|
// It is safe to use 'N' here, even on 32 bit machines as the
|
||||||
|
// first bit is 0.
|
||||||
|
$rc = unpack('N', $packed);
|
||||||
|
if ($rc === false) {
|
||||||
|
throw new InvalidDatabaseException(
|
||||||
|
'Could not unpack an unsigned long value from the given bytes (pointerSize is 3).'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
[, $pointer] = $rc;
|
||||||
|
$pointer += $this->pointerBase + 526336;
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 4:
|
||||||
|
// We cannot use unpack here as we might overflow on 32 bit
|
||||||
|
// machines
|
||||||
|
$pointerOffset = $this->decodeUint($buffer, $pointerSize);
|
||||||
|
|
||||||
|
$pointerBase = $this->pointerBase;
|
||||||
|
|
||||||
|
if (\PHP_INT_MAX - $pointerBase >= $pointerOffset) {
|
||||||
|
$pointer = $pointerOffset + $pointerBase;
|
||||||
|
} else {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
'The database offset is too large to be represented on your platform.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new InvalidDatabaseException(
|
||||||
|
'Unexpected pointer size ' . $pointerSize
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$pointer, $offset];
|
||||||
|
}
|
||||||
|
|
||||||
|
// @phpstan-ignore-next-line
|
||||||
|
private function decodeUint(string $bytes, int $byteLength)
|
||||||
|
{
|
||||||
|
if ($byteLength === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PHP integers are signed. PHP_INT_SIZE - 1 is the number of
|
||||||
|
// complete bytes that can be converted to an integer. However,
|
||||||
|
// we can convert another byte if the leading bit is zero.
|
||||||
|
$useRealInts = $byteLength <= \PHP_INT_SIZE - 1
|
||||||
|
|| ($byteLength === \PHP_INT_SIZE && (\ord($bytes[0]) & 0x80) === 0);
|
||||||
|
|
||||||
|
if ($useRealInts) {
|
||||||
|
$integer = 0;
|
||||||
|
for ($i = 0; $i < $byteLength; ++$i) {
|
||||||
|
$part = \ord($bytes[$i]);
|
||||||
|
$integer = ($integer << 8) + $part;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $integer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We only use gmp or bcmath if the final value is too big
|
||||||
|
$integerAsString = '0';
|
||||||
|
for ($i = 0; $i < $byteLength; ++$i) {
|
||||||
|
$part = \ord($bytes[$i]);
|
||||||
|
|
||||||
|
if (\extension_loaded('gmp')) {
|
||||||
|
$integerAsString = gmp_strval(gmp_add(gmp_mul($integerAsString, '256'), $part));
|
||||||
|
} elseif (\extension_loaded('bcmath')) {
|
||||||
|
$integerAsString = bcadd(bcmul($integerAsString, '256'), (string) $part);
|
||||||
|
} else {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
'The gmp or bcmath extension must be installed to read this database.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $integerAsString;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0:int, 1:int}
|
||||||
|
*/
|
||||||
|
private function sizeFromCtrlByte(int $ctrlByte, int $offset): array
|
||||||
|
{
|
||||||
|
$size = $ctrlByte & 0x1F;
|
||||||
|
|
||||||
|
if ($size < 29) {
|
||||||
|
return [$size, $offset];
|
||||||
|
}
|
||||||
|
|
||||||
|
$bytesToRead = $size - 28;
|
||||||
|
$bytes = Util::read($this->fileStream, $offset, $bytesToRead);
|
||||||
|
|
||||||
|
if ($size === 29) {
|
||||||
|
$size = 29 + \ord($bytes);
|
||||||
|
} elseif ($size === 30) {
|
||||||
|
$rc = unpack('n', $bytes);
|
||||||
|
if ($rc === false) {
|
||||||
|
throw new InvalidDatabaseException(
|
||||||
|
'Could not unpack an unsigned short value from the given bytes.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
[, $adjust] = $rc;
|
||||||
|
$size = 285 + $adjust;
|
||||||
|
} else {
|
||||||
|
$rc = unpack('N', "\x00" . $bytes);
|
||||||
|
if ($rc === false) {
|
||||||
|
throw new InvalidDatabaseException(
|
||||||
|
'Could not unpack an unsigned long value from the given bytes.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
[, $adjust] = $rc;
|
||||||
|
$size = $adjust + 65821;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$size, $offset + $bytesToRead];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function maybeSwitchByteOrder(string $bytes): string
|
||||||
|
{
|
||||||
|
return $this->switchByteOrder ? strrev($bytes) : $bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isPlatformLittleEndian(): bool
|
||||||
|
{
|
||||||
|
$testint = 0x00FF;
|
||||||
|
$packed = pack('S', $testint);
|
||||||
|
$rc = unpack('v', $packed);
|
||||||
|
if ($rc === false) {
|
||||||
|
throw new InvalidDatabaseException(
|
||||||
|
'Could not unpack an unsigned short value from the given bytes.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $testint === current($rc);
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MaxMind\Db\Reader;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class should be thrown when unexpected data is found in the database.
|
||||||
|
*/
|
||||||
|
// phpcs:disable
|
||||||
|
class InvalidDatabaseException extends \Exception {}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MaxMind\Db\Reader;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class provides the metadata for the MaxMind DB file.
|
||||||
|
*/
|
||||||
|
class Metadata
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* This is an unsigned 16-bit integer indicating the major version number
|
||||||
|
* for the database's binary format.
|
||||||
|
*
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
public $binaryFormatMajorVersion;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is an unsigned 16-bit integer indicating the minor version number
|
||||||
|
* for the database's binary format.
|
||||||
|
*
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
public $binaryFormatMinorVersion;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is an unsigned 64-bit integer that contains the database build
|
||||||
|
* timestamp as a Unix epoch value.
|
||||||
|
*
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
public $buildEpoch;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a string that indicates the structure of each data record
|
||||||
|
* associated with an IP address. The actual definition of these
|
||||||
|
* structures is left up to the database creator.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
public $databaseType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This key will always point to a map (associative array). The keys of
|
||||||
|
* that map will be language codes, and the values will be a description
|
||||||
|
* in that language as a UTF-8 string. May be undefined for some
|
||||||
|
* databases.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
public $description;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is an unsigned 16-bit integer which is always 4 or 6. It indicates
|
||||||
|
* whether the database contains IPv4 or IPv6 address data.
|
||||||
|
*
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
public $ipVersion;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An array of strings, each of which is a language code. A given record
|
||||||
|
* may contain data items that have been localized to some or all of
|
||||||
|
* these languages. This may be undefined.
|
||||||
|
*
|
||||||
|
* @var array<string>
|
||||||
|
*/
|
||||||
|
public $languages;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
public $nodeByteSize;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is an unsigned 32-bit integer indicating the number of nodes in
|
||||||
|
* the search tree.
|
||||||
|
*
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
public $nodeCount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is an unsigned 16-bit integer. It indicates the number of bits in a
|
||||||
|
* record in the search tree. Note that each node consists of two records.
|
||||||
|
*
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
public $recordSize;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
public $searchTreeSize;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $metadata
|
||||||
|
*/
|
||||||
|
public function __construct(array $metadata)
|
||||||
|
{
|
||||||
|
if (\func_num_args() !== 1) {
|
||||||
|
throw new \ArgumentCountError(
|
||||||
|
\sprintf('%s() expects exactly 1 parameter, %d given', __METHOD__, \func_num_args())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->binaryFormatMajorVersion
|
||||||
|
= $metadata['binary_format_major_version'];
|
||||||
|
$this->binaryFormatMinorVersion
|
||||||
|
= $metadata['binary_format_minor_version'];
|
||||||
|
$this->buildEpoch = $metadata['build_epoch'];
|
||||||
|
$this->databaseType = $metadata['database_type'];
|
||||||
|
$this->languages = $metadata['languages'];
|
||||||
|
$this->description = $metadata['description'];
|
||||||
|
$this->ipVersion = $metadata['ip_version'];
|
||||||
|
$this->nodeCount = $metadata['node_count'];
|
||||||
|
$this->recordSize = $metadata['record_size'];
|
||||||
|
$this->nodeByteSize = $this->recordSize / 4;
|
||||||
|
$this->searchTreeSize = $this->nodeCount * $this->nodeByteSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MaxMind\Db\Reader;
|
||||||
|
|
||||||
|
class Util
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param resource $stream
|
||||||
|
* @param int<0, max> $numberOfBytes
|
||||||
|
*/
|
||||||
|
public static function read($stream, int $offset, int $numberOfBytes): string
|
||||||
|
{
|
||||||
|
if ($numberOfBytes === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (fseek($stream, $offset) === 0) {
|
||||||
|
$value = fread($stream, $numberOfBytes);
|
||||||
|
|
||||||
|
// We check that the number of bytes read is equal to the number
|
||||||
|
// asked for. We use ftell as getting the length of $value is
|
||||||
|
// much slower.
|
||||||
|
if ($value !== false && ftell($stream) - $offset === $numberOfBytes) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidDatabaseException(
|
||||||
|
'The MaxMind DB file contains bad data'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<extension type="plugin" group="system" method="upgrade">
|
||||||
|
<name>System - MokoSuite DB-IP</name>
|
||||||
|
<element>mokosuite_dbip</element>
|
||||||
|
<author>Moko Consulting</author>
|
||||||
|
<creationDate>2026-06-07</creationDate>
|
||||||
|
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
||||||
|
<license>GPL-3.0-or-later</license>
|
||||||
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
|
<version>02.34.79-dev</version>
|
||||||
|
<description>PLG_SYSTEM_MOKOSUITE_DBIP_DESC</description>
|
||||||
|
<namespace path="src">Moko\Plugin\System\MokoSuiteDBIP</namespace>
|
||||||
|
|
||||||
|
<files>
|
||||||
|
<folder>src</folder>
|
||||||
|
<folder>services</folder>
|
||||||
|
<folder>language</folder>
|
||||||
|
<folder>lib</folder>
|
||||||
|
<folder>data</folder>
|
||||||
|
</files>
|
||||||
|
|
||||||
|
<languages folder="language">
|
||||||
|
<language tag="en-GB">en-GB/plg_system_mokosuite_dbip.ini</language>
|
||||||
|
<language tag="en-GB">en-GB/plg_system_mokosuite_dbip.sys.ini</language>
|
||||||
|
</languages>
|
||||||
|
|
||||||
|
<config>
|
||||||
|
<fields name="params">
|
||||||
|
<fieldset name="basic"
|
||||||
|
label="PLG_SYSTEM_MOKOSUITE_DBIP_FIELDSET_BASIC"
|
||||||
|
description="PLG_SYSTEM_MOKOSUITE_DBIP_FIELDSET_BASIC_DESC">
|
||||||
|
|
||||||
|
<field name="database_source" type="list" default="cdn"
|
||||||
|
label="PLG_SYSTEM_MOKOSUITE_DBIP_SOURCE_LABEL"
|
||||||
|
description="PLG_SYSTEM_MOKOSUITE_DBIP_SOURCE_DESC">
|
||||||
|
<option value="cdn">PLG_SYSTEM_MOKOSUITE_DBIP_SOURCE_CDN</option>
|
||||||
|
<option value="local">PLG_SYSTEM_MOKOSUITE_DBIP_SOURCE_LOCAL</option>
|
||||||
|
</field>
|
||||||
|
|
||||||
|
<field name="database_level" type="list" default="country"
|
||||||
|
label="PLG_SYSTEM_MOKOSUITE_DBIP_DATABASE_LEVEL_LABEL"
|
||||||
|
description="PLG_SYSTEM_MOKOSUITE_DBIP_DATABASE_LEVEL_DESC">
|
||||||
|
<option value="country">PLG_SYSTEM_MOKOSUITE_DBIP_DATABASE_COUNTRY</option>
|
||||||
|
<option value="city">PLG_SYSTEM_MOKOSUITE_DBIP_DATABASE_CITY</option>
|
||||||
|
</field>
|
||||||
|
|
||||||
|
<field name="auto_update" type="radio" default="1"
|
||||||
|
label="PLG_SYSTEM_MOKOSUITE_DBIP_AUTO_UPDATE_LABEL"
|
||||||
|
description="PLG_SYSTEM_MOKOSUITE_DBIP_AUTO_UPDATE_DESC"
|
||||||
|
class="btn-group btn-group-yesno"
|
||||||
|
showon="database_source:cdn">
|
||||||
|
<option value="1">JYES</option>
|
||||||
|
<option value="0">JNO</option>
|
||||||
|
</field>
|
||||||
|
|
||||||
|
<field name="cdn_url" type="url"
|
||||||
|
default="https://git.mokoconsulting.tech/MokoConsulting/geoip-data/releases/download/latest/dbip-city-lite.mmdb"
|
||||||
|
label="PLG_SYSTEM_MOKOSUITE_DBIP_CDN_URL_LABEL"
|
||||||
|
description="PLG_SYSTEM_MOKOSUITE_DBIP_CDN_URL_DESC"
|
||||||
|
filter="url"
|
||||||
|
showon="database_source:cdn" />
|
||||||
|
|
||||||
|
<field name="local_path" type="text"
|
||||||
|
default=""
|
||||||
|
label="PLG_SYSTEM_MOKOSUITE_DBIP_LOCAL_PATH_LABEL"
|
||||||
|
description="PLG_SYSTEM_MOKOSUITE_DBIP_LOCAL_PATH_DESC"
|
||||||
|
filter="path"
|
||||||
|
showon="database_source:local" />
|
||||||
|
|
||||||
|
<field name="last_updated" type="hidden" default="" filter="raw" />
|
||||||
|
</fieldset>
|
||||||
|
</fields>
|
||||||
|
</config>
|
||||||
|
</extension>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Moko.Plugin.System.MokoSuiteDBIP
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Extension\PluginInterface;
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\Plugin\PluginHelper;
|
||||||
|
use Joomla\DI\Container;
|
||||||
|
use Joomla\DI\ServiceProviderInterface;
|
||||||
|
use Joomla\Event\DispatcherInterface;
|
||||||
|
use Moko\Plugin\System\MokoSuiteDBIP\Extension\DBIP;
|
||||||
|
|
||||||
|
return new class implements ServiceProviderInterface
|
||||||
|
{
|
||||||
|
public function register(Container $container): void
|
||||||
|
{
|
||||||
|
$container->set(
|
||||||
|
PluginInterface::class,
|
||||||
|
function (Container $container) {
|
||||||
|
$dispatcher = $container->get(DispatcherInterface::class);
|
||||||
|
$plugin = new DBIP($dispatcher, (array) PluginHelper::getPlugin('system', 'mokosuite_dbip'));
|
||||||
|
$plugin->setApplication(Factory::getApplication());
|
||||||
|
|
||||||
|
return $plugin;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Moko.Plugin.System.MokoSuiteDBIP
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* IP Geolocation by DB-IP — https://db-ip.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Plugin\System\MokoSuiteDBIP\Extension;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Plugin\CMSPlugin;
|
||||||
|
use Joomla\Event\SubscriberInterface;
|
||||||
|
use Moko\Plugin\System\MokoSuiteDBIP\Helper\DBIPHelper;
|
||||||
|
|
||||||
|
class DBIP extends CMSPlugin implements SubscriberInterface
|
||||||
|
{
|
||||||
|
protected $autoloadLanguage = true;
|
||||||
|
|
||||||
|
public static function getSubscribedEvents(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'onAfterInitialise' => 'onAfterInitialise',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize DB-IP: set local path if configured, auto-download city DB if needed.
|
||||||
|
*/
|
||||||
|
public function onAfterInitialise(): void
|
||||||
|
{
|
||||||
|
$source = $this->params->get('database_source', 'cdn');
|
||||||
|
$level = $this->params->get('database_level', 'country');
|
||||||
|
|
||||||
|
// If using a local MMDB file, configure the helper
|
||||||
|
if ($source === 'local')
|
||||||
|
{
|
||||||
|
$localPath = $this->params->get('local_path', '');
|
||||||
|
|
||||||
|
if ($localPath !== '')
|
||||||
|
{
|
||||||
|
DBIPHelper::setLocalPath($localPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CDN mode: auto-download city DB if selected and needed
|
||||||
|
if ($level !== 'city' || !$this->params->get('auto_update', 1))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cityPath = DBIPHelper::getCityDbPath();
|
||||||
|
|
||||||
|
if (file_exists($cityPath))
|
||||||
|
{
|
||||||
|
$age = time() - filemtime($cityPath);
|
||||||
|
|
||||||
|
if ($age < 86400 * 30)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only download during admin page loads
|
||||||
|
$app = $this->getApplication();
|
||||||
|
|
||||||
|
if (!$app->isClient('administrator'))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = $this->params->get(
|
||||||
|
'cdn_url',
|
||||||
|
'https://git.mokoconsulting.tech/MokoConsulting/geoip-data/releases/download/latest/dbip-city-lite.mmdb'
|
||||||
|
);
|
||||||
|
|
||||||
|
DBIPHelper::downloadCityDb($url);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Moko.Plugin.System.MokoSuiteDBIP
|
||||||
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
|
* @license GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* IP Geolocation by DB-IP — https://db-ip.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Moko\Plugin\System\MokoSuiteDBIP\Helper;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use MaxMind\Db\Reader;
|
||||||
|
|
||||||
|
class DBIPHelper
|
||||||
|
{
|
||||||
|
private static ?Reader $countryReader = null;
|
||||||
|
|
||||||
|
private static ?Reader $cityReader = null;
|
||||||
|
|
||||||
|
private static bool $libLoaded = false;
|
||||||
|
|
||||||
|
private static string $customLocalPath = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a custom local path for the city database.
|
||||||
|
*/
|
||||||
|
public static function setLocalPath(string $path): void
|
||||||
|
{
|
||||||
|
self::$customLocalPath = $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the path to the bundled country database.
|
||||||
|
*/
|
||||||
|
public static function getCountryDbPath(): string
|
||||||
|
{
|
||||||
|
return JPATH_PLUGINS . '/system/mokosuite_dbip/data/dbip-country-lite.mmdb';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the path to the city database.
|
||||||
|
* Uses custom local path if set, otherwise the CDN cache location.
|
||||||
|
*/
|
||||||
|
public static function getCityDbPath(): string
|
||||||
|
{
|
||||||
|
if (self::$customLocalPath !== '' && file_exists(self::$customLocalPath))
|
||||||
|
{
|
||||||
|
return self::$customLocalPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JPATH_ADMINISTRATOR . '/cache/mokosuite_dbip/dbip-city-lite.mmdb';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the MaxMind DB Reader library.
|
||||||
|
*/
|
||||||
|
private static function loadLib(): void
|
||||||
|
{
|
||||||
|
if (self::$libLoaded)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$libPath = JPATH_PLUGINS . '/system/mokosuite_dbip/lib';
|
||||||
|
|
||||||
|
require_once $libPath . '/MaxMind/Db/Reader.php';
|
||||||
|
require_once $libPath . '/MaxMind/Db/Reader/Decoder.php';
|
||||||
|
require_once $libPath . '/MaxMind/Db/Reader/InvalidDatabaseException.php';
|
||||||
|
require_once $libPath . '/MaxMind/Db/Reader/Metadata.php';
|
||||||
|
require_once $libPath . '/MaxMind/Db/Reader/Util.php';
|
||||||
|
|
||||||
|
self::$libLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up an IP address and return geolocation data.
|
||||||
|
*
|
||||||
|
* @param string $ip The IP address to look up.
|
||||||
|
*
|
||||||
|
* @return array|null Geolocation data or null if not found.
|
||||||
|
*
|
||||||
|
* Result keys (country DB): country_code, country_name, continent_code, continent_name
|
||||||
|
* Result keys (city DB): + region, city, latitude, longitude, timezone
|
||||||
|
*/
|
||||||
|
public static function lookup(string $ip): ?array
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
self::loadLib();
|
||||||
|
|
||||||
|
// Try city database first
|
||||||
|
$cityPath = self::getCityDbPath();
|
||||||
|
|
||||||
|
if (file_exists($cityPath))
|
||||||
|
{
|
||||||
|
if (self::$cityReader === null)
|
||||||
|
{
|
||||||
|
self::$cityReader = new Reader($cityPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
$record = self::$cityReader->get($ip);
|
||||||
|
|
||||||
|
if ($record !== null)
|
||||||
|
{
|
||||||
|
return self::normalizeCityRecord($record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to bundled country database
|
||||||
|
$countryPath = self::getCountryDbPath();
|
||||||
|
|
||||||
|
if (file_exists($countryPath))
|
||||||
|
{
|
||||||
|
if (self::$countryReader === null)
|
||||||
|
{
|
||||||
|
self::$countryReader = new Reader($countryPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
$record = self::$countryReader->get($ip);
|
||||||
|
|
||||||
|
if ($record !== null)
|
||||||
|
{
|
||||||
|
return self::normalizeCountryRecord($record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (\Throwable $e)
|
||||||
|
{
|
||||||
|
// Silent — don't break the site if DB-IP fails
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up country only (uses bundled DB, always available).
|
||||||
|
*/
|
||||||
|
public static function lookupCountry(string $ip): ?string
|
||||||
|
{
|
||||||
|
$result = self::lookup($ip);
|
||||||
|
|
||||||
|
return $result['country_code'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the city database is installed.
|
||||||
|
*/
|
||||||
|
public static function hasCityDb(): bool
|
||||||
|
{
|
||||||
|
return file_exists(self::getCityDbPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download the city database from the configured URL.
|
||||||
|
*
|
||||||
|
* @param string $url The download URL for the city MMDB file.
|
||||||
|
*
|
||||||
|
* @return bool True on success.
|
||||||
|
*/
|
||||||
|
public static function downloadCityDb(string $url): bool
|
||||||
|
{
|
||||||
|
$destPath = JPATH_ADMINISTRATOR . '/cache/mokosuite_dbip/dbip-city-lite.mmdb';
|
||||||
|
$destDir = \dirname($destPath);
|
||||||
|
|
||||||
|
if (!is_dir($destDir))
|
||||||
|
{
|
||||||
|
mkdir($destDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tmpFile = $destPath . '.tmp';
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
$ch = curl_init($url);
|
||||||
|
$fp = fopen($tmpFile, 'wb');
|
||||||
|
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
\CURLOPT_FILE => $fp,
|
||||||
|
\CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
\CURLOPT_TIMEOUT => 300,
|
||||||
|
\CURLOPT_CONNECTTIMEOUT => 30,
|
||||||
|
\CURLOPT_USERAGENT => 'MokoSuite-DBIP/1.0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$success = curl_exec($ch);
|
||||||
|
$code = curl_getinfo($ch, \CURLINFO_HTTP_CODE);
|
||||||
|
|
||||||
|
curl_close($ch);
|
||||||
|
fclose($fp);
|
||||||
|
|
||||||
|
if ($success && $code === 200 && filesize($tmpFile) > 1024)
|
||||||
|
{
|
||||||
|
if (self::$cityReader !== null)
|
||||||
|
{
|
||||||
|
self::$cityReader->close();
|
||||||
|
self::$cityReader = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
rename($tmpFile, $destPath);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@unlink($tmpFile);
|
||||||
|
}
|
||||||
|
catch (\Throwable $e)
|
||||||
|
{
|
||||||
|
@unlink($tmpFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a DB-IP city record into a flat array.
|
||||||
|
*/
|
||||||
|
private static function normalizeCityRecord(array $record): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'country_code' => $record['country']['iso_code'] ?? '',
|
||||||
|
'country_name' => $record['country']['names']['en'] ?? '',
|
||||||
|
'continent_code' => $record['continent']['code'] ?? '',
|
||||||
|
'continent_name' => $record['continent']['names']['en'] ?? '',
|
||||||
|
'region' => $record['subdivisions'][0]['names']['en'] ?? '',
|
||||||
|
'city' => $record['city']['names']['en'] ?? '',
|
||||||
|
'latitude' => $record['location']['latitude'] ?? null,
|
||||||
|
'longitude' => $record['location']['longitude'] ?? null,
|
||||||
|
'timezone' => $record['location']['time_zone'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a DB-IP country record into a flat array.
|
||||||
|
*/
|
||||||
|
private static function normalizeCountryRecord(array $record): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'country_code' => $record['country']['iso_code'] ?? '',
|
||||||
|
'country_name' => $record['country']['names']['en'] ?? '',
|
||||||
|
'continent_code' => $record['continent']['code'] ?? '',
|
||||||
|
'continent_name' => $record['continent']['names']['en'] ?? '',
|
||||||
|
'region' => '',
|
||||||
|
'city' => '',
|
||||||
|
'latitude' => null,
|
||||||
|
'longitude' => null,
|
||||||
|
'timezone' => '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shut down readers.
|
||||||
|
*/
|
||||||
|
public static function close(): void
|
||||||
|
{
|
||||||
|
if (self::$countryReader !== null)
|
||||||
|
{
|
||||||
|
self::$countryReader->close();
|
||||||
|
self::$countryReader = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::$cityReader !== null)
|
||||||
|
{
|
||||||
|
self::$cityReader->close();
|
||||||
|
self::$cityReader = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.34.50-dev</version>
|
<version>02.34.79-dev</version>
|
||||||
<description>PLG_SYSTEM_MOKOSUITE_DEVTOOLS_DESC</description>
|
<description>PLG_SYSTEM_MOKOSUITE_DEVTOOLS_DESC</description>
|
||||||
<namespace path="src">Moko\Plugin\System\MokoSuiteDevTools</namespace>
|
<namespace path="src">Moko\Plugin\System\MokoSuiteDevTools</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.34.50-dev</version>
|
<version>02.34.79-dev</version>
|
||||||
<description>PLG_SYSTEM_MOKOSUITE_FIREWALL_DESC</description>
|
<description>PLG_SYSTEM_MOKOSUITE_FIREWALL_DESC</description>
|
||||||
<namespace path="src">Moko\Plugin\System\MokoSuiteFirewall</namespace>
|
<namespace path="src">Moko\Plugin\System\MokoSuiteFirewall</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.34.50-dev</version>
|
<version>02.34.79-dev</version>
|
||||||
<description>PLG_SYSTEM_MOKOSUITE_LICENSE_DESC</description>
|
<description>PLG_SYSTEM_MOKOSUITE_LICENSE_DESC</description>
|
||||||
<namespace path="src">Moko\Plugin\System\MokoSuiteLicense</namespace>
|
<namespace path="src">Moko\Plugin\System\MokoSuiteLicense</namespace>
|
||||||
<files><folder>src</folder><folder>services</folder><folder>language</folder></files>
|
<files><folder>src</folder><folder>services</folder><folder>language</folder></files>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.34.50-dev</version>
|
<version>02.34.79-dev</version>
|
||||||
<description>PLG_SYSTEM_MOKOSUITE_MONITOR_DESC</description>
|
<description>PLG_SYSTEM_MOKOSUITE_MONITOR_DESC</description>
|
||||||
<namespace path="src">Moko\Plugin\System\MokoSuiteMonitor</namespace>
|
<namespace path="src">Moko\Plugin\System\MokoSuiteMonitor</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -34,10 +34,58 @@ class Monitor extends CMSPlugin implements SubscriberInterface
|
|||||||
public static function getSubscribedEvents(): array
|
public static function getSubscribedEvents(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'onExtensionAfterSave' => 'onExtensionAfterSave',
|
'onExtensionAfterSave' => 'onExtensionAfterSave',
|
||||||
|
'onAfterInitialise' => 'onAfterInitialise',
|
||||||
|
'onExtensionAfterInstall' => 'onExtensionAfterInstall',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send heartbeat on first admin page load after install/update.
|
||||||
|
*/
|
||||||
|
public function onAfterInitialise(): void
|
||||||
|
{
|
||||||
|
$app = $this->getApplication();
|
||||||
|
if (!$app->isClient('administrator')) return;
|
||||||
|
if (!$this->params->get('heartbeat_enabled', 1)) return;
|
||||||
|
|
||||||
|
$session = \Joomla\CMS\Factory::getSession();
|
||||||
|
if ($session->get('mokosuite.heartbeat_sent', false)) return;
|
||||||
|
|
||||||
|
// Check if version changed since last heartbeat
|
||||||
|
$lastVersion = $this->params->get('_last_heartbeat_version', '');
|
||||||
|
$currentVersion = $this->getMokoSuiteVersion();
|
||||||
|
|
||||||
|
if ($lastVersion !== $currentVersion)
|
||||||
|
{
|
||||||
|
$session->set('mokosuite.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' => 'mokosuite_monitor', 'folder' => 'system', 'type' => 'plugin']);
|
||||||
|
$extension->params = $this->params->toString();
|
||||||
|
$extension->store();
|
||||||
|
}
|
||||||
|
catch (\Throwable $e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send heartbeat immediately after package install/update.
|
||||||
|
*/
|
||||||
|
public function onExtensionAfterInstall($installer, $eid): void
|
||||||
|
{
|
||||||
|
if (!$this->params->get('heartbeat_enabled', 1)) return;
|
||||||
|
|
||||||
|
try { $this->sendHeartbeat(); }
|
||||||
|
catch (\Throwable $e) {}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* After saving this plugin or the core plugin, send heartbeat.
|
* After saving this plugin or the core plugin, send heartbeat.
|
||||||
*/
|
*/
|
||||||
@@ -146,46 +194,47 @@ class Monitor extends CMSPlugin implements SubscriberInterface
|
|||||||
$endpoint = $baseUrl . '/api/index.php/v1/mokosuitehq/heartbeat';
|
$endpoint = $baseUrl . '/api/index.php/v1/mokosuitehq/heartbeat';
|
||||||
$json = json_encode($payload, JSON_UNESCAPED_SLASHES);
|
$json = json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
$ch = curl_init($endpoint);
|
try
|
||||||
curl_setopt_array($ch, [
|
{
|
||||||
CURLOPT_POST => true,
|
$http = \Joomla\CMS\Http\HttpFactory::getHttp(
|
||||||
CURLOPT_HTTPHEADER => $headers,
|
new \Joomla\Registry\Registry(['follow_location' => true, 'transport.curl' => ['certpath' => false]]),
|
||||||
CURLOPT_POSTFIELDS => $json,
|
['curl', 'stream']
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
);
|
||||||
CURLOPT_TIMEOUT => 15,
|
|
||||||
CURLOPT_FOLLOWLOCATION => true,
|
|
||||||
CURLOPT_SSL_VERIFYPEER => false,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = curl_exec($ch);
|
$headerMap = [];
|
||||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
foreach ($headers as $h)
|
||||||
$error = curl_error($ch);
|
{
|
||||||
curl_close($ch);
|
[$key, $val] = explode(': ', $h, 2);
|
||||||
|
$headerMap[$key] = $val;
|
||||||
|
}
|
||||||
|
|
||||||
if ($error)
|
$response = $http->post($endpoint, $json, $headerMap, 15);
|
||||||
{
|
$code = $response->code;
|
||||||
Log::add('Monitor heartbeat failed: ' . $error, Log::WARNING, 'mokosuite');
|
$body = json_decode($response->body, true);
|
||||||
|
|
||||||
|
if ($code >= 200 && $code < 300)
|
||||||
|
{
|
||||||
|
$app->enqueueMessage(
|
||||||
|
'MokoSuiteHQ heartbeat: ' . ($body['status'] ?? 'ok'),
|
||||||
|
'message'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Log::add(
|
||||||
|
\sprintf('Monitor heartbeat HTTP %d: %s', $code, $body['error'] ?? 'Unknown'),
|
||||||
|
Log::WARNING,
|
||||||
|
'mokosuite'
|
||||||
|
);
|
||||||
|
$app->enqueueMessage(
|
||||||
|
'MokoSuiteHQ heartbeat failed (HTTP ' . $code . ')',
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
elseif ($code >= 200 && $code < 300)
|
catch (\Throwable $e)
|
||||||
{
|
{
|
||||||
$body = json_decode($response, true);
|
Log::add('Monitor heartbeat failed: ' . $e->getMessage(), Log::WARNING, 'mokosuite');
|
||||||
$app->enqueueMessage(
|
|
||||||
'MokoSuiteHQ 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,
|
|
||||||
'mokosuite'
|
|
||||||
);
|
|
||||||
$app->enqueueMessage(
|
|
||||||
'MokoSuiteHQ heartbeat failed (HTTP ' . $code . ')',
|
|
||||||
'warning'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,30 +306,30 @@ class Monitor extends CMSPlugin implements SubscriberInterface
|
|||||||
*/
|
*/
|
||||||
private function fetchLocalHealth(string $siteUrl, string $healthToken): ?array
|
private function fetchLocalHealth(string $siteUrl, string $healthToken): ?array
|
||||||
{
|
{
|
||||||
$url = $siteUrl . '/?mokosuite=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);
|
$response = $http->get(
|
||||||
curl_setopt_array($ch, [
|
$siteUrl . '/?mokosuite=health',
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
['Authorization' => 'Bearer ' . $healthToken, 'Accept' => 'application/json'],
|
||||||
CURLOPT_TIMEOUT => 10,
|
10
|
||||||
CURLOPT_FOLLOWLOCATION => true,
|
);
|
||||||
CURLOPT_SSL_VERIFYPEER => false,
|
|
||||||
CURLOPT_HTTPHEADER => [
|
|
||||||
'Authorization: Bearer ' . $healthToken,
|
|
||||||
'Accept: application/json',
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = curl_exec($ch);
|
if ($response->code !== 200 || empty($response->body))
|
||||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
{
|
||||||
curl_close($ch);
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if ($code !== 200 || empty($response))
|
return json_decode($response->body, true) ?: null;
|
||||||
|
}
|
||||||
|
catch (\Throwable $e)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return json_decode($response, true) ?: null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -290,17 +339,11 @@ class Monitor extends CMSPlugin implements SubscriberInterface
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
$db = Factory::getDbo();
|
$extension = new \Joomla\CMS\Table\Extension(Factory::getDbo());
|
||||||
$db->setQuery(
|
$extension->load(['element' => 'pkg_mokosuite', 'type' => 'package']);
|
||||||
$db->getQuery(true)
|
$manifest = json_decode($extension->manifest_cache ?? '{}');
|
||||||
->select($db->quoteName('manifest_cache'))
|
|
||||||
->from($db->quoteName('#__extensions'))
|
|
||||||
->where($db->quoteName('element') . ' = ' . $db->quote('pkg_mokosuite'))
|
|
||||||
->where($db->quoteName('type') . ' = ' . $db->quote('package'))
|
|
||||||
);
|
|
||||||
$mc = json_decode($db->loadResult() ?? '{}');
|
|
||||||
|
|
||||||
return $mc->version ?? '';
|
return $manifest->version ?? '';
|
||||||
}
|
}
|
||||||
catch (\Throwable $e)
|
catch (\Throwable $e)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.34.50-dev</version>
|
<version>02.34.79-dev</version>
|
||||||
<description>PLG_SYSTEM_MOKOSUITE_OFFLINE_DESC</description>
|
<description>PLG_SYSTEM_MOKOSUITE_OFFLINE_DESC</description>
|
||||||
<namespace path="src">Moko\Plugin\System\MokoSuiteOffline</namespace>
|
<namespace path="src">Moko\Plugin\System\MokoSuiteOffline</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.34.50-dev</version>
|
<version>02.34.79-dev</version>
|
||||||
<description>PLG_SYSTEM_MOKOSUITE_TENANT_DESC</description>
|
<description>PLG_SYSTEM_MOKOSUITE_TENANT_DESC</description>
|
||||||
<namespace path="src">Moko\Plugin\System\MokoSuiteTenant</namespace>
|
<namespace path="src">Moko\Plugin\System\MokoSuiteTenant</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.34.50-dev</version>
|
<version>02.34.79-dev</version>
|
||||||
<description>Runs scheduled helpdesk automation rules — auto-close resolved tickets, SLA breach escalation, and time-based actions.</description>
|
<description>Runs scheduled helpdesk automation rules — auto-close resolved tickets, SLA breach escalation, and time-based actions.</description>
|
||||||
<namespace path="src">Moko\Plugin\Task\MokoSuiteTickets</namespace>
|
<namespace path="src">Moko\Plugin\Task\MokoSuiteTickets</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,16 @@ namespace Moko\Plugin\Task\MokoSuiteTickets\Extension;
|
|||||||
|
|
||||||
defined('_JEXEC') or die;
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\Log\Log;
|
||||||
use Joomla\CMS\Plugin\CMSPlugin;
|
use Joomla\CMS\Plugin\CMSPlugin;
|
||||||
use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent;
|
use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent;
|
||||||
use Joomla\Component\Scheduler\Administrator\Task\Status;
|
use Joomla\Component\Scheduler\Administrator\Task\Status;
|
||||||
use Joomla\Component\Scheduler\Administrator\Traits\TaskPluginTrait;
|
use Joomla\Component\Scheduler\Administrator\Traits\TaskPluginTrait;
|
||||||
use Joomla\Event\SubscriberInterface;
|
use Joomla\Event\SubscriberInterface;
|
||||||
use Moko\Component\MokoSuite\Administrator\Model\TicketsModel;
|
use Moko\Component\MokoSuite\Administrator\Model\TicketsModel;
|
||||||
|
use Moko\Component\MokoSuite\Administrator\Service\AttachmentService;
|
||||||
|
use Moko\Component\MokoSuite\Administrator\Service\NotificationService;
|
||||||
|
|
||||||
class TicketAutomation extends CMSPlugin implements SubscriberInterface
|
class TicketAutomation extends CMSPlugin implements SubscriberInterface
|
||||||
{
|
{
|
||||||
@@ -26,6 +30,14 @@ class TicketAutomation extends CMSPlugin implements SubscriberInterface
|
|||||||
'langConstPrefix' => 'PLG_TASK_MOKOSUITE_TICKETS_AUTOMATION',
|
'langConstPrefix' => 'PLG_TASK_MOKOSUITE_TICKETS_AUTOMATION',
|
||||||
'method' => 'runAutomation',
|
'method' => 'runAutomation',
|
||||||
],
|
],
|
||||||
|
'mokosuite.ticket.imap_poll' => [
|
||||||
|
'langConstPrefix' => 'PLG_TASK_MOKOSUITE_TICKETS_IMAP_POLL',
|
||||||
|
'method' => 'runImapPoll',
|
||||||
|
],
|
||||||
|
'mokosuite.ticket.autoclose' => [
|
||||||
|
'langConstPrefix' => 'PLG_TASK_MOKOSUITE_TICKETS_AUTOCLOSE',
|
||||||
|
'method' => 'runAutoClose',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $autoloadLanguage = true;
|
protected $autoloadLanguage = true;
|
||||||
@@ -62,4 +74,239 @@ class TicketAutomation extends CMSPlugin implements SubscriberInterface
|
|||||||
return Status::KNOCKOUT;
|
return Status::KNOCKOUT;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll IMAP inbox and create tickets from unread emails (#136).
|
||||||
|
*/
|
||||||
|
private function runImapPoll(ExecuteTaskEvent $event): int
|
||||||
|
{
|
||||||
|
$config = $this->getComponentConfig();
|
||||||
|
$host = $config['imap_host'] ?? '';
|
||||||
|
$port = (int) ($config['imap_port'] ?? 993);
|
||||||
|
$user = $config['imap_user'] ?? '';
|
||||||
|
$pass = $config['imap_password'] ?? '';
|
||||||
|
$ssl = ($config['imap_ssl'] ?? '1') === '1';
|
||||||
|
$folder = $config['imap_folder'] ?? 'INBOX';
|
||||||
|
$processed = $config['imap_processed_folder'] ?? 'INBOX.Processed';
|
||||||
|
$defaultCat = (int) ($config['default_category'] ?? 0) ?: null;
|
||||||
|
|
||||||
|
if (empty($host) || empty($user) || empty($pass))
|
||||||
|
{
|
||||||
|
$this->logTask('IMAP not configured — skipping', 'warning');
|
||||||
|
return Status::OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('imap_open'))
|
||||||
|
{
|
||||||
|
$this->logTask('php-imap extension not available', 'error');
|
||||||
|
return Status::KNOCKOUT;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mailbox = '{' . $host . ':' . $port . '/imap' . ($ssl ? '/ssl' : '') . '/novalidate-cert}' . $folder;
|
||||||
|
$mbox = @imap_open($mailbox, $user, $pass);
|
||||||
|
|
||||||
|
if (!$mbox)
|
||||||
|
{
|
||||||
|
$this->logTask('IMAP connection failed: ' . imap_last_error(), 'error');
|
||||||
|
return Status::KNOCKOUT;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$created = 0;
|
||||||
|
$replied = 0;
|
||||||
|
|
||||||
|
$emails = imap_search($mbox, 'UNSEEN');
|
||||||
|
|
||||||
|
if ($emails === false)
|
||||||
|
{
|
||||||
|
imap_close($mbox);
|
||||||
|
$this->logTask('No new emails');
|
||||||
|
return Status::OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($emails as $msgNum)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
$header = imap_headerinfo($mbox, $msgNum);
|
||||||
|
$subject = isset($header->subject) ? imap_utf8($header->subject) : '(no subject)';
|
||||||
|
$fromAddr = $header->from[0]->mailbox . '@' . $header->from[0]->host;
|
||||||
|
$body = $this->getImapBody($mbox, $msgNum);
|
||||||
|
|
||||||
|
// Match sender to Joomla user
|
||||||
|
$userId = $this->findUserByEmail($fromAddr);
|
||||||
|
|
||||||
|
// Check if this is a reply (subject contains [#123])
|
||||||
|
$ticketId = 0;
|
||||||
|
if (preg_match('/\[#(\d+)\]/', $subject, $m))
|
||||||
|
{
|
||||||
|
$ticketId = (int) $m[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ticketId > 0)
|
||||||
|
{
|
||||||
|
// Add as reply to existing ticket
|
||||||
|
$reply = (object) [
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'body' => $body,
|
||||||
|
'is_internal' => 0,
|
||||||
|
'created' => Factory::getDate()->toSql(),
|
||||||
|
];
|
||||||
|
$db->insertObject('#__mokosuite_ticket_replies', $reply, 'id');
|
||||||
|
$replied++;
|
||||||
|
|
||||||
|
// Notify
|
||||||
|
$db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuite_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('#__mokosuite_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, 'mokosuite');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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('#__mokosuite_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_mokosuite'))
|
||||||
|
->where('type = ' . $db->quote('component'))
|
||||||
|
);
|
||||||
|
return json_decode($db->loadResult() ?? '{}', true) ?: [];
|
||||||
|
}
|
||||||
|
catch (\Throwable $e)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findUserByEmail(string $email): int
|
||||||
|
{
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$db->setQuery(
|
||||||
|
$db->getQuery(true)
|
||||||
|
->select('id')
|
||||||
|
->from('#__users')
|
||||||
|
->where('email = ' . $db->quote($email))
|
||||||
|
->setLimit(1)
|
||||||
|
);
|
||||||
|
return (int) $db->loadResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getImapBody($mbox, int $msgNum): string
|
||||||
|
{
|
||||||
|
$structure = imap_fetchstructure($mbox, $msgNum);
|
||||||
|
|
||||||
|
// Simple single-part message
|
||||||
|
if (empty($structure->parts))
|
||||||
|
{
|
||||||
|
$body = imap_fetchbody($mbox, $msgNum, '1');
|
||||||
|
if ($structure->encoding === 3) $body = base64_decode($body);
|
||||||
|
if ($structure->encoding === 4) $body = quoted_printable_decode($body);
|
||||||
|
return trim(strip_tags($body));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multipart — find text/plain or text/html
|
||||||
|
$textBody = '';
|
||||||
|
|
||||||
|
foreach ($structure->parts as $i => $part)
|
||||||
|
{
|
||||||
|
$partNum = (string) ($i + 1);
|
||||||
|
|
||||||
|
if ($part->type === 0) // text
|
||||||
|
{
|
||||||
|
$content = imap_fetchbody($mbox, $msgNum, $partNum);
|
||||||
|
if ($part->encoding === 3) $content = base64_decode($content);
|
||||||
|
if ($part->encoding === 4) $content = quoted_printable_decode($content);
|
||||||
|
|
||||||
|
$subtype = strtolower($part->subtype ?? '');
|
||||||
|
|
||||||
|
if ($subtype === 'plain' && empty($textBody))
|
||||||
|
{
|
||||||
|
$textBody = $content;
|
||||||
|
}
|
||||||
|
elseif ($subtype === 'html' && empty($textBody))
|
||||||
|
{
|
||||||
|
$textBody = strip_tags($content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim($textBody);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.34.50-dev</version>
|
<version>02.34.79-dev</version>
|
||||||
<description>PLG_TASK_MOKOSUITEDEMO_DESC</description>
|
<description>PLG_TASK_MOKOSUITEDEMO_DESC</description>
|
||||||
<namespace path="src">Moko\Plugin\Task\MokoSuiteDemo</namespace>
|
<namespace path="src">Moko\Plugin\Task\MokoSuiteDemo</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* INGROUP: MokoSuite
|
* INGROUP: MokoSuite
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuite
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuite
|
||||||
* PATH: /src/packages/plg_system_mokosuite/Service/DemoResetService.php
|
* PATH: /src/packages/plg_system_mokosuite/Service/DemoResetService.php
|
||||||
* VERSION: 02.34.50
|
* VERSION: 02.34.79
|
||||||
* BRIEF: Content-only snapshot/restore for demo site reset
|
* BRIEF: Content-only snapshot/restore for demo site reset
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.34.50-dev</version>
|
<version>02.34.79-dev</version>
|
||||||
<description>PLG_TASK_MOKOSUITESYNC_DESC</description>
|
<description>PLG_TASK_MOKOSUITESYNC_DESC</description>
|
||||||
<namespace path="src">Moko\Plugin\Task\MokoSuiteSync</namespace>
|
<namespace path="src">Moko\Plugin\Task\MokoSuiteSync</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* INGROUP: MokoSuite
|
* INGROUP: MokoSuite
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuite
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuite
|
||||||
* PATH: /src/packages/plg_system_mokosuite/Service/ContentSyncReceiver.php
|
* PATH: /src/packages/plg_system_mokosuite/Service/ContentSyncReceiver.php
|
||||||
* VERSION: 02.34.50
|
* VERSION: 02.34.79
|
||||||
* BRIEF: Receiver-side content sync — applies incoming payload to local DB
|
* BRIEF: Receiver-side content sync — applies incoming payload to local DB
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* INGROUP: MokoSuite
|
* INGROUP: MokoSuite
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuite
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuite
|
||||||
* PATH: /src/packages/plg_system_mokosuite/Service/ContentSyncService.php
|
* PATH: /src/packages/plg_system_mokosuite/Service/ContentSyncService.php
|
||||||
* VERSION: 02.34.50
|
* VERSION: 02.34.79
|
||||||
* BRIEF: Sender-side content sync — builds payload and pushes to remote sites
|
* BRIEF: Sender-side content sync — builds payload and pushes to remote sites
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<license>GPL-3.0-or-later</license>
|
<license>GPL-3.0-or-later</license>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<version>02.34.50-dev</version>
|
<version>02.34.79-dev</version>
|
||||||
<description>Joomla Web Services API routes for MokoSuite site management — health checks, cache, updates, backups, and site info.</description>
|
<description>Joomla Web Services API routes for MokoSuite site management — health checks, cache, updates, backups, and site info.</description>
|
||||||
<namespace path="src">Moko\Plugin\WebServices\MokoSuite</namespace>
|
<namespace path="src">Moko\Plugin\WebServices\MokoSuite</namespace>
|
||||||
<files>
|
<files>
|
||||||
|
|||||||
@@ -124,5 +124,23 @@ final class MokoSuiteApi extends CMSPlugin implements SubscriberInterface
|
|||||||
'provision',
|
'provision',
|
||||||
['component' => 'com_mokosuite']
|
['component' => 'com_mokosuite']
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Helpdesk Tickets API (#142)
|
||||||
|
$router->createCRUDRoutes(
|
||||||
|
'v1/mokosuite/tickets',
|
||||||
|
'tickets',
|
||||||
|
['component' => 'com_mokosuite']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ticket reply (custom route — POST only)
|
||||||
|
$router->addRoute(
|
||||||
|
new \Joomla\Router\Route(
|
||||||
|
['POST'],
|
||||||
|
'v1/mokosuite/tickets/:id/reply',
|
||||||
|
'tickets.reply',
|
||||||
|
['id' => '(\d+)'],
|
||||||
|
['component' => 'com_mokosuite']
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<extension type="package" method="upgrade">
|
<extension type="package" method="upgrade">
|
||||||
<name>Package - MokoSuite</name>
|
<name>Package - MokoSuite</name>
|
||||||
<packagename>mokosuite</packagename>
|
<packagename>mokosuite</packagename>
|
||||||
<version>02.34.50-dev</version>
|
<version>02.34.79-dev</version>
|
||||||
<creationDate>2026-06-02</creationDate>
|
<creationDate>2026-06-02</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
<file type="plugin" id="plg_system_mokosuite_tenant" group="system">plg_system_mokosuite_tenant.zip</file>
|
<file type="plugin" id="plg_system_mokosuite_tenant" group="system">plg_system_mokosuite_tenant.zip</file>
|
||||||
<file type="plugin" id="plg_system_mokosuite_devtools" group="system">plg_system_mokosuite_devtools.zip</file>
|
<file type="plugin" id="plg_system_mokosuite_devtools" group="system">plg_system_mokosuite_devtools.zip</file>
|
||||||
<file type="plugin" id="plg_system_mokosuite_offline" group="system">plg_system_mokosuite_offline.zip</file>
|
<file type="plugin" id="plg_system_mokosuite_offline" group="system">plg_system_mokosuite_offline.zip</file>
|
||||||
|
<file type="plugin" id="plg_system_mokosuite_dbip" group="system">plg_system_mokosuite_dbip.zip</file>
|
||||||
<file type="component" id="com_mokosuite">com_mokosuite.zip</file>
|
<file type="component" id="com_mokosuite">com_mokosuite.zip</file>
|
||||||
<file type="module" id="mod_mokosuite_cpanel" client="administrator">mod_mokosuite_cpanel.zip</file>
|
<file type="module" id="mod_mokosuite_cpanel" client="administrator">mod_mokosuite_cpanel.zip</file>
|
||||||
<file type="module" id="mod_mokosuite_menu" client="administrator">mod_mokosuite_menu.zip</file>
|
<file type="module" id="mod_mokosuite_menu" client="administrator">mod_mokosuite_menu.zip</file>
|
||||||
|
|||||||
+49
-155
@@ -77,6 +77,7 @@ class Pkg_MokosuiteInstallerScript
|
|||||||
$this->enablePlugin('system', 'mokosuite_tenant');
|
$this->enablePlugin('system', 'mokosuite_tenant');
|
||||||
$this->enablePlugin('system', 'mokosuite_devtools');
|
$this->enablePlugin('system', 'mokosuite_devtools');
|
||||||
$this->enablePlugin('system', 'mokosuite_offline');
|
$this->enablePlugin('system', 'mokosuite_offline');
|
||||||
|
$this->enablePlugin('system', 'mokosuite_dbip');
|
||||||
$this->enablePlugin('webservices', 'mokosuite');
|
$this->enablePlugin('webservices', 'mokosuite');
|
||||||
$this->enablePlugin('task', 'mokosuitedemo');
|
$this->enablePlugin('task', 'mokosuitedemo');
|
||||||
$this->enablePlugin('task', 'mokosuitesync');
|
$this->enablePlugin('task', 'mokosuitesync');
|
||||||
@@ -943,194 +944,87 @@ class Pkg_MokosuiteInstallerScript
|
|||||||
*/
|
*/
|
||||||
private function setupCpanelModule(): void
|
private function setupCpanelModule(): void
|
||||||
{
|
{
|
||||||
try
|
$this->ensureAdminModule('mod_mokosuite_cpanel', 'MokoSuite', 'top', 6, 0, '{"show_health":"1","show_plugins":"1"}');
|
||||||
{
|
|
||||||
$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_mokosuite_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_mokosuite_cpanel'));
|
|
||||||
$db->setQuery($query);
|
|
||||||
|
|
||||||
if ((int) $db->loadResult() > 0)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the module instance on the cpanel position
|
|
||||||
$module = (object) [
|
|
||||||
'title' => 'MokoSuite',
|
|
||||||
'note' => '',
|
|
||||||
'content' => '',
|
|
||||||
'ordering' => 0,
|
|
||||||
'position' => 'top',
|
|
||||||
'checked_out' => null,
|
|
||||||
'checked_out_time' => null,
|
|
||||||
'publish_up' => null,
|
|
||||||
'publish_down' => null,
|
|
||||||
'published' => 1,
|
|
||||||
'module' => 'mod_mokosuite_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, 'mokosuite');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Set up the MokoSuite admin sidebar menu module at position 0.
|
|
||||||
*/
|
|
||||||
private function setupAdminMenuModule(): void
|
private function setupAdminMenuModule(): void
|
||||||
{
|
{
|
||||||
try
|
$this->ensureAdminModule('mod_mokosuite_menu', 'MokoSuite Menu', 'menu', 3, 0);
|
||||||
{
|
}
|
||||||
$db = Factory::getDbo();
|
|
||||||
|
|
||||||
// Enable the module extension
|
private function setupCacheModule(): void
|
||||||
$db->setQuery(
|
{
|
||||||
$db->getQuery(true)
|
$this->ensureAdminModule('mod_mokosuite_cache', 'MokoSuite Cache Cleaner', 'status', 3, 8);
|
||||||
->update($db->quoteName('#__extensions'))
|
|
||||||
->set($db->quoteName('enabled') . ' = 1')
|
|
||||||
->where($db->quoteName('type') . ' = ' . $db->quote('module'))
|
|
||||||
->where($db->quoteName('element') . ' = ' . $db->quote('mod_mokosuite_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_mokosuite_menu'))
|
|
||||||
);
|
|
||||||
|
|
||||||
if ((int) $db->loadResult() > 0)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$module = (object) [
|
|
||||||
'title' => 'MokoSuite Menu',
|
|
||||||
'note' => '',
|
|
||||||
'content' => '',
|
|
||||||
'ordering' => 0,
|
|
||||||
'position' => 'menu',
|
|
||||||
'checked_out' => null,
|
|
||||||
'checked_out_time' => null,
|
|
||||||
'publish_up' => null,
|
|
||||||
'publish_down' => null,
|
|
||||||
'published' => 1,
|
|
||||||
'module' => 'mod_mokosuite_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, 'mokosuite');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
try
|
||||||
{
|
{
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getDbo();
|
||||||
|
|
||||||
// Enable the module extension
|
// Enable the extension entry
|
||||||
$db->setQuery(
|
$db->setQuery(
|
||||||
$db->getQuery(true)
|
$db->getQuery(true)
|
||||||
->update($db->quoteName('#__extensions'))
|
->update('#__extensions')
|
||||||
->set($db->quoteName('enabled') . ' = 1')
|
->set('enabled = 1')
|
||||||
->where($db->quoteName('type') . ' = ' . $db->quote('module'))
|
->where('type = ' . $db->quote('module'))
|
||||||
->where($db->quoteName('element') . ' = ' . $db->quote('mod_mokosuite_cache'))
|
->where('element = ' . $db->quote($element))
|
||||||
)->execute();
|
)->execute();
|
||||||
|
|
||||||
// Check if module instance exists
|
// Find existing module instance
|
||||||
$db->setQuery(
|
$db->setQuery(
|
||||||
$db->getQuery(true)
|
$db->getQuery(true)
|
||||||
->select('COUNT(*)')
|
->select('id')
|
||||||
->from($db->quoteName('#__modules'))
|
->from('#__modules')
|
||||||
->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokosuite_cache'))
|
->where('module = ' . $db->quote($element))
|
||||||
|
->setLimit(1)
|
||||||
);
|
);
|
||||||
|
$moduleId = (int) $db->loadResult();
|
||||||
|
|
||||||
if ((int) $db->loadResult() > 0)
|
// Build save data — Joomla's ModuleModel expects this format
|
||||||
{
|
$data = [
|
||||||
return;
|
'title' => $title,
|
||||||
}
|
'module' => $element,
|
||||||
|
'position' => $position,
|
||||||
$module = (object) [
|
|
||||||
'title' => 'MokoSuite Cache Cleaner',
|
|
||||||
'note' => '',
|
|
||||||
'content' => '',
|
|
||||||
'ordering' => 8,
|
|
||||||
'position' => 'status',
|
|
||||||
'checked_out' => null,
|
|
||||||
'checked_out_time' => null,
|
|
||||||
'publish_up' => null,
|
|
||||||
'publish_down' => null,
|
|
||||||
'published' => 1,
|
'published' => 1,
|
||||||
'module' => 'mod_mokosuite_cache',
|
'access' => $access,
|
||||||
'access' => 3,
|
'ordering' => $ordering,
|
||||||
'showtitle' => 0,
|
'showtitle' => 0,
|
||||||
'params' => '{}',
|
|
||||||
'client_id' => 1,
|
'client_id' => 1,
|
||||||
'language' => '*',
|
'language' => '*',
|
||||||
|
'params' => $params,
|
||||||
|
'assignment' => 0, // 0 = all pages
|
||||||
];
|
];
|
||||||
|
|
||||||
$db->insertObject('#__modules', $module, 'id');
|
if ($moduleId > 0)
|
||||||
|
|
||||||
if ((int) $module->id)
|
|
||||||
{
|
{
|
||||||
$mm = (object) ['moduleid' => (int) $module->id, 'menuid' => 0];
|
$data['id'] = $moduleId;
|
||||||
$db->insertObject('#__modules_menu', $mm, 'moduleid');
|
}
|
||||||
|
|
||||||
|
// Use Joomla's ModuleModel to handle save + menu assignment
|
||||||
|
\Joomla\CMS\MVC\Factory\MVCFactory::class;
|
||||||
|
$app = Factory::getApplication();
|
||||||
|
|
||||||
|
/** @var \Joomla\Component\Modules\Administrator\Model\ModuleModel $model */
|
||||||
|
$model = $app->bootComponent('com_modules')
|
||||||
|
->getMVCFactory()
|
||||||
|
->createModel('Module', 'Administrator', ['ignore_request' => true]);
|
||||||
|
|
||||||
|
if (!$model->save($data))
|
||||||
|
{
|
||||||
|
Log::add("Module setup ({$element}): " . $model->getError(), Log::WARNING, 'mokosuite');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (\Throwable $e)
|
catch (\Throwable $e)
|
||||||
{
|
{
|
||||||
Log::add('Cache module setup error: ' . $e->getMessage(), Log::WARNING, 'mokosuite');
|
Log::add("Module setup ({$element}): " . $e->getMessage(), Log::WARNING, 'mokosuite');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user