Merge pull request 'fix(install): enable ticket/offline plugins, consolidate monitor into core' (#218) from rc into main
Generic: Project CI / Tests (push) Blocked by required conditions
Generic: Project CI / Tests (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Failing after 17s
Generic: Project CI / Lint & Validate (push) Successful in 13s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 6s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Project CI / Lint & Validate (pull_request) Successful in 14s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 12s
Universal: PR Check / Validate PR (pull_request) Failing after 8s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 39s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 48s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 52s
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Generic: Project CI / Tests (push) Blocked by required conditions
Generic: Project CI / Tests (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Failing after 17s
Generic: Project CI / Lint & Validate (push) Successful in 13s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 6s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Project CI / Lint & Validate (pull_request) Successful in 14s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 12s
Universal: PR Check / Validate PR (pull_request) Failing after 8s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 39s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 48s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 52s
Universal: Auto Version Bump / Version Bump (push) Has been skipped
This commit was merged in pull request #218.
This commit is contained in:
@@ -1,66 +1,66 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# PATH: /.mokogitea/workflows/auto-bump.yml
|
||||
# VERSION: 09.02.00
|
||||
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
||||
|
||||
name: "Universal: Auto Version Bump"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- rc
|
||||
- 'feature/**'
|
||||
- 'patch/**'
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
name: Version Bump
|
||||
runs-on: release
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip bump]') &&
|
||||
!startsWith(github.event.head_commit.message, 'Merge pull request')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup mokocli tools
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
if [ -d "/opt/mokocli/cli" ]; then
|
||||
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
|
||||
else
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
|
||||
/tmp/mokocli
|
||||
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Bump version
|
||||
run: |
|
||||
php ${MOKO_CLI}/version_auto_bump.php \
|
||||
--path . --branch "${GITHUB_REF_NAME}" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokoplatform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
||||
# PATH: /.mokogitea/workflows/auto-bump.yml
|
||||
# VERSION: 09.02.00
|
||||
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
|
||||
|
||||
name: "Universal: Auto Version Bump"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- rc
|
||||
- 'feature/**'
|
||||
- 'patch/**'
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
name: Version Bump
|
||||
runs-on: release
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip bump]') &&
|
||||
!startsWith(github.event.head_commit.message, 'Merge pull request')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup mokoplatform tools
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
if [ -d "/opt/mokoplatform/cli" ]; then
|
||||
echo "MOKO_CLI=/opt/mokoplatform/cli" >> "$GITHUB_ENV"
|
||||
else
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokoplatform.git" \
|
||||
/tmp/mokoplatform-api
|
||||
cd /tmp/mokoplatform-api && composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/mokoplatform-api/cli" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Bump version
|
||||
run: |
|
||||
php ${MOKO_CLI}/version_auto_bump.php \
|
||||
--path . --branch "${GITHUB_REF_NAME}" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
@@ -112,19 +112,16 @@ jobs:
|
||||
- name: Update RC release notes from CHANGELOG.md
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# Extract [Unreleased] section from changelog
|
||||
NOTES=""
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||
[ -z "$NOTES" ] && NOTES="Release candidate"
|
||||
else
|
||||
NOTES="Release candidate"
|
||||
fi
|
||||
[ -z "$NOTES" ] && NOTES="Release candidate"
|
||||
|
||||
# Find the RC release and update its body
|
||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/releases/tags/release-candidate" \
|
||||
| python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/release-candidate" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
python3 -c "
|
||||
@@ -135,7 +132,7 @@ jobs:
|
||||
'${API_BASE}/releases/${RELEASE_ID}',
|
||||
data=payload, method='PATCH',
|
||||
headers={
|
||||
'Authorization': 'token ${TOKEN}',
|
||||
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
urllib.request.urlopen(req)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Automation
|
||||
# VERSION: 02.35.00
|
||||
# VERSION: 02.41.00
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
@@ -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
|
||||
+22
-36
@@ -14,15 +14,35 @@
|
||||
INGROUP: MokoSuiteClient.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
PATH: ./CHANGELOG.md
|
||||
VERSION: 02.35.00
|
||||
VERSION: 02.41.00
|
||||
BRIEF: Version history using `Keep a Changelog`
|
||||
-->
|
||||
|
||||
# Changelog
|
||||
## [Unreleased]
|
||||
|
||||
## [02.41.00] --- 2026-06-20
|
||||
|
||||
## [02.35.00] --- 2026-06-19
|
||||
### Fixed
|
||||
- Ticket automation and offline bypass plugins not enabling on install/update — `enablePlugin()` now handles empty element columns with a fallback match by manifest name
|
||||
- Heartbeat silently failing — `sendHeartbeat()` was reading config from the retired monitor plugin; now reads from core plugin params
|
||||
- Cpanel module not publishing on update — `ensureAdminModule()` now does a direct DB update for existing modules (bypasses ModuleModel checked_out issues)
|
||||
- Cpanel module access level changed from 6 (may not exist) to 3 (Special)
|
||||
- Admin menu module ordering set to -1 to ensure it appears at the top of the menu position
|
||||
|
||||
### Changed
|
||||
- Retired monitor plugin config (base_url, signing_key, heartbeat_enabled) consolidated into core plugin params with one-time migration
|
||||
- Runtime heartbeat moved from retired `plg_system_mokosuiteclient_monitor` into core plugin (`checkHeartbeat` on admin page load after version change)
|
||||
- `DisplayController::sendHeartbeat()` reads from core plugin instead of retired monitor plugin
|
||||
- Removed monitor plugin from cpanel dashboard plugin grid
|
||||
|
||||
### Added
|
||||
- Missing language strings for IMAP poll and auto-close ticket automation task routines
|
||||
- Monitor fieldset in core plugin XML with heartbeat_enabled, monitor_base_url, and monitor_signing_key fields
|
||||
- Language strings for monitor fieldset (PLG_SYSTEM_MOKOSUITECLIENT_FIELDSET_MONITOR_*)
|
||||
|
||||
|
||||
## [02.35.00] --- 2026-06-18
|
||||
|
||||
### Changed
|
||||
- **Full rename: MokoSuite → MokoSuiteClient** — repo, all Joomla element names (com_mokosuiteclient, plg_system_mokosuiteclient, mod_mokosuiteclient_*, etc.), PHP classes, language files, folder structure, and manifest references. This is the client tracker for the MokoSuite platform.
|
||||
@@ -144,37 +164,3 @@
|
||||
### Removed
|
||||
- License key validation (licensing system not ready — will return in future release)
|
||||
- Dynamic MokoGitea update feed dependency (replaced with static updates.xml)
|
||||
|
||||
## [02.31] - 2026-06-01
|
||||
|
||||
### Added
|
||||
- License key support via Joomla's native Update Sites download key system (dlid)
|
||||
- Update server URL migrated from static XML to MokoGitea's dynamic update feed endpoint
|
||||
- Legacy static update site URLs auto-migrated to dynamic endpoint on install/update
|
||||
- Persistent admin warning when no license key is configured in Update Sites
|
||||
- Daily heartbeat validation of license key against MokoGitea — warns if key is invalid or expired
|
||||
- Stale/duplicate update site cleanup on install/update (removes old static URL entries and orphaned records)
|
||||
- Content sync rewritten — bulk MokoSuiteClient API endpoints (syncclear + syncpush) replace per-item Joomla API calls
|
||||
- Sync task per-instance config: target URL, health token, content type checkboxes (articles, categories, menus, modules)
|
||||
- Bulk sync completes in under 5 seconds (clear + push in 2-3 HTTP requests)
|
||||
- Asset table and nested set tree repair after sync push on target site
|
||||
- Enhanced dev mode: disables caching, enables Joomla + MokoOnyx debug, suppresses hit recording, shows offline on primary domain
|
||||
- Dev mode off: clears content versions, resets hits, disables debug, takes site online
|
||||
- Hardcoded dev alias (dev.{primary_domain}) with noindex/nofollow — bypasses offline mode for development
|
||||
- Primary domain auto-detected on first config save
|
||||
|
||||
### Changed
|
||||
- Branding, master user, support URL, and admin colors are now hardcoded (no longer configurable)
|
||||
- Master user enforcement is always active (toggle removed)
|
||||
- Diagnostics + maintenance merged into default config tab
|
||||
- Emergency access moved to Security tab
|
||||
- Content sync configuration moved from system plugin to individual scheduled task instances
|
||||
|
||||
### Removed
|
||||
- Static `updates.xml` — update feed is now generated dynamically by MokoGitea from git releases
|
||||
- Basic branding config tab (brand name, company name, support URL)
|
||||
- Visual branding config tab (colors, icon, custom CSS)
|
||||
- Suite Access config tab (master user toggle, master email)
|
||||
- Content Sync config tab (targets now in scheduled tasks)
|
||||
- Site Aliases config tab (hardcoded to dev.{primary_domain})
|
||||
- File sync (images/, files/, media/) — sync is API/DB content only
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoSuiteClient.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
VERSION: 02.35.00
|
||||
VERSION: 02.41.00
|
||||
PATH: ./CODE_OF_CONDUCT.md
|
||||
BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default
|
||||
-->
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@
|
||||
DEFGROUP: mokoconsulting-tech.MokoSuiteClientBrand
|
||||
INGROUP: MokoStandards.Governance
|
||||
REPO: https://github.com/mokoconsulting-tech/MokoSuiteClientBrand
|
||||
VERSION: 02.35.00
|
||||
VERSION: 02.41.00
|
||||
PATH: /GOVERNANCE.md
|
||||
BRIEF: Project governance rules, roles, and decision process for MokoSuiteClientBrand
|
||||
-->
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
INGROUP: MokoSuiteClient.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
PATH: ./LICENSE.md
|
||||
VERSION: 02.35.00
|
||||
VERSION: 02.41.00
|
||||
BRIEF: Project license (GPL-3.0-or-later)
|
||||
-->
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
# ==============================================================================
|
||||
|
||||
# Extension Configuration
|
||||
EXTENSION_NAME := mokoexample
|
||||
EXTENSION_TYPE := module
|
||||
EXTENSION_NAME := mokosuiteclient
|
||||
EXTENSION_TYPE := package
|
||||
# Options: module, plugin, component, package, template
|
||||
EXTENSION_VERSION := 1.0.0
|
||||
EXTENSION_VERSION := 02.35.00
|
||||
|
||||
# Module Configuration (for modules only)
|
||||
MODULE_TYPE := site
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoSuiteClient
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient
|
||||
VERSION: 02.35.00
|
||||
VERSION: 02.41.00
|
||||
PATH: /README.md
|
||||
BRIEF: MokoSuiteClient platform plugin for Joomla
|
||||
-->
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ DEFGROUP: [PROJECT_NAME]
|
||||
INGROUP: [PROJECT_NAME].Documentation
|
||||
REPO: [REPOSITORY_URL]
|
||||
PATH: /SECURITY.md
|
||||
VERSION: 02.35.00
|
||||
VERSION: 02.41.00
|
||||
BRIEF: Security vulnerability reporting and handling policy
|
||||
-->
|
||||
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
INGROUP: MokoSuiteClient.Build
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
FILE: build-guide.md
|
||||
VERSION: 02.35.00
|
||||
VERSION: 02.41.00
|
||||
PATH: /docs/guides/
|
||||
BRIEF: Build and packaging guide for the MokoSuiteClient system plugin
|
||||
NOTE: Defines environment setup, repository layout, packaging rules, and release preparation
|
||||
-->
|
||||
|
||||
# MokoSuiteClient Build Guide (VERSION: 02.35.00)
|
||||
# MokoSuiteClient Build Guide (VERSION: 02.41.00)
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoSuiteClient.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
VERSION: 02.35.00
|
||||
VERSION: 02.41.00
|
||||
PATH: /docs/guides/configuration-guide.md
|
||||
BRIEF: Configuration guide for the MokoSuiteClient system plugin
|
||||
NOTE: Defines plugin parameters, expected behaviors, and recommended defaults
|
||||
-->
|
||||
|
||||
# MokoSuiteClient Configuration Guide (VERSION: 02.35.00)
|
||||
# MokoSuiteClient Configuration Guide (VERSION: 02.41.00)
|
||||
|
||||
## 1. Objective
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoSuiteClient.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
VERSION: 02.35.00
|
||||
VERSION: 02.41.00
|
||||
PATH: /docs/guides/installation-guide.md
|
||||
BRIEF: Installation guide for the MokoSuiteClient system plugin
|
||||
NOTE: First document in the guide set
|
||||
-->
|
||||
|
||||
# MokoSuiteClient Installation Guide (VERSION: 02.35.00)
|
||||
# MokoSuiteClient Installation Guide (VERSION: 02.41.00)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoSuiteClient.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
VERSION: 02.35.00
|
||||
VERSION: 02.41.00
|
||||
PATH: /docs/guides/operations-guide.md
|
||||
BRIEF: Operational guide for administering and managing the MokoSuiteClient system plugin
|
||||
NOTE: Defines lifecycle, responsibilities, and operational behaviors
|
||||
-->
|
||||
|
||||
# MokoSuiteClient Operations Guide (VERSION: 02.35.00)
|
||||
# MokoSuiteClient Operations Guide (VERSION: 02.41.00)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoSuiteClient.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
VERSION: 02.35.00
|
||||
VERSION: 02.41.00
|
||||
PATH: /docs/guides/rollback-and-recovery-guide.md
|
||||
BRIEF: Rollback and recovery guide for restoring stable operation after plugin related incidents
|
||||
NOTE: Completes the core guide set for Suite plugin governance
|
||||
-->
|
||||
|
||||
# MokoSuiteClient Rollback and Recovery Guide (VERSION: 02.35.00)
|
||||
# MokoSuiteClient Rollback and Recovery Guide (VERSION: 02.41.00)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoSuiteClient.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
VERSION: 02.35.00
|
||||
VERSION: 02.41.00
|
||||
PATH: /docs/guides/testing-guide.md
|
||||
BRIEF: Testing guide for MokoSuiteClient v02.01.08
|
||||
NOTE: Covers manual test procedures for language overrides, install/uninstall, and configuration
|
||||
-->
|
||||
|
||||
# MokoSuiteClient Testing Guide (VERSION: 02.35.00)
|
||||
# MokoSuiteClient Testing Guide (VERSION: 02.41.00)
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoSuiteClient.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
VERSION: 02.35.00
|
||||
VERSION: 02.41.00
|
||||
PATH: /docs/guides/troubleshooting-guide.md
|
||||
BRIEF: Troubleshooting guide for diagnosing and resolving issues related to the MokoSuiteClient plugin
|
||||
NOTE: Designed for administrators and Suite operations teams
|
||||
-->
|
||||
|
||||
# MokoSuiteClient Troubleshooting Guide (VERSION: 02.35.00)
|
||||
# MokoSuiteClient Troubleshooting Guide (VERSION: 02.41.00)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoSuiteClient.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
VERSION: 02.35.00
|
||||
VERSION: 02.41.00
|
||||
PATH: /docs/guides/upgrade-and-versioning-guide.md
|
||||
BRIEF: Guide for updating, versioning, and maintaining the MokoSuiteClient plugin
|
||||
NOTE: Defines release flow, version rules, and upgrade validation
|
||||
-->
|
||||
|
||||
# MokoSuiteClient Upgrade and Versioning Guide (VERSION: 02.35.00)
|
||||
# MokoSuiteClient Upgrade and Versioning Guide (VERSION: 02.41.00)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
+2
-2
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoSuiteClient.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
VERSION: 02.35.00
|
||||
VERSION: 02.41.00
|
||||
PATH: /docs/index.md
|
||||
BRIEF: Master index of all documentation for the MokoSuiteClient plugin
|
||||
NOTE: Automatically maintained index for all guide canvases
|
||||
-->
|
||||
|
||||
# MokoSuiteClient Documentation Index (VERSION: 02.35.00)
|
||||
# MokoSuiteClient Documentation Index (VERSION: 02.41.00)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
INGROUP: MokoSuiteClient
|
||||
REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
PATH: /docs/plugin-basic.md
|
||||
VERSION: 02.35.00
|
||||
VERSION: 02.41.00
|
||||
BRIEF: Baseline documentation for the MokoSuiteClient system plugin
|
||||
NOTE: Foundational reference for internal and external stakeholders
|
||||
-->
|
||||
|
||||
# MokoSuiteClient Plugin Overview (VERSION: 02.35.00)
|
||||
# MokoSuiteClient Plugin Overview (VERSION: 02.41.00)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ DEFGROUP: MokoSuiteClient.Documentation
|
||||
INGROUP: MokoStandards.Templates
|
||||
REPO: https://github.com/mokoconsulting-tech/MokoSuiteClient
|
||||
PATH: /docs/update-server.md
|
||||
VERSION: 02.35.00
|
||||
VERSION: 02.41.00
|
||||
BRIEF: How this extension's Joomla update server file (update.xml) is managed
|
||||
-->
|
||||
|
||||
|
||||
@@ -50,14 +50,14 @@
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomOpenGraph/raw/branch/dev/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>MokoJoomBackup</name>
|
||||
<name>MokoSuiteClientBackup</name>
|
||||
<element>pkg_mokojoombackup</element>
|
||||
<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>
|
||||
<category>Tools</category>
|
||||
<article>https://mokoconsulting.tech/support/products/mokojoombackup</article>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomBackup/raw/branch/dev/updates.xml</updateserver>
|
||||
<article>https://mokoconsulting.tech/support/products/mokosuiteclientbackup</article>
|
||||
<updateserver>https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClientBackup/raw/branch/dev/updates.xml</updateserver>
|
||||
</extension>
|
||||
<extension>
|
||||
<name>MokoJoomHero</name>
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<config>
|
||||
<fieldset name="general" label="General" description="General component settings.">
|
||||
<field name="brand_name" type="text" default="MokoSuiteClient"
|
||||
label="Brand Name"
|
||||
description="Displayed in the admin sidebar, dashboard, and emails."
|
||||
hint="MokoSuiteClient" />
|
||||
<field name="support_email" type="email" default=""
|
||||
label="Support Email"
|
||||
description="Reply-to address for outbound notification emails."
|
||||
hint="support@example.com" />
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="notifications" label="Email Notifications" description="Configure email recipients for ticket and security notifications.">
|
||||
<field name="admin_emails" type="text" default=""
|
||||
label="Admin Email Addresses"
|
||||
@@ -16,6 +27,31 @@
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
|
||||
<field name="spacer_ntfy" type="spacer" label="Push Notifications (ntfy)" />
|
||||
<field name="ntfy_enabled" type="radio" default="0"
|
||||
label="Enable ntfy Push"
|
||||
description="Send push notifications via ntfy for ticket and security events."
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="ntfy_server" type="url" default="https://ntfy.mokoconsulting.tech"
|
||||
label="ntfy Server URL"
|
||||
description="Full URL to your ntfy server."
|
||||
showon="ntfy_enabled:1" />
|
||||
<field name="ntfy_topic" type="text" default="mokosuiteclient-tickets"
|
||||
label="Ticket Topic"
|
||||
description="ntfy topic name for helpdesk ticket notifications."
|
||||
showon="ntfy_enabled:1" />
|
||||
<field name="ntfy_security_topic" type="text" default="mokosuiteclient-security"
|
||||
label="Security Topic"
|
||||
description="ntfy topic name for security alert notifications. Falls back to ticket topic if empty."
|
||||
showon="ntfy_enabled:1" />
|
||||
<field name="ntfy_token" type="password" default=""
|
||||
label="ntfy Auth Token"
|
||||
description="Bearer token for authenticated ntfy topics. Leave empty for public topics."
|
||||
showon="ntfy_enabled:1" />
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="helpdesk" label="Helpdesk Settings" description="Default helpdesk behavior.">
|
||||
@@ -33,6 +69,44 @@
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</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 name="permissions" label="COM_MOKOSUITECLIENT_ACL_TITLE"
|
||||
|
||||
@@ -72,6 +72,9 @@ CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_tickets` (
|
||||
`sla_response_due` DATETIME DEFAULT NULL,
|
||||
`sla_resolution_due` DATETIME DEFAULT NULL,
|
||||
`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`),
|
||||
KEY `idx_status` (`status`),
|
||||
KEY `idx_status_id` (`status_id`),
|
||||
@@ -111,15 +114,32 @@ CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_ticket_canned` (
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_ticket_attachments` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`ticket_id` INT UNSIGNED NOT NULL,
|
||||
`reply_id` INT UNSIGNED DEFAULT NULL,
|
||||
`filename` VARCHAR(255) NOT NULL,
|
||||
`filepath` VARCHAR(512) NOT NULL,
|
||||
`filesize` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`mimetype` VARCHAR(100) NOT NULL DEFAULT '',
|
||||
`uploaded_by` INT NOT NULL DEFAULT 0,
|
||||
`created` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_ticket` (`ticket_id`),
|
||||
KEY `idx_reply` (`reply_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_ticket_automation` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`trigger_event` VARCHAR(50) NOT NULL DEFAULT 'ticket_created',
|
||||
`conditions` TEXT NOT NULL DEFAULT '[]',
|
||||
`actions` TEXT NOT NULL DEFAULT '[]',
|
||||
`conditions` TEXT NOT NULL,
|
||||
`actions` TEXT NOT NULL,
|
||||
`behavior` ENUM('append','always_new','skip_if_open') NOT NULL DEFAULT 'append',
|
||||
`enabled` TINYINT NOT NULL DEFAULT 1,
|
||||
`ordering` INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`)
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_trigger` (`trigger_event`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokosuiteclient_ticket_assignees` (
|
||||
|
||||
@@ -33,9 +33,10 @@ class DisplayController extends BaseController
|
||||
'waflog' => 'core.admin',
|
||||
'categories' => 'mokosuiteclient.tickets',
|
||||
'canned' => 'mokosuiteclient.tickets',
|
||||
'automation' => 'core.admin',
|
||||
'database' => 'core.admin',
|
||||
'cleanup' => 'mokosuiteclient.cache',
|
||||
'automation' => 'core.admin',
|
||||
'database' => 'core.admin',
|
||||
'cleanup' => 'mokosuiteclient.cache',
|
||||
'ticketsettings' => 'core.admin',
|
||||
];
|
||||
|
||||
public function display($cachable = false, $urlparams = [])
|
||||
@@ -89,22 +90,22 @@ class DisplayController extends BaseController
|
||||
|
||||
try
|
||||
{
|
||||
$monitorPlugin = \Joomla\CMS\Plugin\PluginHelper::getPlugin('system', 'mokosuiteclient_monitor');
|
||||
$corePlugin = \Joomla\CMS\Plugin\PluginHelper::getPlugin('system', 'mokosuiteclient');
|
||||
|
||||
if (!$monitorPlugin)
|
||||
if (!$corePlugin)
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Monitor plugin not enabled.']);
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Core plugin not enabled.']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$params = new \Joomla\Registry\Registry($monitorPlugin->params);
|
||||
$baseUrl = rtrim($params->get('base_url', ''), '/');
|
||||
$params = new \Joomla\Registry\Registry($corePlugin->params);
|
||||
$baseUrl = rtrim($params->get('monitor_base_url', ''), '/');
|
||||
|
||||
// Fall back to manifest XML default if not yet saved in params
|
||||
// Fall back to manifest XML default
|
||||
if (empty($baseUrl))
|
||||
{
|
||||
$manifestFile = JPATH_PLUGINS . '/system/mokosuiteclient_monitor/mokosuiteclient_monitor.xml';
|
||||
$manifestFile = JPATH_PLUGINS . '/system/mokosuiteclient/mokosuiteclient.xml';
|
||||
|
||||
if (is_file($manifestFile))
|
||||
{
|
||||
@@ -112,7 +113,7 @@ class DisplayController extends BaseController
|
||||
|
||||
if ($xml)
|
||||
{
|
||||
foreach ($xml->xpath('//field[@name="base_url"]') as $field)
|
||||
foreach ($xml->xpath('//field[@name="monitor_base_url"]') as $field)
|
||||
{
|
||||
$baseUrl = rtrim((string) $field['default'], '/');
|
||||
break;
|
||||
@@ -123,14 +124,12 @@ class DisplayController extends BaseController
|
||||
|
||||
if (empty($baseUrl))
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => 'MokoSuiteClientHQ URL not configured in monitor plugin.']);
|
||||
$this->jsonResponse(['success' => false, 'message' => 'MokoSuiteClientHQ URL not configured.']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$corePlugin = \Joomla\CMS\Plugin\PluginHelper::getPlugin('system', 'mokosuiteclient');
|
||||
$coreParams = new \Joomla\Registry\Registry($corePlugin ? $corePlugin->params : '{}');
|
||||
$healthToken = $coreParams->get('health_api_token', '');
|
||||
$healthToken = $params->get('health_api_token', '');
|
||||
|
||||
if (empty($healthToken))
|
||||
{
|
||||
@@ -155,12 +154,12 @@ class DisplayController extends BaseController
|
||||
|
||||
// RSA sign the request
|
||||
$headers = ['Content-Type: application/json'];
|
||||
$signingKeyB64 = $params->get('signing_key', '');
|
||||
$signingKeyB64 = $params->get('monitor_signing_key', '');
|
||||
|
||||
// Fall back to manifest XML default if not yet saved in params
|
||||
// Fall back to manifest XML default
|
||||
if (empty($signingKeyB64))
|
||||
{
|
||||
$manifestFile = JPATH_PLUGINS . '/system/mokosuiteclient_monitor/mokosuiteclient_monitor.xml';
|
||||
$manifestFile = JPATH_PLUGINS . '/system/mokosuiteclient/mokosuiteclient.xml';
|
||||
|
||||
if (is_file($manifestFile))
|
||||
{
|
||||
@@ -168,7 +167,7 @@ class DisplayController extends BaseController
|
||||
|
||||
if ($xml)
|
||||
{
|
||||
foreach ($xml->xpath('//field[@name="signing_key"]') as $field)
|
||||
foreach ($xml->xpath('//field[@name="monitor_signing_key"]') as $field)
|
||||
{
|
||||
$signingKeyB64 = (string) $field['default'];
|
||||
break;
|
||||
@@ -365,10 +364,14 @@ class DisplayController extends BaseController
|
||||
$input = Factory::getApplication()->getInput();
|
||||
|
||||
$this->jsonResponse($this->getModel('Tickets')->createTicket([
|
||||
'subject' => $input->getString('subject', ''),
|
||||
'body' => $input->getRaw('body', ''),
|
||||
'priority' => $input->getString('priority', 'normal'),
|
||||
'category_id' => $input->getInt('category_id', 0),
|
||||
'subject' => $input->getString('subject', ''),
|
||||
'body' => $input->getRaw('body', ''),
|
||||
'priority' => $input->getString('priority', 'normal'),
|
||||
'category_id' => $input->getInt('category_id', 0),
|
||||
'contact_id' => $input->getInt('contact_id', 0),
|
||||
'assign_users' => $input->get('assign_users', [], 'ARRAY'),
|
||||
'assign_groups' => $input->get('assign_groups', [], 'ARRAY'),
|
||||
'custom_fields' => $input->get('custom_fields', [], 'ARRAY'),
|
||||
]));
|
||||
}
|
||||
|
||||
@@ -405,10 +408,85 @@ class DisplayController extends BaseController
|
||||
|
||||
$this->jsonResponse($this->getModel('Tickets')->updateStatus(
|
||||
$input->getInt('ticket_id', 0),
|
||||
$input->getString('status', '')
|
||||
$input->getInt('status', 0)
|
||||
));
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Ticket Settings — Status/Priority CRUD
|
||||
// ==================================================================
|
||||
|
||||
public function saveStatus()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('core.admin'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$this->jsonResponse($this->getModel('Tickets')->saveStatus([
|
||||
'id' => $input->getInt('id', 0),
|
||||
'title' => $input->getString('title', ''),
|
||||
'alias' => $input->getString('alias', ''),
|
||||
'color' => $input->getString('color', 'bg-secondary'),
|
||||
'is_default' => $input->getInt('is_default', 0),
|
||||
'is_closed' => $input->getInt('is_closed', 0),
|
||||
'ordering' => $input->getInt('ordering', 0),
|
||||
]));
|
||||
}
|
||||
|
||||
public function deleteStatus()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('core.admin'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||
$this->jsonResponse($this->getModel('Tickets')->deleteStatus($id));
|
||||
}
|
||||
|
||||
public function savePriority()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('core.admin'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$this->jsonResponse($this->getModel('Tickets')->savePriority([
|
||||
'id' => $input->getInt('id', 0),
|
||||
'title' => $input->getString('title', ''),
|
||||
'alias' => $input->getString('alias', ''),
|
||||
'color' => $input->getString('color', 'bg-secondary'),
|
||||
'is_default' => $input->getInt('is_default', 0),
|
||||
'ordering' => $input->getInt('ordering', 0),
|
||||
]));
|
||||
}
|
||||
|
||||
public function deletePriority()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('core.admin'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||
$this->jsonResponse($this->getModel('Tickets')->deletePriority($id));
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// KB Search
|
||||
// ==================================================================
|
||||
@@ -420,6 +498,7 @@ class DisplayController extends BaseController
|
||||
if (strlen($query) < 3)
|
||||
{
|
||||
$this->jsonResponse(['results' => []]);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
@@ -447,7 +526,8 @@ class DisplayController extends BaseController
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$this->jsonResponse(['results' => []]);
|
||||
Log::add('KB search failed: ' . $e->getMessage(), Log::ERROR, 'mokosuiteclient');
|
||||
$this->jsonResponse(['results' => [], 'error' => 'Search unavailable']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,7 +575,7 @@ class DisplayController extends BaseController
|
||||
public function saveCategory()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); }
|
||||
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$db = Factory::getDbo();
|
||||
$id = $input->getInt('id', 0);
|
||||
@@ -520,16 +600,29 @@ class DisplayController extends BaseController
|
||||
public function deleteCategory()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); }
|
||||
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery($db->getQuery(true)->delete('#__mokosuiteclient_ticket_categories')->where('id = ' . Factory::getApplication()->getInput()->getInt('id', 0)))->execute();
|
||||
$this->jsonResponse(['success' => true, 'message' => 'Category deleted.']);
|
||||
}
|
||||
|
||||
public function reorderCategory()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
|
||||
$order = json_decode(Factory::getApplication()->getInput()->getRaw('order', '[]'), true);
|
||||
if (!is_array($order)) { $this->jsonResponse(['success' => false, 'message' => 'Invalid order']); return; }
|
||||
$db = Factory::getDbo();
|
||||
foreach ($order as $i => $id) {
|
||||
$db->setQuery('UPDATE ' . $db->quoteName('#__mokosuiteclient_ticket_categories') . ' SET ordering = ' . (int) $i . ' WHERE id = ' . (int) $id)->execute();
|
||||
}
|
||||
$this->jsonResponse(['success' => true, 'message' => 'Order saved.']);
|
||||
}
|
||||
|
||||
public function saveCanned()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); }
|
||||
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$db = Factory::getDbo();
|
||||
$data = (object) [
|
||||
@@ -547,16 +640,95 @@ class DisplayController extends BaseController
|
||||
public function deleteCanned()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); }
|
||||
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery($db->getQuery(true)->delete('#__mokosuiteclient_ticket_canned')->where('id = ' . Factory::getApplication()->getInput()->getInt('id', 0)))->execute();
|
||||
$this->jsonResponse(['success' => true, 'message' => 'Canned response deleted.']);
|
||||
}
|
||||
|
||||
public function reorderCanned()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
|
||||
$order = json_decode(Factory::getApplication()->getInput()->getRaw('order', '[]'), true);
|
||||
if (!is_array($order)) { $this->jsonResponse(['success' => false, 'message' => 'Invalid order']); return; }
|
||||
$db = Factory::getDbo();
|
||||
foreach ($order as $i => $id) {
|
||||
$db->setQuery('UPDATE ' . $db->quoteName('#__mokosuiteclient_ticket_canned') . ' SET ordering = ' . (int) $i . ' WHERE id = ' . (int) $id)->execute();
|
||||
}
|
||||
$this->jsonResponse(['success' => true, 'message' => 'Order saved.']);
|
||||
}
|
||||
|
||||
public function uploadAttachment()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$ticketId = $input->getInt('ticket_id', 0);
|
||||
$replyId = $input->getInt('reply_id', 0) ?: null;
|
||||
if (!$ticketId) { $this->jsonResponse(['success' => false, 'message' => 'Missing ticket_id']); return; }
|
||||
$files = $input->files->get('attachments', [], 'raw');
|
||||
if (empty($files) || empty($files['name'])) { $this->jsonResponse(['success' => false, 'message' => 'No files uploaded']); return; }
|
||||
$saved = \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::upload($ticketId, $replyId, $files);
|
||||
$this->jsonResponse(['success' => true, 'message' => count($saved) . ' file(s) uploaded', 'count' => count($saved)]);
|
||||
}
|
||||
|
||||
public function downloadAttachment()
|
||||
{
|
||||
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
|
||||
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuiteclient_ticket_attachments')->where('id = ' . $id));
|
||||
$att = $db->loadObject();
|
||||
if (!$att) { throw new \RuntimeException('Attachment not found', 404); }
|
||||
$path = \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::getAbsolutePath($att);
|
||||
if (!file_exists($path)) { throw new \RuntimeException('File not found', 404); }
|
||||
$app = Factory::getApplication();
|
||||
$app->setHeader('Content-Type', $att->mimetype ?: 'application/octet-stream');
|
||||
$safeName = str_replace(['"', "\r", "\n"], '', $att->filename);
|
||||
$app->setHeader('Content-Disposition', 'attachment; filename="' . $safeName . '"');
|
||||
$app->setHeader('Content-Length', (string) filesize($path));
|
||||
$app->sendHeaders();
|
||||
readfile($path);
|
||||
$app->close();
|
||||
}
|
||||
|
||||
public function deleteAttachment()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
|
||||
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||
$ok = \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::delete($id);
|
||||
$this->jsonResponse(['success' => $ok, 'message' => $ok ? 'Attachment deleted' : 'Not found']);
|
||||
}
|
||||
|
||||
public function rateTicket()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; }
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$ticketId = $input->getInt('ticket_id', 0);
|
||||
$rating = $input->getInt('rating', 0);
|
||||
$feedback = $input->getString('feedback', '');
|
||||
if (!$ticketId || $rating < 1 || $rating > 5) {
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Invalid rating (1-5)']);
|
||||
return;
|
||||
}
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery(
|
||||
'UPDATE ' . $db->quoteName('#__mokosuiteclient_tickets')
|
||||
. ' SET satisfaction_rating = ' . $rating
|
||||
. ', satisfaction_feedback = ' . $db->quote($feedback)
|
||||
. ', satisfaction_rated_at = ' . $db->quote(Factory::getDate()->toSql())
|
||||
. ' WHERE id = ' . $ticketId
|
||||
)->execute();
|
||||
$this->jsonResponse(['success' => true, 'message' => 'Thank you for your feedback!']);
|
||||
}
|
||||
|
||||
public function saveAutomation()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); }
|
||||
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$db = Factory::getDbo();
|
||||
$data = (object) [
|
||||
@@ -564,6 +736,7 @@ class DisplayController extends BaseController
|
||||
'trigger_event' => $input->getString('trigger_event', 'ticket_created'),
|
||||
'conditions' => $input->getRaw('conditions', '[]'),
|
||||
'actions' => $input->getRaw('actions', '[]'),
|
||||
'behavior' => $input->getString('behavior', 'append'),
|
||||
'enabled' => 1,
|
||||
'ordering' => 0,
|
||||
];
|
||||
@@ -576,7 +749,7 @@ class DisplayController extends BaseController
|
||||
public function deleteAutomation()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); }
|
||||
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery($db->getQuery(true)->delete('#__mokosuiteclient_ticket_automation')->where('id = ' . Factory::getApplication()->getInput()->getInt('id', 0)))->execute();
|
||||
$this->jsonResponse(['success' => true, 'message' => 'Rule deleted.']);
|
||||
@@ -585,7 +758,7 @@ class DisplayController extends BaseController
|
||||
public function toggleAutomation()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); }
|
||||
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery($db->getQuery(true)->update('#__mokosuiteclient_ticket_automation')
|
||||
@@ -594,6 +767,19 @@ class DisplayController extends BaseController
|
||||
$this->jsonResponse(['success' => true, 'message' => 'Rule updated.']);
|
||||
}
|
||||
|
||||
public function reorderAutomation()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
|
||||
$order = json_decode(Factory::getApplication()->getInput()->getRaw('order', '[]'), true);
|
||||
if (!is_array($order)) { $this->jsonResponse(['success' => false, 'message' => 'Invalid order']); return; }
|
||||
$db = Factory::getDbo();
|
||||
foreach ($order as $i => $id) {
|
||||
$db->setQuery('UPDATE ' . $db->quoteName('#__mokosuiteclient_ticket_automation') . ' SET ordering = ' . (int) $i . ' WHERE id = ' . (int) $id)->execute();
|
||||
}
|
||||
$this->jsonResponse(['success' => true, 'message' => 'Order saved.']);
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Settings Import/Export (#132)
|
||||
// ==================================================================
|
||||
|
||||
@@ -575,6 +575,39 @@ class TicketsModel extends BaseDatabaseModel
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backend users for assignee selection.
|
||||
*/
|
||||
public function getBackendUsers(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select(['u.id', 'u.name', 'u.username'])
|
||||
->from($db->quoteName('#__users', 'u'))
|
||||
->where($db->quoteName('u.block') . ' = 0')
|
||||
->order($db->quoteName('u.name') . ' ASC')
|
||||
);
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Joomla user groups for assignee selection.
|
||||
*/
|
||||
public function getUserGroups(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select(['id', 'title'])
|
||||
->from($db->quoteName('#__usergroups'))
|
||||
->order($db->quoteName('title') . ' ASC')
|
||||
);
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Joomla custom field groups assigned to a ticket category.
|
||||
*/
|
||||
@@ -1100,6 +1133,117 @@ class TicketsModel extends BaseDatabaseModel
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Status/Priority CRUD
|
||||
// ==================================================================
|
||||
|
||||
public function saveStatus(array $data): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$obj = (object) $data;
|
||||
|
||||
if (!empty($obj->title) && empty($obj->alias))
|
||||
{
|
||||
$obj->alias = strtolower(preg_replace('/[^a-z0-9]+/i', '_', $obj->title));
|
||||
}
|
||||
|
||||
if (empty($obj->id))
|
||||
{
|
||||
unset($obj->id);
|
||||
$db->insertObject('#__mokosuiteclient_ticket_statuses', $obj, 'id');
|
||||
|
||||
return ['status' => 'ok', 'id' => (int) $obj->id, 'message' => 'Status created'];
|
||||
}
|
||||
|
||||
$db->updateObject('#__mokosuiteclient_ticket_statuses', $obj, 'id');
|
||||
|
||||
return ['status' => 'ok', 'id' => (int) $obj->id, 'message' => 'Status updated'];
|
||||
}
|
||||
|
||||
public function deleteStatus(int $id): array
|
||||
{
|
||||
if ($id < 1)
|
||||
{
|
||||
return ['status' => 'error', 'message' => 'Invalid ID'];
|
||||
}
|
||||
|
||||
$db = $this->getDatabase();
|
||||
|
||||
// Check no tickets use this status
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokosuiteclient_tickets'))
|
||||
->where($db->quoteName('status_id') . ' = ' . $id)
|
||||
);
|
||||
|
||||
if ((int) $db->loadResult() > 0)
|
||||
{
|
||||
return ['status' => 'error', 'message' => 'Cannot delete — status is in use by tickets'];
|
||||
}
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete($db->quoteName('#__mokosuiteclient_ticket_statuses'))
|
||||
->where($db->quoteName('id') . ' = ' . $id)
|
||||
)->execute();
|
||||
|
||||
return ['status' => 'ok', 'message' => 'Status deleted'];
|
||||
}
|
||||
|
||||
public function savePriority(array $data): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$obj = (object) $data;
|
||||
|
||||
if (!empty($obj->title) && empty($obj->alias))
|
||||
{
|
||||
$obj->alias = strtolower(preg_replace('/[^a-z0-9]+/i', '_', $obj->title));
|
||||
}
|
||||
|
||||
if (empty($obj->id))
|
||||
{
|
||||
unset($obj->id);
|
||||
$db->insertObject('#__mokosuiteclient_ticket_priorities', $obj, 'id');
|
||||
|
||||
return ['status' => 'ok', 'id' => (int) $obj->id, 'message' => 'Priority created'];
|
||||
}
|
||||
|
||||
$db->updateObject('#__mokosuiteclient_ticket_priorities', $obj, 'id');
|
||||
|
||||
return ['status' => 'ok', 'id' => (int) $obj->id, 'message' => 'Priority updated'];
|
||||
}
|
||||
|
||||
public function deletePriority(int $id): array
|
||||
{
|
||||
if ($id < 1)
|
||||
{
|
||||
return ['status' => 'error', 'message' => 'Invalid ID'];
|
||||
}
|
||||
|
||||
$db = $this->getDatabase();
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokosuiteclient_tickets'))
|
||||
->where($db->quoteName('priority_id') . ' = ' . $id)
|
||||
);
|
||||
|
||||
if ((int) $db->loadResult() > 0)
|
||||
{
|
||||
return ['status' => 'error', 'message' => 'Cannot delete — priority is in use by tickets'];
|
||||
}
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete($db->quoteName('#__mokosuiteclient_ticket_priorities'))
|
||||
->where($db->quoteName('id') . ' = ' . $id)
|
||||
)->execute();
|
||||
|
||||
return ['status' => 'ok', 'message' => 'Priority deleted'];
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Akeeba Ticket System Importer
|
||||
// ==================================================================
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteClient
|
||||
* @subpackage com_mokosuiteclient
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuiteClient\Administrator\Service;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Filesystem\File;
|
||||
use Joomla\CMS\Filesystem\Folder;
|
||||
use Joomla\CMS\Log\Log;
|
||||
|
||||
class AttachmentService
|
||||
{
|
||||
private const STORAGE_DIR = JPATH_ROOT . '/media/com_mokosuiteclient/attachments';
|
||||
|
||||
private const ALLOWED_EXTENSIONS = [
|
||||
'jpg', 'jpeg', 'png', 'gif', 'webp',
|
||||
'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)) {
|
||||
Log::add("Failed to create attachment directory: {$ticketDir}", Log::ERROR, 'mokosuiteclient');
|
||||
return [];
|
||||
}
|
||||
|
||||
$userId = (int) Factory::getUser()->id;
|
||||
$db = Factory::getDbo();
|
||||
|
||||
for ($i = 0, $count = count($files['name']); $i < $count; $i++)
|
||||
{
|
||||
if ($files['error'][$i] !== UPLOAD_ERR_OK) {
|
||||
Log::add("Attachment upload error for '{$files['name'][$i]}': PHP error code {$files['error'][$i]}", Log::WARNING, 'mokosuiteclient');
|
||||
continue;
|
||||
}
|
||||
|
||||
$originalName = File::makeSafe($files['name'][$i]);
|
||||
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
|
||||
|
||||
// Validate extension
|
||||
if (!in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
|
||||
Log::add("Attachment rejected: disallowed extension .{$ext}", Log::WARNING, 'mokosuiteclient');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate size
|
||||
if ($files['size'][$i] > self::MAX_FILE_SIZE) {
|
||||
Log::add("Attachment rejected: file too large ({$files['size'][$i]} bytes)", Log::WARNING, 'mokosuiteclient');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate unique filename to prevent overwrites
|
||||
$storedName = uniqid('att_', true) . '.' . $ext;
|
||||
$destPath = $ticketDir . '/' . $storedName;
|
||||
|
||||
if (!File::upload($files['tmp_name'][$i], $destPath)) {
|
||||
Log::add("Attachment upload failed: {$originalName}", Log::ERROR, 'mokosuiteclient');
|
||||
continue;
|
||||
}
|
||||
|
||||
$record = (object) [
|
||||
'ticket_id' => $ticketId,
|
||||
'reply_id' => $replyId,
|
||||
'filename' => $originalName,
|
||||
'filepath' => $ticketId . '/' . $storedName,
|
||||
'filesize' => $files['size'][$i],
|
||||
'mimetype' => mime_content_type($destPath) ?: 'application/octet-stream',
|
||||
'uploaded_by' => $userId,
|
||||
'created' => Factory::getDate()->toSql(),
|
||||
];
|
||||
|
||||
$db->insertObject('#__mokosuiteclient_ticket_attachments', $record, 'id');
|
||||
$saved[] = $record;
|
||||
}
|
||||
|
||||
return $saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attachments for a ticket.
|
||||
*/
|
||||
public static function getForTicket(int $ticketId): array
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('a.*, u.name AS uploader_name')
|
||||
->from($db->quoteName('#__mokosuiteclient_ticket_attachments', 'a'))
|
||||
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = a.uploaded_by')
|
||||
->where($db->quoteName('a.ticket_id') . ' = ' . $ticketId)
|
||||
->order('a.created ASC')
|
||||
);
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the absolute filesystem path for an attachment.
|
||||
*/
|
||||
public static function getAbsolutePath(object $attachment): ?string
|
||||
{
|
||||
$path = realpath(self::STORAGE_DIR . '/' . $attachment->filepath);
|
||||
if ($path === false || !str_starts_with($path, realpath(self::STORAGE_DIR))) {
|
||||
return null;
|
||||
}
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an attachment (file + DB record).
|
||||
*/
|
||||
public static function delete(int $attachmentId): bool
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('*')
|
||||
->from('#__mokosuiteclient_ticket_attachments')
|
||||
->where('id = ' . $attachmentId)
|
||||
);
|
||||
$att = $db->loadObject();
|
||||
|
||||
if (!$att) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$path = self::STORAGE_DIR . '/' . $att->filepath;
|
||||
|
||||
if (file_exists($path)) {
|
||||
File::delete($path);
|
||||
}
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete('#__mokosuiteclient_ticket_attachments')
|
||||
->where('id = ' . $attachmentId)
|
||||
)->execute();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size for display.
|
||||
*/
|
||||
public static function formatSize(int $bytes): string
|
||||
{
|
||||
if ($bytes < 1024) return $bytes . ' B';
|
||||
if ($bytes < 1048576) return round($bytes / 1024, 1) . ' KB';
|
||||
return round($bytes / 1048576, 1) . ' MB';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteClient
|
||||
* @subpackage com_mokosuiteclient
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuiteClient\Administrator\Service;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
|
||||
/**
|
||||
* Automation rule engine — evaluates trigger/condition/action rules.
|
||||
*
|
||||
* Called from event hooks (system plugin, task plugin) whenever
|
||||
* a triggering event occurs. Loads matching rules, checks conditions,
|
||||
* and executes actions.
|
||||
*
|
||||
* @since 02.35.00
|
||||
*/
|
||||
class AutomationEngine
|
||||
{
|
||||
/**
|
||||
* Fire all matching rules for a given trigger event.
|
||||
*
|
||||
* @param string $triggerEvent Event name (ticket_created, user_login, etc.)
|
||||
* @param array $context Context data (ticket object, user data, etc.)
|
||||
*/
|
||||
public static function fire(string $triggerEvent, array $context = []): void
|
||||
{
|
||||
try
|
||||
{
|
||||
$rules = self::getActiveRules($triggerEvent);
|
||||
|
||||
foreach ($rules as $rule)
|
||||
{
|
||||
$conditions = json_decode($rule->conditions, true) ?: [];
|
||||
$actions = json_decode($rule->actions, true) ?: [];
|
||||
|
||||
if (self::evaluateConditions($conditions, $context))
|
||||
{
|
||||
self::executeActions($actions, $rule, $context);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Automation engine error: ' . $e->getMessage(), Log::ERROR, 'mokosuiteclient');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active automation rules for a trigger event.
|
||||
*/
|
||||
private static function getActiveRules(string $event): array
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('*')
|
||||
->from('#__mokosuiteclient_ticket_automation')
|
||||
->where($db->quoteName('trigger_event') . ' = ' . $db->quote($event))
|
||||
->where($db->quoteName('enabled') . ' = 1')
|
||||
->order('ordering ASC')
|
||||
);
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate all conditions (AND logic).
|
||||
*/
|
||||
private static function evaluateConditions(array $conditions, array $context): bool
|
||||
{
|
||||
foreach ($conditions as $c)
|
||||
{
|
||||
$field = $c['field'] ?? '';
|
||||
$op = $c['op'] ?? 'eq';
|
||||
$expected = $c['value'] ?? '';
|
||||
$actual = $context[$field] ?? '';
|
||||
|
||||
switch ($op)
|
||||
{
|
||||
case 'eq': if ((string) $actual !== (string) $expected) return false; break;
|
||||
case 'neq': if ((string) $actual === (string) $expected) return false; break;
|
||||
case 'gt': if ((float) $actual <= (float) $expected) return false; break;
|
||||
case 'lt': if ((float) $actual >= (float) $expected) return false; break;
|
||||
case 'in':
|
||||
$values = array_map('trim', explode(',', $expected));
|
||||
if (!in_array((string) $actual, $values, true)) return false;
|
||||
break;
|
||||
case 'not_in':
|
||||
$values = array_map('trim', explode(',', $expected));
|
||||
if (in_array((string) $actual, $values, true)) return false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute actions for a matched rule.
|
||||
*/
|
||||
private static function executeActions(array $actions, object $rule, array $context): void
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$ticketId = (int) ($context['ticket_id'] ?? $context['id'] ?? 0);
|
||||
|
||||
foreach ($actions as $action)
|
||||
{
|
||||
$type = $action['type'] ?? '';
|
||||
$value = $action['value'] ?? '';
|
||||
|
||||
try
|
||||
{
|
||||
switch ($type)
|
||||
{
|
||||
case 'set_status':
|
||||
if ($ticketId) {
|
||||
$statusId = self::resolveStatusId($db, $value);
|
||||
$sets = "status = {$db->quote($value)}, modified = {$db->quote(Factory::getDate()->toSql())}";
|
||||
if ($statusId) { $sets .= ", status_id = {$statusId}"; }
|
||||
$db->setQuery("UPDATE {$db->quoteName('#__mokosuiteclient_tickets')} SET {$sets} WHERE id = {$ticketId}")->execute();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'set_priority':
|
||||
if ($ticketId) {
|
||||
$priorityId = self::resolvePriorityId($db, $value);
|
||||
$sets = "priority = {$db->quote($value)}, modified = {$db->quote(Factory::getDate()->toSql())}";
|
||||
if ($priorityId) { $sets .= ", priority_id = {$priorityId}"; }
|
||||
$db->setQuery("UPDATE {$db->quoteName('#__mokosuiteclient_tickets')} SET {$sets} WHERE id = {$ticketId}")->execute();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'assign':
|
||||
$assignId = (int) $value;
|
||||
if ($ticketId && $assignId > 0) {
|
||||
$db->setQuery("UPDATE {$db->quoteName('#__mokosuiteclient_tickets')} SET assigned_to = {$assignId}, modified = {$db->quote(Factory::getDate()->toSql())} WHERE id = {$ticketId}")->execute();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'add_note':
|
||||
if ($ticketId) {
|
||||
$note = (object) [
|
||||
'ticket_id' => $ticketId,
|
||||
'user_id' => 0,
|
||||
'body' => $value ?: '[Automation: ' . ($rule->title ?? '') . ']',
|
||||
'is_internal' => 1,
|
||||
'created' => Factory::getDate()->toSql(),
|
||||
];
|
||||
$db->insertObject('#__mokosuiteclient_ticket_replies', $note);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'send_email':
|
||||
NotificationService::securityAlert(
|
||||
'automation',
|
||||
'Automation: ' . ($rule->title ?? ''),
|
||||
$value ?: 'Rule triggered for ticket #' . $ticketId
|
||||
);
|
||||
break;
|
||||
|
||||
case 'send_ntfy':
|
||||
NotificationService::pushNtfySecurity(
|
||||
'automation',
|
||||
'Automation: ' . ($rule->title ?? ''),
|
||||
$value ?: 'Rule triggered for ticket #' . $ticketId
|
||||
);
|
||||
break;
|
||||
|
||||
case 'close':
|
||||
if ($ticketId) {
|
||||
$closedId = self::resolveClosedStatusId($db);
|
||||
$sets = "status = 'closed', closed = {$db->quote(Factory::getDate()->toSql())}, modified = {$db->quote(Factory::getDate()->toSql())}";
|
||||
if ($closedId) { $sets .= ", status_id = {$closedId}"; }
|
||||
$db->setQuery("UPDATE {$db->quoteName('#__mokosuiteclient_tickets')} SET {$sets} WHERE id = {$ticketId}")->execute();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'create_ticket':
|
||||
self::createTicketFromAutomation($rule, $context, $value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add("Automation action '{$type}' failed for rule #{$rule->id}: " . $e->getMessage(), Log::ERROR, 'mokosuiteclient');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ticket from automation (with behavior: append/always_new/skip_if_open).
|
||||
*/
|
||||
private static function resolveStatusId($db, string $alias): int
|
||||
{
|
||||
return (int) $db->setQuery(
|
||||
$db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_statuses')
|
||||
->where($db->quoteName('alias') . ' = ' . $db->quote($alias)), 0, 1
|
||||
)->loadResult();
|
||||
}
|
||||
|
||||
private static function resolvePriorityId($db, string $alias): int
|
||||
{
|
||||
return (int) $db->setQuery(
|
||||
$db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_priorities')
|
||||
->where($db->quoteName('alias') . ' = ' . $db->quote($alias)), 0, 1
|
||||
)->loadResult();
|
||||
}
|
||||
|
||||
private static function resolveClosedStatusId($db): int
|
||||
{
|
||||
return (int) $db->setQuery(
|
||||
$db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_statuses')
|
||||
->where($db->quoteName('is_closed') . ' = 1'), 0, 1
|
||||
)->loadResult();
|
||||
}
|
||||
|
||||
private static function createTicketFromAutomation(object $rule, array $context, string $subject): void
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$behavior = $rule->behavior ?? 'append';
|
||||
$userId = (int) ($context['user_id'] ?? 0);
|
||||
$catId = (int) ($context['category_id'] ?? 0);
|
||||
|
||||
if ($behavior !== 'always_new' && $userId > 0)
|
||||
{
|
||||
// Check for existing open ticket (check both status ENUM and status_id)
|
||||
$query = $db->getQuery(true)
|
||||
->select('t.id')
|
||||
->from($db->quoteName('#__mokosuiteclient_tickets', 't'))
|
||||
->join('LEFT', $db->quoteName('#__mokosuiteclient_ticket_statuses', 's') . ' ON t.status_id = s.id')
|
||||
->where('t.created_by = ' . $userId)
|
||||
->where("(s.id IS NULL AND t.status NOT IN ('closed', 'resolved')) OR (s.id IS NOT NULL AND s.is_closed = 0)");
|
||||
|
||||
if ($catId > 0) {
|
||||
$query->where('category_id = ' . $catId);
|
||||
}
|
||||
|
||||
$db->setQuery($query, 0, 1);
|
||||
$existingId = (int) $db->loadResult();
|
||||
|
||||
if ($existingId > 0)
|
||||
{
|
||||
if ($behavior === 'skip_if_open') return;
|
||||
|
||||
// append — add reply to existing ticket
|
||||
$reply = (object) [
|
||||
'ticket_id' => $existingId,
|
||||
'user_id' => 0,
|
||||
'body' => $subject ?: '[Automation: ' . ($rule->title ?? '') . ']',
|
||||
'is_internal' => 1,
|
||||
'created' => Factory::getDate()->toSql(),
|
||||
];
|
||||
$db->insertObject('#__mokosuiteclient_ticket_replies', $reply);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Create new ticket
|
||||
$openStatusId = self::resolveStatusId($db, 'open') ?: null;
|
||||
$normalPriorityId = self::resolvePriorityId($db, $context['priority'] ?? 'normal') ?: null;
|
||||
$ticket = (object) [
|
||||
'subject' => $subject ?: 'Automation: ' . ($rule->title ?? ''),
|
||||
'body' => $context['body'] ?? '',
|
||||
'status' => 'open',
|
||||
'status_id' => $openStatusId,
|
||||
'priority' => $context['priority'] ?? 'normal',
|
||||
'priority_id' => $normalPriorityId,
|
||||
'category_id' => $catId ?: null,
|
||||
'created_by' => $userId,
|
||||
'created' => Factory::getDate()->toSql(),
|
||||
];
|
||||
$db->insertObject('#__mokosuiteclient_tickets', $ticket, 'id');
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,9 @@ class NotificationService
|
||||
Log::add('Notification send failed to ' . $email . ': ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||
}
|
||||
}
|
||||
|
||||
// Push notification via ntfy
|
||||
self::pushNtfy($event, $ticket, $subject);
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
@@ -302,6 +305,7 @@ class NotificationService
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Failed to look up email for user ID ' . $userId . ': ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -328,10 +332,168 @@ class NotificationService
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Failed to load notification config: ' . $e->getMessage(), Log::ERROR, 'mokosuiteclient');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Ntfy Push Notifications (#205)
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* Send a push notification via ntfy for ticket events.
|
||||
*/
|
||||
private static function pushNtfy(string $event, object $ticket, string $title): void
|
||||
{
|
||||
$config = self::getNotificationConfig();
|
||||
$ntfyEnabled = $config['ntfy_enabled'] ?? '0';
|
||||
|
||||
if (!$ntfyEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$ntfyServer = rtrim($config['ntfy_server'] ?? 'https://ntfy.mokoconsulting.tech', '/');
|
||||
$ntfyTopic = $config['ntfy_topic'] ?? 'mokosuiteclient-tickets';
|
||||
$ntfyToken = $config['ntfy_token'] ?? '';
|
||||
|
||||
$tagMap = [
|
||||
'ticket_created' => 'ticket,new',
|
||||
'ticket_replied' => 'speech_balloon',
|
||||
'status_changed' => 'arrows_counterclockwise',
|
||||
'ticket_assigned' => 'bust_in_silhouette',
|
||||
];
|
||||
|
||||
$priorityMap = [
|
||||
'ticket_created' => '4',
|
||||
'ticket_replied' => '3',
|
||||
'status_changed' => '3',
|
||||
'ticket_assigned' => '3',
|
||||
];
|
||||
|
||||
$siteUrl = rtrim(Uri::root(), '/');
|
||||
$ticketUrl = $siteUrl . '/administrator/index.php?option=com_mokosuiteclient&view=ticket&id=' . ($ticket->id ?? 0);
|
||||
|
||||
$message = self::buildNtfyMessage($event, $ticket);
|
||||
|
||||
$headers = [
|
||||
'Title: ' . $title,
|
||||
'Priority: ' . ($priorityMap[$event] ?? '3'),
|
||||
'Tags: ' . ($tagMap[$event] ?? 'ticket'),
|
||||
'Click: ' . $ticketUrl,
|
||||
];
|
||||
|
||||
if ($ntfyToken !== '')
|
||||
{
|
||||
$headers[] = 'Authorization: Bearer ' . $ntfyToken;
|
||||
}
|
||||
|
||||
$url = $ntfyServer . '/' . $ntfyTopic;
|
||||
|
||||
try
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $message);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false)
|
||||
{
|
||||
Log::add("Ntfy push connection failed for event {$event}: " . $curlError, Log::WARNING, 'mokosuiteclient');
|
||||
}
|
||||
elseif ($httpCode < 200 || $httpCode >= 300)
|
||||
{
|
||||
Log::add("Ntfy push failed (HTTP {$httpCode}) for event {$event}", Log::WARNING, 'mokosuiteclient');
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Ntfy push error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a short ntfy message body for ticket events.
|
||||
*/
|
||||
private static function buildNtfyMessage(string $event, object $ticket): string
|
||||
{
|
||||
$subject = $ticket->subject ?? 'Ticket #' . ($ticket->id ?? '?');
|
||||
|
||||
switch ($event)
|
||||
{
|
||||
case 'ticket_created':
|
||||
$priority = ucfirst($ticket->priority ?? 'normal');
|
||||
return "New ticket: {$subject}\nPriority: {$priority}";
|
||||
|
||||
case 'ticket_replied':
|
||||
return "Reply on: {$subject}";
|
||||
|
||||
case 'status_changed':
|
||||
$status = ucwords(str_replace('_', ' ', $ticket->status ?? ''));
|
||||
return "Status → {$status}: {$subject}";
|
||||
|
||||
case 'ticket_assigned':
|
||||
return "Assigned to you: {$subject}";
|
||||
|
||||
default:
|
||||
return $subject;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a push notification via ntfy for security events.
|
||||
*/
|
||||
public static function pushNtfySecurity(string $event, string $title, string $body): void
|
||||
{
|
||||
$config = self::getNotificationConfig();
|
||||
$ntfyEnabled = $config['ntfy_enabled'] ?? '0';
|
||||
|
||||
if (!$ntfyEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$ntfyServer = rtrim($config['ntfy_server'] ?? 'https://ntfy.mokoconsulting.tech', '/');
|
||||
$ntfyTopic = $config['ntfy_security_topic'] ?? $config['ntfy_topic'] ?? 'mokosuiteclient-security';
|
||||
$ntfyToken = $config['ntfy_token'] ?? '';
|
||||
|
||||
$headers = [
|
||||
'Title: [Security] ' . $title,
|
||||
'Priority: 5',
|
||||
'Tags: warning,shield',
|
||||
];
|
||||
|
||||
if ($ntfyToken !== '')
|
||||
{
|
||||
$headers[] = 'Authorization: Bearer ' . $ntfyToken;
|
||||
}
|
||||
|
||||
$url = $ntfyServer . '/' . $ntfyTopic;
|
||||
|
||||
try
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
|
||||
curl_exec($ch);
|
||||
curl_close($ch);
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Ntfy security push error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Security Event Notifications (#131)
|
||||
// ==================================================================
|
||||
@@ -407,6 +569,9 @@ class NotificationService
|
||||
Log::add('Security alert send failed: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||
}
|
||||
}
|
||||
|
||||
// Also push via ntfy
|
||||
self::pushNtfySecurity($event, $subject, $body);
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
|
||||
@@ -23,6 +23,7 @@ class HtmlView extends BaseHtmlView
|
||||
protected $priorities = [];
|
||||
protected $customFields = [];
|
||||
protected $fieldValues = [];
|
||||
protected $attachments = [];
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
@@ -43,6 +44,9 @@ class HtmlView extends BaseHtmlView
|
||||
$this->fieldValues = $model->getFieldValues($id);
|
||||
}
|
||||
|
||||
// Load attachments
|
||||
$this->attachments = \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::getForTicket($id);
|
||||
|
||||
if (!$this->ticket)
|
||||
{
|
||||
Factory::getApplication()->enqueueMessage('Ticket not found.', 'error');
|
||||
|
||||
@@ -25,6 +25,8 @@ class HtmlView extends BaseHtmlView
|
||||
protected $contacts = [];
|
||||
protected $statuses = [];
|
||||
protected $priorities = [];
|
||||
protected $backendUsers = [];
|
||||
protected $userGroups = [];
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
@@ -46,6 +48,8 @@ class HtmlView extends BaseHtmlView
|
||||
$this->overdue = $model->getOverdueTickets();
|
||||
$this->atsAvailable = $model->checkAtsAvailable();
|
||||
$this->contacts = $model->getContacts();
|
||||
$this->backendUsers = $model->getBackendUsers();
|
||||
$this->userGroups = $model->getUserGroups();
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later
|
||||
*
|
||||
* @package MokoSuiteClient
|
||||
* @subpackage Component
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuiteClient\Administrator\View\Ticketsettings;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $statuses = [];
|
||||
protected $priorities = [];
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$model = $this->getModel('Tickets');
|
||||
|
||||
$this->statuses = $model->getStatuses();
|
||||
$this->priorities = $model->getPriorities();
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
ToolbarHelper::title(Text::_('COM_MOKOSUITECLIENT_TICKET_SETTINGS'), 'cog');
|
||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient&view=tickets');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -9,81 +9,110 @@ $token = Session::getFormToken();
|
||||
$saveUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.saveAutomation&format=json');
|
||||
$deleteUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.deleteAutomation&format=json');
|
||||
$toggleUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.toggleAutomation&format=json');
|
||||
$reorderUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.reorderAutomation&format=json');
|
||||
|
||||
$triggerLabels = ['ticket_created' => 'On Ticket Created', 'ticket_replied' => 'On Reply', 'status_changed' => 'On Status Change', 'scheduled' => 'Scheduled (Cron)'];
|
||||
$triggerLabels = [
|
||||
'ticket_created' => 'On Ticket Created',
|
||||
'ticket_replied' => 'On Reply',
|
||||
'status_changed' => 'On Status Change',
|
||||
'ticket_assigned' => 'On Assignment',
|
||||
'user_login' => 'On User Login',
|
||||
'user_register' => 'On User Register',
|
||||
'user_login_failed' => 'On Failed Login',
|
||||
'content_save' => 'On Article Save',
|
||||
'extension_install' => 'On Extension Install',
|
||||
'scheduled' => 'Scheduled (Cron)',
|
||||
];
|
||||
$conditionFields = ['status', 'priority', 'category_id', 'assigned_to', 'sla_responded', 'age_hours'];
|
||||
$conditionOps = ['eq' => '=', 'neq' => '≠', 'gt' => '>', 'lt' => '<', 'in' => 'in', 'not_in' => 'not in'];
|
||||
$actionTypes = ['set_status', 'set_priority', 'assign', 'add_note', 'send_email', 'send_ntfy', 'close'];
|
||||
?>
|
||||
|
||||
<div id="mokosuiteclient-automation">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<?php foreach ($rules as $r): ?>
|
||||
<?php $conditions = json_decode($r->conditions, true) ?: []; $actions = json_decode($r->actions, true) ?: []; ?>
|
||||
<div class="card mb-2 <?php echo !$r->enabled ? 'opacity-50' : ''; ?>" data-id="<?php echo $r->id; ?>">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" class="form-check-input rule-toggle" data-id="<?php echo $r->id; ?>" <?php echo $r->enabled ? 'checked' : ''; ?>>
|
||||
<div id="rules-list">
|
||||
<?php foreach ($rules as $r): ?>
|
||||
<?php $conditions = json_decode($r->conditions, true) ?: []; $actions = json_decode($r->actions, true) ?: []; ?>
|
||||
<div class="card mb-2 rule-card <?php echo !$r->enabled ? 'opacity-50' : ''; ?>" data-id="<?php echo $r->id; ?>" draggable="true">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1" style="cursor:pointer;" onclick="openRuleModal(<?php echo $r->id; ?>)">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<?php endforeach; ?>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (empty($rules)): ?>
|
||||
<div class="alert alert-info">No automation rules. Click "Add Rule" to create one.</div>
|
||||
<?php endif; ?>
|
||||
<?php if (empty($rules)): ?>
|
||||
<div class="alert alert-info">No automation rules. Click "Add Rule" to create one.</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Rule Modal -->
|
||||
<div class="modal fade" id="newRuleModal" tabindex="-1">
|
||||
<!-- Rule Modal -->
|
||||
<div class="modal fade" id="ruleModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<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="mb-3">
|
||||
<label class="form-label">Title</label>
|
||||
<input type="text" id="rule-title" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Trigger</label>
|
||||
<select id="rule-trigger" class="form-select">
|
||||
<?php foreach ($triggerLabels as $k => $v): ?><option value="<?php echo $k; ?>"><?php echo $v; ?></option><?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Conditions (JSON)</label>
|
||||
<textarea id="rule-conditions" class="form-control font-monospace" rows="3" placeholder='[{"field":"status","op":"eq","value":"resolved"}]'></textarea>
|
||||
<small class="text-muted">Fields: status, priority, category_id, assigned_to, sla_responded, age_hours. Ops: eq, neq, gt, lt, in, not_in</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Actions (JSON)</label>
|
||||
<textarea id="rule-actions" class="form-control font-monospace" rows="3" placeholder='[{"type":"set_status","value":"closed"}]'></textarea>
|
||||
<small class="text-muted">Types: set_status, set_priority, assign, add_note, send_email</small>
|
||||
<input type="hidden" id="rule-id" value="0">
|
||||
<div class="row mb-3">
|
||||
<div class="col-5">
|
||||
<label class="form-label">Title</label>
|
||||
<input type="text" id="rule-title" class="form-control" required>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label">Trigger</label>
|
||||
<select id="rule-trigger" class="form-select">
|
||||
<?php foreach ($triggerLabels as $k => $v): ?><option value="<?php echo $k; ?>"><?php echo $v; ?></option><?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<label class="form-label">Behavior</label>
|
||||
<select id="rule-behavior" class="form-select">
|
||||
<option value="append">Append to existing</option>
|
||||
<option value="always_new">Always new ticket</option>
|
||||
<option value="skip_if_open">Skip if open</option>
|
||||
</select>
|
||||
</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 class="modal-footer">
|
||||
<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>
|
||||
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() {
|
||||
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();
|
||||
fd.append('id', '0');
|
||||
fd.append('id', document.getElementById('rule-id').value);
|
||||
fd.append('title', document.getElementById('rule-title').value);
|
||||
fd.append('trigger_event', document.getElementById('rule-trigger').value);
|
||||
fd.append('conditions', document.getElementById('rule-conditions').value || '[]');
|
||||
fd.append('actions', document.getElementById('rule-actions').value || '[]');
|
||||
fd.append(token, '1');
|
||||
fd.append('behavior', document.getElementById('rule-behavior').value);
|
||||
fd.append('conditions', JSON.stringify(conditions));
|
||||
fd.append('actions', JSON.stringify(actions));
|
||||
fd.append(tokenKey, '1');
|
||||
fetch('<?php echo $saveUrl; ?>', {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]}); });
|
||||
});
|
||||
|
||||
// Toggle rule
|
||||
// ── Toggle ──────────────────────────────────────────────────
|
||||
document.querySelectorAll('.rule-toggle').forEach(function(cb) {
|
||||
cb.addEventListener('change', function() {
|
||||
var fd = new FormData();
|
||||
fd.append('id', this.dataset.id);
|
||||
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'}})
|
||||
.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) {
|
||||
btn.addEventListener('click', function() {
|
||||
if (!confirm('Delete this rule?')) return;
|
||||
var card = this.closest('.card');
|
||||
var fd = new FormData();
|
||||
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'}})
|
||||
.then(function(r){return r.json()})
|
||||
.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>
|
||||
|
||||
@@ -9,43 +9,71 @@ $categories = $this->categories;
|
||||
$token = Session::getFormToken();
|
||||
$saveUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.saveCanned&format=json');
|
||||
$deleteUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.deleteCanned&format=json');
|
||||
$reorderUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.reorderCanned&format=json');
|
||||
|
||||
// Build category map for filter display
|
||||
$catMap = [0 => 'All Categories'];
|
||||
foreach ($categories as $cat)
|
||||
{
|
||||
$catMap[$cat->id] = $cat->title;
|
||||
}
|
||||
?>
|
||||
|
||||
<div id="mokosuiteclient-canned">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4><?php echo count($responses); ?> Canned Responses</h4>
|
||||
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newCannedModal">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<?php foreach ($responses as $r): ?>
|
||||
<div class="card mb-2" data-id="<?php echo $r->id; ?>">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<strong><?php echo htmlspecialchars($r->title); ?></strong>
|
||||
<p class="text-muted small mb-0 mt-1"><?php echo htmlspecialchars(mb_substr($r->body, 0, 150)); ?></p>
|
||||
<div id="canned-list">
|
||||
<?php foreach ($responses as $r): ?>
|
||||
<div class="card mb-2 canned-card" data-id="<?php echo $r->id; ?>" data-category="<?php echo (int) $r->category_id; ?>" style="cursor:grab;">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1" style="cursor:pointer;" onclick="openCannedModal(<?php echo $r->id; ?>)">
|
||||
<div 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>
|
||||
<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>
|
||||
<?php endforeach; ?>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (empty($responses)): ?>
|
||||
<div class="alert alert-info">No canned responses yet. Click "Add Response" to create one.</div>
|
||||
<?php endif; ?>
|
||||
<?php if (empty($responses)): ?>
|
||||
<div class="alert alert-info" id="canned-empty">No canned responses yet. Click "Add Response" to create one.</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Canned Modal -->
|
||||
<div class="modal fade" id="newCannedModal" tabindex="-1">
|
||||
<!-- Canned Response Modal (create + edit) -->
|
||||
<div class="modal fade" id="cannedModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<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">
|
||||
<input type="hidden" id="canned-id" value="0">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Title</label>
|
||||
<input type="text" id="canned-title" class="form-control" required>
|
||||
@@ -53,7 +81,7 @@ $deleteUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.delete
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Category (optional)</label>
|
||||
<select id="canned-category" class="form-select">
|
||||
<option value="">All categories</option>
|
||||
<option value="">No category</option>
|
||||
<?php foreach ($categories as $cat): ?>
|
||||
<option value="<?php echo $cat->id; ?>"><?php echo htmlspecialchars($cat->title); ?></option>
|
||||
<?php endforeach; ?>
|
||||
@@ -61,7 +89,7 @@ $deleteUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.delete
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<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 class="modal-footer">
|
||||
@@ -74,15 +102,46 @@ $deleteUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.delete
|
||||
|
||||
<script>
|
||||
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() {
|
||||
var title = document.getElementById('canned-title').value.trim();
|
||||
if (!title) { Joomla.renderMessages({error:['Title is required']}); return; }
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('id', '0');
|
||||
fd.append('title', document.getElementById('canned-title').value);
|
||||
fd.append('id', document.getElementById('canned-id').value);
|
||||
fd.append('title', title);
|
||||
fd.append('body', document.getElementById('canned-body').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'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){
|
||||
@@ -91,17 +150,78 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Delete ──────────────────────────────────────────────────
|
||||
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;
|
||||
var card = this.closest('.card');
|
||||
var fd = new FormData();
|
||||
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'}})
|
||||
.then(function(r){return r.json()})
|
||||
.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>
|
||||
|
||||
@@ -9,6 +9,7 @@ $users = $this->users;
|
||||
$token = Session::getFormToken();
|
||||
$saveUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.saveCategory&format=json');
|
||||
$deleteUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.deleteCategory&format=json');
|
||||
$reorderUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.reorderCategory&format=json');
|
||||
?>
|
||||
|
||||
<div id="mokosuiteclient-categories">
|
||||
@@ -22,10 +23,11 @@ $deleteUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.delete
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<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>
|
||||
<?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="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>
|
||||
@@ -122,5 +124,39 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
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>
|
||||
|
||||
@@ -8,6 +8,17 @@ use Joomla\CMS\Session\Session;
|
||||
$t = $this->ticket;
|
||||
$canned = $this->cannedResponses;
|
||||
$token = Session::getFormToken();
|
||||
$attachments = $this->attachments;
|
||||
$downloadUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.downloadAttachment');
|
||||
$uploadUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.uploadAttachment&format=json');
|
||||
$deleteAttUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.deleteAttachment&format=json');
|
||||
|
||||
// Group attachments by reply_id (null = ticket-level)
|
||||
$attByReply = [];
|
||||
foreach ($attachments as $att) {
|
||||
$key = $att->reply_id ?? 0;
|
||||
$attByReply[$key][] = $att;
|
||||
}
|
||||
|
||||
$statuses = $this->statuses ?? [];
|
||||
$priorities = $this->priorities ?? [];
|
||||
@@ -25,7 +36,21 @@ $priorities = $this->priorities ?? [];
|
||||
</div>
|
||||
<span class="badge bg-dark">Original</span>
|
||||
</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\MokoSuiteClient\Administrator\Service\AttachmentService::formatSize($att->filesize); ?>)</span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Replies -->
|
||||
@@ -40,7 +65,21 @@ $priorities = $this->priorities ?? [];
|
||||
<span class="badge bg-warning text-dark">Internal Note</span>
|
||||
<?php endif; ?>
|
||||
</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\MokoSuiteClient\Administrator\Service\AttachmentService::formatSize($att->filesize); ?>)</span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
@@ -59,6 +98,10 @@ $priorities = $this->priorities ?? [];
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<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">
|
||||
<button type="button" class="btn btn-primary" id="btn-reply"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.addTicketReply&format=json'); ?>"
|
||||
@@ -145,6 +188,45 @@ $priorities = $this->priorities ?? [];
|
||||
</div>
|
||||
<?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_mokosuiteclient&task=display.rateTicket&format=json'); ?>"
|
||||
data-ticket="<?php echo $t->id; ?>" data-token="<?php echo $token; ?>" disabled>
|
||||
Submit Rating
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Status actions -->
|
||||
<div class="card mb-3">
|
||||
<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) {
|
||||
btn.addEventListener('click', function() {
|
||||
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;
|
||||
el.disabled = true;
|
||||
var fd = new FormData();
|
||||
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(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; });
|
||||
.then(function(d){
|
||||
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; });
|
||||
});
|
||||
});
|
||||
// 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>
|
||||
|
||||
@@ -182,6 +182,26 @@ $token = Session::getFormToken();
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Assign Users</label>
|
||||
<select name="assign_users[]" class="form-select" multiple size="4">
|
||||
<?php foreach ($this->backendUsers as $u): ?>
|
||||
<option value="<?php echo $u->id; ?>"><?php echo $this->escape($u->name); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<small class="text-muted">Hold Ctrl/Cmd to select multiple</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Assign Groups</label>
|
||||
<select name="assign_groups[]" class="form-select" multiple size="4">
|
||||
<?php foreach ($this->userGroups as $g): ?>
|
||||
<option value="<?php echo $g->id; ?>"><?php echo $this->escape($g->title); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<small class="text-muted">Hold Ctrl/Cmd to select multiple</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea name="body" class="form-control" rows="6" required></textarea>
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later
|
||||
*
|
||||
* @package MokoSuiteClient
|
||||
* @subpackage Component
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$token = Session::getFormToken();
|
||||
|
||||
$colorOptions = [
|
||||
'bg-primary', 'bg-secondary', 'bg-success', 'bg-danger',
|
||||
'bg-warning text-dark', 'bg-info text-dark', 'bg-dark', 'bg-light text-dark',
|
||||
];
|
||||
?>
|
||||
|
||||
<div class="row">
|
||||
<!-- Statuses -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong><span class="fa-solid fa-circle-dot"></span> Ticket Statuses</strong>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-striped mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th class="w-10 text-center">Color</th>
|
||||
<th class="w-10 text-center">Default</th>
|
||||
<th class="w-10 text-center">Closed?</th>
|
||||
<th class="w-10 text-center">Order</th>
|
||||
<th class="w-10 text-center">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($this->statuses as $s): ?>
|
||||
<tr>
|
||||
<td><?php echo $this->escape($s->title); ?> <small class="text-muted">(<?php echo $this->escape($s->alias); ?>)</small></td>
|
||||
<td class="text-center"><span class="badge <?php echo $this->escape($s->color); ?>"> </span></td>
|
||||
<td class="text-center"><?php echo $s->is_default ? '<span class="badge bg-success">Yes</span>' : ''; ?></td>
|
||||
<td class="text-center"><?php echo $s->is_closed ? '<span class="badge bg-dark">Closed</span>' : ''; ?></td>
|
||||
<td class="text-center"><?php echo (int) $s->ordering; ?></td>
|
||||
<td class="text-center">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="editStatus(<?php echo htmlspecialchars(json_encode($s)); ?>)">
|
||||
<span class="icon-pencil"></span>
|
||||
</button>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.deleteStatus&id=' . $s->id . '&' . $token . '=1'); ?>"
|
||||
class="btn btn-sm btn-outline-danger"
|
||||
onclick="return confirm('Delete this status?')">
|
||||
<span class="icon-trash"></span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<form method="post" action="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.saveStatus'); ?>" id="statusForm" class="row g-2 align-items-end">
|
||||
<input type="hidden" name="id" id="status-id" value="0">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Title</label>
|
||||
<input type="text" name="title" id="status-title" class="form-control form-control-sm" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Alias</label>
|
||||
<input type="text" name="alias" id="status-alias" class="form-control form-control-sm">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Color</label>
|
||||
<select name="color" id="status-color" class="form-select form-select-sm">
|
||||
<?php foreach ($colorOptions as $c): ?>
|
||||
<option value="<?php echo $c; ?>"><?php echo $c; ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Order</label>
|
||||
<input type="number" name="ordering" id="status-ordering" class="form-control form-control-sm" value="0">
|
||||
</div>
|
||||
<div class="col-md-1 text-center">
|
||||
<label class="form-label small">Default</label>
|
||||
<input type="checkbox" name="is_default" id="status-default" value="1" class="form-check-input">
|
||||
</div>
|
||||
<div class="col-md-1 text-center">
|
||||
<label class="form-label small">Closed</label>
|
||||
<input type="checkbox" name="is_closed" id="status-closed" value="1" class="form-check-input">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="hidden" name="<?php echo $token; ?>" value="1">
|
||||
<button type="submit" class="btn btn-sm btn-primary w-100" id="status-btn">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Priorities -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong><span class="fa-solid fa-flag"></span> Ticket Priorities</strong>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-striped mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th class="w-10 text-center">Color</th>
|
||||
<th class="w-10 text-center">Default</th>
|
||||
<th class="w-10 text-center">Order</th>
|
||||
<th class="w-10 text-center">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($this->priorities as $p): ?>
|
||||
<tr>
|
||||
<td><?php echo $this->escape($p->title); ?> <small class="text-muted">(<?php echo $this->escape($p->alias); ?>)</small></td>
|
||||
<td class="text-center"><span class="badge <?php echo $this->escape($p->color); ?>"> </span></td>
|
||||
<td class="text-center"><?php echo $p->is_default ? '<span class="badge bg-success">Yes</span>' : ''; ?></td>
|
||||
<td class="text-center"><?php echo (int) $p->ordering; ?></td>
|
||||
<td class="text-center">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="editPriority(<?php echo htmlspecialchars(json_encode($p)); ?>)">
|
||||
<span class="icon-pencil"></span>
|
||||
</button>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.deletePriority&id=' . $p->id . '&' . $token . '=1'); ?>"
|
||||
class="btn btn-sm btn-outline-danger"
|
||||
onclick="return confirm('Delete this priority?')">
|
||||
<span class="icon-trash"></span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<form method="post" action="<?php echo Route::_('index.php?option=com_mokosuiteclient&task=display.savePriority'); ?>" id="priorityForm" class="row g-2 align-items-end">
|
||||
<input type="hidden" name="id" id="priority-id" value="0">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Title</label>
|
||||
<input type="text" name="title" id="priority-title" class="form-control form-control-sm" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Alias</label>
|
||||
<input type="text" name="alias" id="priority-alias" class="form-control form-control-sm">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Color</label>
|
||||
<select name="color" id="priority-color" class="form-select form-select-sm">
|
||||
<?php foreach ($colorOptions as $c): ?>
|
||||
<option value="<?php echo $c; ?>"><?php echo $c; ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Order</label>
|
||||
<input type="number" name="ordering" id="priority-ordering" class="form-control form-control-sm" value="0">
|
||||
</div>
|
||||
<div class="col-md-1 text-center">
|
||||
<label class="form-label small">Default</label>
|
||||
<input type="checkbox" name="is_default" id="priority-default" value="1" class="form-check-input">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<input type="hidden" name="<?php echo $token; ?>" value="1">
|
||||
<button type="submit" class="btn btn-sm btn-primary w-100" id="priority-btn">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function editStatus(s) {
|
||||
document.getElementById('status-id').value = s.id;
|
||||
document.getElementById('status-title').value = s.title;
|
||||
document.getElementById('status-alias').value = s.alias;
|
||||
document.getElementById('status-color').value = s.color;
|
||||
document.getElementById('status-ordering').value = s.ordering;
|
||||
document.getElementById('status-default').checked = !!parseInt(s.is_default);
|
||||
document.getElementById('status-closed').checked = !!parseInt(s.is_closed);
|
||||
document.getElementById('status-btn').textContent = 'Update';
|
||||
}
|
||||
function editPriority(p) {
|
||||
document.getElementById('priority-id').value = p.id;
|
||||
document.getElementById('priority-title').value = p.title;
|
||||
document.getElementById('priority-alias').value = p.alias;
|
||||
document.getElementById('priority-color').value = p.color;
|
||||
document.getElementById('priority-ordering').value = p.ordering;
|
||||
document.getElementById('priority-default').checked = !!parseInt(p.is_default);
|
||||
document.getElementById('priority-btn').textContent = 'Update';
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -0,0 +1,313 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteClient
|
||||
* @subpackage com_mokosuiteclient
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuiteClient\Api\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
|
||||
/**
|
||||
* Helpdesk Tickets REST API controller.
|
||||
*
|
||||
* GET /api/index.php/v1/mokosuiteclient/tickets - list tickets
|
||||
* GET /api/index.php/v1/mokosuiteclient/tickets/{id} - get single ticket with replies
|
||||
* POST /api/index.php/v1/mokosuiteclient/tickets - create ticket
|
||||
* PATCH /api/index.php/v1/mokosuiteclient/tickets/{id} - update ticket fields
|
||||
* POST /api/index.php/v1/mokosuiteclient/tickets/{id}/reply - add reply
|
||||
*
|
||||
* @since 02.35.00
|
||||
*/
|
||||
class TicketsController extends BaseController
|
||||
{
|
||||
/**
|
||||
* GET /tickets — list tickets with optional filters.
|
||||
*/
|
||||
public function displayList(): void
|
||||
{
|
||||
$this->requireAuth('core.manage', 'com_mokosuiteclient');
|
||||
|
||||
$app = Factory::getApplication();
|
||||
$db = Factory::getDbo();
|
||||
$input = $app->getInput();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select('t.*, s.title AS status_title, p.title AS priority_title, c.title AS category_title, u.name AS created_by_name')
|
||||
->from($db->quoteName('#__mokosuiteclient_tickets', 't'))
|
||||
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_statuses', 's') . ' ON s.id = t.status_id')
|
||||
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_priorities', 'p') . ' ON p.id = t.priority_id')
|
||||
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_categories', 'c') . ' ON c.id = t.category_id')
|
||||
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
|
||||
->order('t.created DESC');
|
||||
|
||||
// Filters
|
||||
$status = $input->getString('status', '');
|
||||
if ($status) {
|
||||
$query->where($db->quoteName('t.status') . ' = ' . $db->quote($status));
|
||||
}
|
||||
|
||||
$categoryId = $input->getInt('category_id', 0);
|
||||
if ($categoryId) {
|
||||
$query->where($db->quoteName('t.category_id') . ' = ' . $categoryId);
|
||||
}
|
||||
|
||||
$assignedTo = $input->getInt('assigned_to', 0);
|
||||
if ($assignedTo) {
|
||||
$query->where($db->quoteName('t.assigned_to') . ' = ' . $assignedTo);
|
||||
}
|
||||
|
||||
$limit = min($input->getInt('limit', 25), 100);
|
||||
$offset = $input->getInt('offset', 0);
|
||||
$db->setQuery($query, $offset, $limit);
|
||||
|
||||
$tickets = $db->loadObjectList() ?: [];
|
||||
|
||||
// Total count (with same filters applied)
|
||||
$countQuery = clone $query;
|
||||
$countQuery->clear('select')->clear('order')->select('COUNT(*)');
|
||||
$db->setQuery($countQuery);
|
||||
$total = (int) $db->loadResult();
|
||||
|
||||
$this->sendJson(200, [
|
||||
'tickets' => $tickets,
|
||||
'total' => $total,
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /tickets/{id} — single ticket with replies and attachments.
|
||||
*/
|
||||
public function displayItem(): void
|
||||
{
|
||||
$this->requireAuth('core.manage', 'com_mokosuiteclient');
|
||||
|
||||
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Ticket
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('t.*, s.title AS status_title, p.title AS priority_title, c.title AS category_title, u.name AS created_by_name')
|
||||
->from($db->quoteName('#__mokosuiteclient_tickets', 't'))
|
||||
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_statuses', 's') . ' ON s.id = t.status_id')
|
||||
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_priorities', 'p') . ' ON p.id = t.priority_id')
|
||||
->leftJoin($db->quoteName('#__mokosuiteclient_ticket_categories', 'c') . ' ON c.id = t.category_id')
|
||||
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
|
||||
->where('t.id = ' . $id)
|
||||
);
|
||||
$ticket = $db->loadObject();
|
||||
|
||||
if (!$ticket) {
|
||||
$this->sendJson(404, ['error' => 'Ticket not found']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Replies
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('r.*, u.name AS user_name')
|
||||
->from($db->quoteName('#__mokosuiteclient_ticket_replies', 'r'))
|
||||
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id')
|
||||
->where('r.ticket_id = ' . $id)
|
||||
->order('r.created ASC')
|
||||
);
|
||||
$ticket->replies = $db->loadObjectList() ?: [];
|
||||
|
||||
// Attachments
|
||||
$ticket->attachments = \Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService::getForTicket($id);
|
||||
|
||||
$this->sendJson(200, $ticket);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /tickets — create a new ticket.
|
||||
*/
|
||||
public function create(): void
|
||||
{
|
||||
$this->requireAuth('core.manage', 'com_mokosuiteclient');
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$subject = $input->getString('subject', '');
|
||||
$body = $input->getRaw('body', '');
|
||||
|
||||
if (empty($subject)) {
|
||||
$this->sendJson(400, ['error' => 'Subject is required']);
|
||||
return;
|
||||
}
|
||||
|
||||
$statusId = $input->getInt('status_id', 0) ?: null;
|
||||
$priorityId = $input->getInt('priority_id', 0) ?: null;
|
||||
$status = $input->getString('status', 'open');
|
||||
$priority = $input->getString('priority', 'normal');
|
||||
|
||||
// Resolve status_id from alias if not provided
|
||||
if (!$statusId && $status) {
|
||||
$q = $db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_statuses')
|
||||
->where($db->quoteName('alias') . ' = ' . $db->quote($status));
|
||||
$statusId = (int) $db->setQuery($q, 0, 1)->loadResult() ?: null;
|
||||
}
|
||||
if (!$priorityId && $priority) {
|
||||
$q = $db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_priorities')
|
||||
->where($db->quoteName('alias') . ' = ' . $db->quote($priority));
|
||||
$priorityId = (int) $db->setQuery($q, 0, 1)->loadResult() ?: null;
|
||||
}
|
||||
|
||||
$ticket = (object) [
|
||||
'subject' => $subject,
|
||||
'body' => $body,
|
||||
'status' => $status,
|
||||
'status_id' => $statusId,
|
||||
'priority' => $priority,
|
||||
'priority_id' => $priorityId,
|
||||
'category_id' => $input->getInt('category_id', 0) ?: null,
|
||||
'created_by' => (int) Factory::getUser()->id,
|
||||
'assigned_to' => $input->getInt('assigned_to', 0) ?: null,
|
||||
'created' => Factory::getDate()->toSql(),
|
||||
];
|
||||
|
||||
$db->insertObject('#__mokosuiteclient_tickets', $ticket, 'id');
|
||||
|
||||
// Trigger notification
|
||||
\Moko\Component\MokoSuiteClient\Administrator\Service\NotificationService::notify('ticket_created', $ticket);
|
||||
|
||||
$this->sendJson(201, ['id' => (int) $ticket->id, 'message' => 'Ticket created']);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /tickets/{id} — update ticket fields.
|
||||
*/
|
||||
public function update(): void
|
||||
{
|
||||
$this->requireAuth('core.manage', 'com_mokosuiteclient');
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$id = $input->getInt('id', 0);
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Type-safe input extraction
|
||||
$fields = [];
|
||||
$intFields = ['status_id', 'priority_id', 'category_id', 'assigned_to'];
|
||||
$strFields = ['status', 'priority'];
|
||||
|
||||
foreach ($intFields as $field) {
|
||||
$value = $input->getInt($field, 0);
|
||||
if ($value > 0) { $fields[$field] = $value; }
|
||||
}
|
||||
foreach ($strFields as $field) {
|
||||
$value = $input->getString($field, '');
|
||||
if ($value !== '') { $fields[$field] = $value; }
|
||||
}
|
||||
|
||||
if (empty($fields)) {
|
||||
$this->sendJson(400, ['error' => 'No fields to update']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync status/status_id if only one is provided
|
||||
if (isset($fields['status']) && !isset($fields['status_id'])) {
|
||||
$q = $db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_statuses')
|
||||
->where($db->quoteName('alias') . ' = ' . $db->quote($fields['status']));
|
||||
$resolved = (int) $db->setQuery($q, 0, 1)->loadResult();
|
||||
if ($resolved) { $fields['status_id'] = $resolved; }
|
||||
} elseif (isset($fields['status_id']) && !isset($fields['status'])) {
|
||||
$q = $db->getQuery(true)->select('alias')->from('#__mokosuiteclient_ticket_statuses')
|
||||
->where('id = ' . (int) $fields['status_id']);
|
||||
$alias = $db->setQuery($q, 0, 1)->loadResult();
|
||||
if ($alias) { $fields['status'] = $alias; }
|
||||
}
|
||||
if (isset($fields['priority']) && !isset($fields['priority_id'])) {
|
||||
$q = $db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_priorities')
|
||||
->where($db->quoteName('alias') . ' = ' . $db->quote($fields['priority']));
|
||||
$resolved = (int) $db->setQuery($q, 0, 1)->loadResult();
|
||||
if ($resolved) { $fields['priority_id'] = $resolved; }
|
||||
} elseif (isset($fields['priority_id']) && !isset($fields['priority'])) {
|
||||
$q = $db->getQuery(true)->select('alias')->from('#__mokosuiteclient_ticket_priorities')
|
||||
->where('id = ' . (int) $fields['priority_id']);
|
||||
$alias = $db->setQuery($q, 0, 1)->loadResult();
|
||||
if ($alias) { $fields['priority'] = $alias; }
|
||||
}
|
||||
|
||||
$sets = [];
|
||||
foreach ($fields as $k => $v) {
|
||||
$sets[] = $db->quoteName($k) . ' = ' . (is_int($v) ? $v : $db->quote($v));
|
||||
}
|
||||
$sets[] = 'modified = ' . $db->quote(Factory::getDate()->toSql());
|
||||
|
||||
$db->setQuery('UPDATE ' . $db->quoteName('#__mokosuiteclient_tickets') . ' SET ' . implode(', ', $sets) . ' WHERE id = ' . $id)->execute();
|
||||
|
||||
if ($db->getAffectedRows() === 0) {
|
||||
$this->sendJson(404, ['error' => 'Ticket not found']);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sendJson(200, ['id' => $id, 'message' => 'Ticket updated', 'updated' => array_keys($fields)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /tickets/{id}/reply — add a reply.
|
||||
*/
|
||||
public function reply(): void
|
||||
{
|
||||
$this->requireAuth('core.manage', 'com_mokosuiteclient');
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$ticketId = $input->getInt('id', 0);
|
||||
$body = $input->getRaw('body', '');
|
||||
|
||||
if (!$ticketId || empty($body)) {
|
||||
$this->sendJson(400, ['error' => 'ticket_id and body are required']);
|
||||
return;
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$reply = (object) [
|
||||
'ticket_id' => $ticketId,
|
||||
'user_id' => (int) Factory::getUser()->id,
|
||||
'body' => $body,
|
||||
'is_internal' => $input->getInt('is_internal', 0),
|
||||
'created' => Factory::getDate()->toSql(),
|
||||
];
|
||||
|
||||
$db->insertObject('#__mokosuiteclient_ticket_replies', $reply, 'id');
|
||||
|
||||
// Notify
|
||||
$db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuiteclient_tickets')->where('id = ' . $ticketId));
|
||||
$ticket = $db->loadObject();
|
||||
if ($ticket) {
|
||||
\Moko\Component\MokoSuiteClient\Administrator\Service\NotificationService::notify('ticket_replied', $ticket, ['reply_body' => $body]);
|
||||
}
|
||||
|
||||
$this->sendJson(201, ['reply_id' => (int) $reply->id, 'message' => 'Reply added']);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
private function requireAuth(string $action, string $asset): void
|
||||
{
|
||||
$user = Factory::getUser();
|
||||
if (!$user->authorise($action, $asset)) {
|
||||
$this->sendJson(403, ['error' => 'Not authorized']);
|
||||
throw new \RuntimeException('Not authorized', 403);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
<?php
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later
|
||||
*
|
||||
* @package MokoSuiteClient
|
||||
* @subpackage API
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoSuiteClient\Api\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Plugin\PluginHelper;
|
||||
use Joomla\CMS\User\UserHelper;
|
||||
use Joomla\Registry\Registry;
|
||||
|
||||
/**
|
||||
* Remote user management API controller.
|
||||
*
|
||||
* All endpoints require the MokoSuiteClient health API token via
|
||||
* Authorization: Bearer header. Actions are performed on behalf
|
||||
* of MokoSuiteClientHQ for security incident response and maintenance.
|
||||
*
|
||||
* @since 02.35.00
|
||||
*/
|
||||
class UsersController extends BaseController
|
||||
{
|
||||
/**
|
||||
* Route to the appropriate action based on task.
|
||||
*/
|
||||
public function execute($task = 'export'): void
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
$method = $app->input->getMethod();
|
||||
|
||||
// Authenticate via health API token
|
||||
if (!$this->authenticateToken())
|
||||
{
|
||||
$this->sendJson(401, ['error' => 'Invalid or missing token']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the master usernames to protect them
|
||||
$this->masterUsernames = $this->getMasterUsernames();
|
||||
|
||||
match ($task)
|
||||
{
|
||||
'resetPasswords' => $this->resetPasswords(),
|
||||
'reset2fa' => $this->reset2fa(),
|
||||
'disableAll' => $this->disableAll(),
|
||||
'enableAll' => $this->enableAll(),
|
||||
'forceLogout' => $this->forceLogout(),
|
||||
'export' => $this->exportUsers(),
|
||||
default => $this->sendJson(400, ['error' => 'Unknown action: ' . $task]),
|
||||
};
|
||||
}
|
||||
|
||||
/** @var array Master usernames that should be protected from mass actions */
|
||||
private array $masterUsernames = [];
|
||||
|
||||
/**
|
||||
* Reset all user passwords and force change on next login.
|
||||
* Excludes master user accounts.
|
||||
*
|
||||
* POST /api/index.php/v1/mokosuiteclient/users/reset-passwords
|
||||
*/
|
||||
private function resetPasswords(): void
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$now = Factory::getDate()->toSql();
|
||||
$count = 0;
|
||||
|
||||
$users = $this->getNonMasterUsers();
|
||||
|
||||
foreach ($users as $user)
|
||||
{
|
||||
// Generate a random password
|
||||
$newPassword = UserHelper::genRandomPassword(16);
|
||||
$hashed = UserHelper::hashPassword($newPassword);
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__users'))
|
||||
->set($db->quoteName('password') . ' = ' . $db->quote($hashed))
|
||||
->set($db->quoteName('requireReset') . ' = 1')
|
||||
->set($db->quoteName('lastResetTime') . ' = ' . $db->quote($now))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $user->id)
|
||||
)->execute();
|
||||
|
||||
$count++;
|
||||
}
|
||||
|
||||
$this->sendJson(200, [
|
||||
'status' => 'ok',
|
||||
'action' => 'reset_passwords',
|
||||
'count' => $count,
|
||||
'message' => sprintf('%d user password(s) reset. Users must set a new password on next login.', $count),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset/disable 2FA (OTP) for all non-master users.
|
||||
*
|
||||
* POST /api/index.php/v1/mokosuiteclient/users/reset-2fa
|
||||
*/
|
||||
private function reset2fa(): void
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$count = 0;
|
||||
|
||||
$users = $this->getNonMasterUsers();
|
||||
$userIds = array_map(fn($u) => (int) $u->id, $users);
|
||||
|
||||
if (!empty($userIds))
|
||||
{
|
||||
// Remove OTP config from user profiles
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete($db->quoteName('#__user_profiles'))
|
||||
->where($db->quoteName('user_id') . ' IN (' . implode(',', $userIds) . ')')
|
||||
->where($db->quoteName('profile_key') . ' LIKE ' . $db->quote('joomlatoken.%'))
|
||||
)->execute();
|
||||
$count += $db->getAffectedRows();
|
||||
|
||||
// Also clear any MFA/2FA records
|
||||
try
|
||||
{
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete($db->quoteName('#__user_mfa'))
|
||||
->where($db->quoteName('user_id') . ' IN (' . implode(',', $userIds) . ')')
|
||||
)->execute();
|
||||
$count += $db->getAffectedRows();
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
// Table may not exist on older Joomla versions
|
||||
}
|
||||
}
|
||||
|
||||
$this->sendJson(200, [
|
||||
'status' => 'ok',
|
||||
'action' => 'reset_2fa',
|
||||
'count' => $count,
|
||||
'message' => sprintf('2FA/MFA disabled for %d user(s).', \count($userIds)),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable (block) all user accounts except master users.
|
||||
*
|
||||
* POST /api/index.php/v1/mokosuiteclient/users/disable-all
|
||||
*/
|
||||
private function disableAll(): void
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$masterIds = $this->getMasterUserIds();
|
||||
$where = !empty($masterIds)
|
||||
? $db->quoteName('id') . ' NOT IN (' . implode(',', $masterIds) . ')'
|
||||
: '1=1';
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__users'))
|
||||
->set($db->quoteName('block') . ' = 1')
|
||||
->where($where)
|
||||
->where($db->quoteName('block') . ' = 0')
|
||||
)->execute();
|
||||
|
||||
$count = $db->getAffectedRows();
|
||||
|
||||
$this->sendJson(200, [
|
||||
'status' => 'ok',
|
||||
'action' => 'disable_all',
|
||||
'count' => $count,
|
||||
'message' => sprintf('%d user account(s) disabled. Master users preserved.', $count),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-enable (unblock) all user accounts.
|
||||
*
|
||||
* POST /api/index.php/v1/mokosuiteclient/users/enable-all
|
||||
*/
|
||||
private function enableAll(): void
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__users'))
|
||||
->set($db->quoteName('block') . ' = 0')
|
||||
->where($db->quoteName('block') . ' = 1')
|
||||
)->execute();
|
||||
|
||||
$count = $db->getAffectedRows();
|
||||
|
||||
$this->sendJson(200, [
|
||||
'status' => 'ok',
|
||||
'action' => 'enable_all',
|
||||
'count' => $count,
|
||||
'message' => sprintf('%d user account(s) re-enabled.', $count),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force logout all active sessions.
|
||||
*
|
||||
* POST /api/index.php/v1/mokosuiteclient/users/force-logout
|
||||
*/
|
||||
private function forceLogout(): void
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Clear all sessions except the current API session
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete($db->quoteName('#__session'))
|
||||
->where($db->quoteName('client_id') . ' IN (0, 1)')
|
||||
)->execute();
|
||||
|
||||
$count = $db->getAffectedRows();
|
||||
|
||||
$this->sendJson(200, [
|
||||
'status' => 'ok',
|
||||
'action' => 'force_logout',
|
||||
'count' => $count,
|
||||
'message' => sprintf('%d active session(s) terminated.', $count),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export user list with basic info.
|
||||
*
|
||||
* GET /api/index.php/v1/mokosuiteclient/users/export
|
||||
*/
|
||||
private function exportUsers(): void
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('u.id'),
|
||||
$db->quoteName('u.name'),
|
||||
$db->quoteName('u.username'),
|
||||
$db->quoteName('u.email'),
|
||||
$db->quoteName('u.block'),
|
||||
$db->quoteName('u.lastvisitDate'),
|
||||
$db->quoteName('u.registerDate'),
|
||||
])
|
||||
->from($db->quoteName('#__users', 'u'))
|
||||
->order($db->quoteName('u.name') . ' ASC')
|
||||
);
|
||||
|
||||
$users = $db->loadObjectList() ?: [];
|
||||
|
||||
// Add group names for each user
|
||||
foreach ($users as $user)
|
||||
{
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select($db->quoteName('g.title'))
|
||||
->from($db->quoteName('#__usergroups', 'g'))
|
||||
->join('INNER', $db->quoteName('#__user_usergroup_map', 'm') . ' ON m.group_id = g.id')
|
||||
->where($db->quoteName('m.user_id') . ' = ' . (int) $user->id)
|
||||
);
|
||||
|
||||
$user->groups = $db->loadColumn() ?: [];
|
||||
}
|
||||
|
||||
$this->sendJson(200, [
|
||||
'status' => 'ok',
|
||||
'action' => 'export',
|
||||
'count' => \count($users),
|
||||
'users' => $users,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Authenticate the request using the health API token.
|
||||
*/
|
||||
private function authenticateToken(): bool
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
$auth = $app->input->server->get('HTTP_AUTHORIZATION', '', 'STRING');
|
||||
$token = '';
|
||||
|
||||
if (str_starts_with($auth, 'Bearer '))
|
||||
{
|
||||
$token = substr($auth, 7);
|
||||
}
|
||||
|
||||
if (empty($token))
|
||||
{
|
||||
// Also check JSON body for backwards compatibility
|
||||
$token = $app->input->json->get('token', '', 'RAW');
|
||||
}
|
||||
|
||||
if (empty($token))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
$plugin = PluginHelper::getPlugin('system', 'mokosuiteclient');
|
||||
|
||||
if (!$plugin)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
$params = new Registry($plugin->params);
|
||||
$healthToken = $params->get('health_api_token', '');
|
||||
|
||||
return !empty($healthToken) && hash_equals($healthToken, $token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get master usernames from the MokoSuiteClient plugin config.
|
||||
*/
|
||||
private function getMasterUsernames(): array
|
||||
{
|
||||
$helperFile = JPATH_PLUGINS . '/system/mokosuiteclient/Helper/MokoSuiteClientHelper.php';
|
||||
|
||||
if (file_exists($helperFile))
|
||||
{
|
||||
require_once $helperFile;
|
||||
|
||||
if (method_exists(\Moko\Plugin\System\MokoSuiteClient\Helper\MokoSuiteClientHelper::class, 'getMasterUsernames'))
|
||||
{
|
||||
return \Moko\Plugin\System\MokoSuiteClient\Helper\MokoSuiteClientHelper::getMasterUsernames();
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user IDs of master users.
|
||||
*/
|
||||
private function getMasterUserIds(): array
|
||||
{
|
||||
if (empty($this->masterUsernames))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$quoted = array_map([$db, 'quote'], $this->masterUsernames);
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select($db->quoteName('id'))
|
||||
->from($db->quoteName('#__users'))
|
||||
->where($db->quoteName('username') . ' IN (' . implode(',', $quoted) . ')')
|
||||
);
|
||||
|
||||
return array_map('intval', $db->loadColumn() ?: []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all non-master user records.
|
||||
*/
|
||||
private function getNonMasterUsers(): array
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select([$db->quoteName('id'), $db->quoteName('username'), $db->quoteName('email')])
|
||||
->from($db->quoteName('#__users'));
|
||||
|
||||
$masterIds = $this->getMasterUserIds();
|
||||
|
||||
if (!empty($masterIds))
|
||||
{
|
||||
$query->where($db->quoteName('id') . ' NOT IN (' . implode(',', $masterIds) . ')');
|
||||
}
|
||||
|
||||
return $db->setQuery($query)->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Send JSON response and terminate.
|
||||
*/
|
||||
private function sendJson(int $code, array $data): void
|
||||
{
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($data, JSON_UNESCAPED_SLASHES);
|
||||
Factory::getApplication()->close();
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.35.00</version>
|
||||
<version>02.41.00</version>
|
||||
<description>MokoSuiteClient admin dashboard and REST API. Provides a control panel for managing MokoSuiteClient feature plugins, site health monitoring, and remote management endpoints.</description>
|
||||
|
||||
<namespace path="src">Moko\Component\MokoSuiteClient</namespace>
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.35.00</version>
|
||||
<version>02.41.00</version>
|
||||
<description>MOD_MOKOSUITECLIENT_CACHE_DESC</description>
|
||||
<namespace path="src">Moko\Module\MokoSuiteClientCache</namespace>
|
||||
<namespace path="src">Moko\Module\MokoSuiteCache</namespace>
|
||||
|
||||
<files>
|
||||
<folder module="mod_mokosuiteclient_cache">services</folder>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.35.00</version>
|
||||
<version>02.41.00</version>
|
||||
<description>MOD_MOKOSUITECLIENT_CATEGORIES_DESC</description>
|
||||
<namespace path="src">Moko\Module\MokoSuiteClientCategories</namespace>
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.35.00</version>
|
||||
<version>02.41.00</version>
|
||||
<description>MOD_MOKOSUITECLIENT_CPANEL_DESC</description>
|
||||
<namespace path="src">Moko\Module\MokoSuiteClientCpanel</namespace>
|
||||
<namespace path="src">Moko\Module\MokoSuiteCpanel</namespace>
|
||||
|
||||
<files>
|
||||
<folder module="mod_mokosuiteclient_cpanel">services</folder>
|
||||
|
||||
@@ -22,7 +22,7 @@ $healthOk = $healthOk ?? true;
|
||||
$counts = $counts ?? (object) ['articles' => 0, 'users' => 0, 'extensions' => 0, 'updates' => 0];
|
||||
$disk = $disk ?? (object) ['free_mb' => null, 'total_mb' => null];
|
||||
$currentIp = $currentIp ?? '';
|
||||
$collapsed = $params->get('collapsed', 1);
|
||||
$collapsed = $params->get('collapsed', 0);
|
||||
$showHealth = $params->get('show_health', 1);
|
||||
$showStats = $params->get('show_stats', 1);
|
||||
$showDisk = $params->get('show_disk', 1);
|
||||
@@ -48,7 +48,6 @@ $labels = [
|
||||
'mokosuiteclient_firewall' => 'Firewall',
|
||||
'mokosuiteclient_tenant' => 'Tenant',
|
||||
'mokosuiteclient_devtools' => 'DevTools',
|
||||
'mokosuiteclient_monitor' => 'Monitor',
|
||||
];
|
||||
|
||||
$diskPct = ($disk->total_mb && $disk->total_mb > 0)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.35.00</version>
|
||||
<version>02.41.00</version>
|
||||
<description>MokoSuiteClient admin sidebar menu — renders a dedicated MokoSuiteClient section in the admin menu before Joomla's default menu.</description>
|
||||
<namespace path="src">Moko\Module\MokoSuiteClientMenu</namespace>
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
/**
|
||||
* MokoSuiteClient Admin Sidebar Menu
|
||||
*
|
||||
* Renders MokoSuiteClient static views first, then auto-discovers installed
|
||||
* Moko components from #__menu and renders their submenu items as
|
||||
* nested MetisMenu collapsible sections.
|
||||
* Each installed Moko component gets its own top-level collapsible section.
|
||||
* com_mokosuiteclienthq is always pinned first. com_mokosuiteclient uses static views
|
||||
* as children. All other components auto-discover their submenu items.
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
@@ -17,8 +17,8 @@ $app = Factory::getApplication();
|
||||
$currentOption = $app->getInput()->get('option', '');
|
||||
$currentView = $app->getInput()->get('view', '');
|
||||
|
||||
// ── Static MokoSuiteClient views ────────────────────────────────────────────
|
||||
$mokosuiteclientItems = [
|
||||
// ── Static views for com_mokosuiteclient ──────────────────────────────────
|
||||
$mokosuiteclientStaticViews = [
|
||||
['icon' => 'icon-cogs', 'title' => 'Dashboard', 'link' => 'index.php?option=com_mokosuiteclient'],
|
||||
['icon' => 'fa-solid fa-handshake-angle', 'title' => 'Helpdesk', 'link' => 'index.php?option=com_mokosuiteclient&view=tickets'],
|
||||
['icon' => 'icon-puzzle-piece', 'title' => 'Extensions', 'link' => 'index.php?option=com_mokosuiteclient&view=extensions'],
|
||||
@@ -30,27 +30,25 @@ $mokosuiteclientItems = [
|
||||
['icon' => 'icon-power-off', 'title' => 'Feature Plugins', 'link' => 'index.php?option=com_plugins&filter[folder]=system&filter[search]=mokosuiteclient'],
|
||||
];
|
||||
|
||||
// ── Auto-discover Moko component menus from #__menu ──────────────────
|
||||
// ── Auto-discover all Moko components from #__menu ──────────────────
|
||||
$mokoComponents = [];
|
||||
|
||||
try
|
||||
{
|
||||
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
||||
|
||||
// Find all Moko component menu items (exclude com_mokosuiteclient — handled above)
|
||||
$db->setQuery(
|
||||
"SELECT m.id, m.title, m.link, m.level, m.parent_id, m.img, e.element"
|
||||
. " FROM " . $db->quoteName('#__menu') . " m"
|
||||
. " LEFT JOIN " . $db->quoteName('#__extensions') . " e ON m.component_id = e.extension_id"
|
||||
. " WHERE m.client_id = 1 AND m.level >= 1 AND m.published = 1"
|
||||
. " AND e.element LIKE 'com_moko%'"
|
||||
. " AND e.element != 'com_mokosuiteclient'"
|
||||
. " AND e.enabled = 1"
|
||||
. " ORDER BY e.element, m.level, m.lft"
|
||||
);
|
||||
$menuItems = $db->loadObjectList() ?: [];
|
||||
|
||||
// Load sys.ini language files for discovered components
|
||||
// Load language files for discovered components
|
||||
$lang = Factory::getLanguage();
|
||||
$loadedLangs = [];
|
||||
foreach ($menuItems as $m)
|
||||
@@ -92,100 +90,112 @@ catch (\Throwable $e)
|
||||
// Silent — menu works without auto-discovered components
|
||||
}
|
||||
|
||||
// ── Determine active state ───────────────────────────────────────────
|
||||
$mokosuiteclientActive = ($currentOption === 'com_mokosuiteclient');
|
||||
$anyMokoActive = $mokosuiteclientActive;
|
||||
|
||||
foreach ($mokoComponents as $comp)
|
||||
// Override com_mokosuiteclient children with static views
|
||||
if (isset($mokoComponents['com_mokosuiteclient']))
|
||||
{
|
||||
$parsed = [];
|
||||
parse_str(parse_url($comp['link'], PHP_URL_QUERY) ?? '', $parsed);
|
||||
if (($parsed['option'] ?? '') === $currentOption)
|
||||
$mokoComponents['com_mokosuiteclient']['children'] = $mokosuiteclientStaticViews;
|
||||
$mokoComponents['com_mokosuiteclient']['icon'] = 'icon-shield-alt';
|
||||
}
|
||||
else
|
||||
{
|
||||
// com_mokosuiteclient not in admin menu — add it manually
|
||||
$mokoComponents['com_mokosuiteclient'] = [
|
||||
'id' => 0,
|
||||
'title' => 'MokoSuiteClient',
|
||||
'link' => 'index.php?option=com_mokosuiteclient',
|
||||
'icon' => 'icon-shield-alt',
|
||||
'element' => 'com_mokosuiteclient',
|
||||
'children' => $mokosuiteclientStaticViews,
|
||||
];
|
||||
}
|
||||
|
||||
// ── Sort: com_mokosuiteclienthq first, then alphabetical by title ─────────
|
||||
$hq = null;
|
||||
$rest = [];
|
||||
|
||||
foreach ($mokoComponents as $key => $comp)
|
||||
{
|
||||
if ($key === 'com_mokosuiteclienthq')
|
||||
{
|
||||
$anyMokoActive = true;
|
||||
$hq = $comp;
|
||||
}
|
||||
else
|
||||
{
|
||||
$rest[$key] = $comp;
|
||||
}
|
||||
}
|
||||
|
||||
$topClass = 'item parent item-level-1' . ($anyMokoActive ? ' mm-active' : '');
|
||||
$topCollapse = 'collapse-level-1 mm-collapse' . ($anyMokoActive ? ' mm-show' : '');
|
||||
usort($rest, fn($a, $b) => strcasecmp($a['title'], $b['title']));
|
||||
|
||||
$sorted = [];
|
||||
if ($hq !== null)
|
||||
{
|
||||
$sorted[] = $hq;
|
||||
}
|
||||
foreach ($rest as $comp)
|
||||
{
|
||||
$sorted[] = $comp;
|
||||
}
|
||||
?>
|
||||
|
||||
<style>
|
||||
.sidebar-wrapper .item-level-1 > a { padding-inline-start: 1.5rem; }
|
||||
.sidebar-wrapper .mokosuiteclient-menu-item > a { padding-inline-start: 2rem; }
|
||||
.sidebar-wrapper .mokosuiteclient-menu-child > a { padding-inline-start: 2.5rem; }
|
||||
.sidebar-wrapper .mokosuiteclient-ext-item > a { padding-inline-start: 1.5rem; }
|
||||
.sidebar-wrapper .mokosuiteclient-ext-child > a { padding-inline-start: 2.5rem; }
|
||||
</style>
|
||||
|
||||
<ul class="nav flex-column main-nav">
|
||||
<li class="<?php echo $topClass; ?>">
|
||||
<a class="has-arrow" href="#" aria-label="MokoSuiteClient">
|
||||
<span class="icon-shield-alt" aria-hidden="true"></span>
|
||||
<span class="sidebar-item-title">MokoSuiteClient</span>
|
||||
<?php foreach ($sorted as $comp): ?>
|
||||
<?php
|
||||
$compParsed = [];
|
||||
parse_str(parse_url($comp['link'], PHP_URL_QUERY) ?? '', $compParsed);
|
||||
$compOption = $compParsed['option'] ?? '';
|
||||
$compActive = ($compOption === $currentOption);
|
||||
|
||||
// For com_mokosuiteclient static children, also check the plugins filter link
|
||||
if (!$compActive && $comp['element'] === 'com_mokosuiteclient' && $currentOption === 'com_plugins')
|
||||
{
|
||||
$compActive = true;
|
||||
}
|
||||
|
||||
$hasChildren = !empty($comp['children']);
|
||||
$liClass = 'item mokosuiteclient-ext-item' . ($hasChildren ? ' parent item-level-1' : '') . ($compActive ? ' mm-active' : '');
|
||||
$aClass = ($hasChildren ? 'has-arrow' : 'no-dropdown') . ($compActive ? ' mm-active' : '');
|
||||
$childCollapse = 'collapse-level-1 mm-collapse' . ($compActive ? ' mm-show' : '');
|
||||
?>
|
||||
<li class="<?php echo $liClass; ?>">
|
||||
<a class="<?php echo $aClass; ?>" href="<?php echo $hasChildren ? '#' : Route::_($comp['link']); ?>"<?php echo ($compActive && !$hasChildren) ? ' aria-current="page"' : ''; ?>>
|
||||
<span class="<?php echo $comp['icon']; ?>" aria-hidden="true" style="display:inline-block!important;width:1.25em;text-align:center;margin-inline-end:0.4em;"></span>
|
||||
<span class="sidebar-item-title"><?php echo $comp['title']; ?></span>
|
||||
</a>
|
||||
<ul class="<?php echo $topCollapse; ?>" style="padding-inline-start:0.5rem;">
|
||||
|
||||
<?php // ── MokoSuiteClient static items ── ?>
|
||||
<?php foreach ($mokosuiteclientItems as $item): ?>
|
||||
<?php if ($hasChildren): ?>
|
||||
<ul class="<?php echo $childCollapse; ?>" style="padding-inline-start:0.5rem;">
|
||||
<?php foreach ($comp['children'] as $child): ?>
|
||||
<?php
|
||||
$active = false;
|
||||
$parsed = [];
|
||||
parse_str(parse_url($item['link'], PHP_URL_QUERY) ?? '', $parsed);
|
||||
if (($parsed['option'] ?? '') === $currentOption)
|
||||
$childParsed = [];
|
||||
parse_str(parse_url($child['link'], PHP_URL_QUERY) ?? '', $childParsed);
|
||||
$childOption = $childParsed['option'] ?? '';
|
||||
$childView = $childParsed['view'] ?? '';
|
||||
|
||||
$childActive = false;
|
||||
if ($childOption === $currentOption)
|
||||
{
|
||||
$active = empty($parsed['view'])
|
||||
$childActive = empty($childView)
|
||||
? ($currentView === '' || $currentView === 'dashboard')
|
||||
: ($currentView === ($parsed['view'] ?? ''));
|
||||
: ($currentView === $childView);
|
||||
}
|
||||
$liClass = 'item mokosuiteclient-menu-item' . ($active ? ' mm-active' : '');
|
||||
$aClass = 'no-dropdown' . ($active ? ' mm-active' : '');
|
||||
|
||||
$childLiClass = 'item mokosuiteclient-ext-child' . ($childActive ? ' mm-active' : '');
|
||||
$childAClass = 'no-dropdown' . ($childActive ? ' mm-active' : '');
|
||||
?>
|
||||
<li class="<?php echo $liClass; ?>">
|
||||
<a class="<?php echo $aClass; ?>" href="<?php echo Route::_($item['link']); ?>"<?php echo $active ? ' aria-current="page"' : ''; ?>>
|
||||
<span class="<?php echo $item['icon']; ?>" aria-hidden="true" style="display:inline-block!important;width:1.25em;text-align:center;margin-inline-end:0.4em;"></span>
|
||||
<span class="sidebar-item-title"><?php echo $item['title']; ?></span>
|
||||
<li class="<?php echo $childLiClass; ?>">
|
||||
<a class="<?php echo $childAClass; ?>" href="<?php echo Route::_($child['link']); ?>"<?php echo $childActive ? ' aria-current="page"' : ''; ?>>
|
||||
<span class="<?php echo $child['icon']; ?>" aria-hidden="true" style="display:inline-block!important;width:1.25em;text-align:center;margin-inline-end:0.4em;"></span>
|
||||
<span class="sidebar-item-title"><?php echo $child['title']; ?></span>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php // ── Auto-discovered Moko components with submenus ── ?>
|
||||
<?php foreach ($mokoComponents as $comp): ?>
|
||||
<?php
|
||||
$compParsed = [];
|
||||
parse_str(parse_url($comp['link'], PHP_URL_QUERY) ?? '', $compParsed);
|
||||
$compActive = ($compParsed['option'] ?? '') === $currentOption;
|
||||
$hasChildren = !empty($comp['children']);
|
||||
$compLiClass = 'item mokosuiteclient-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 mokosuiteclient-menu-child' . ($childActive ? ' mm-active' : '');
|
||||
$childAClass = 'no-dropdown' . ($childActive ? ' mm-active' : '');
|
||||
?>
|
||||
<li class="<?php echo $childLiClass; ?>">
|
||||
<a class="<?php echo $childAClass; ?>" href="<?php echo Route::_($child['link']); ?>"<?php echo $childActive ? ' aria-current="page"' : ''; ?>>
|
||||
<span class="<?php echo $child['icon']; ?>" aria-hidden="true" style="display:inline-block!important;width:1.25em;text-align:center;margin-inline-end:0.4em;"></span>
|
||||
<span class="sidebar-item-title"><?php echo $child['title']; ?></span>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoSuiteClient
|
||||
* REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
* VERSION: 02.35.00
|
||||
* VERSION: 02.41.00
|
||||
* PATH: /src/Extension/MokoSuiteClient.php
|
||||
* NOTE: Core system plugin for MokoSuiteClient admin tools suite
|
||||
*/
|
||||
@@ -36,6 +36,7 @@ use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
use Joomla\CMS\Version;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
/**
|
||||
@@ -163,6 +164,8 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface
|
||||
{
|
||||
$this->handleOneTimeLogin();
|
||||
$this->checkSetupRequired();
|
||||
$this->ensureAdminModulesActive();
|
||||
$this->checkHeartbeat();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2157,19 +2160,505 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Download Key Preservation
|
||||
// Admin Module Self-Healing
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Preserve download keys across Joomla extension updates.
|
||||
* Ensure MokoSuiteClient admin modules are published with correct positions.
|
||||
*
|
||||
* Joomla's installer can wipe the extra_query column (which holds
|
||||
* download keys / dlid) when rebuilding or reinstalling update sites.
|
||||
* This method keeps a backup of all non-empty extra_query values and
|
||||
* restores any that get cleared.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.34.12
|
||||
* Runs once per session to self-heal if modules were accidentally
|
||||
* unpublished or had their position cleared.
|
||||
*/
|
||||
private function ensureAdminModulesActive(): void
|
||||
{
|
||||
$session = \Joomla\CMS\Factory::getSession();
|
||||
|
||||
if ($session->get('mokosuiteclient.modules_checked', false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$session->set('mokosuiteclient.modules_checked', true);
|
||||
|
||||
$modules = [
|
||||
'mod_mokosuiteclient_cpanel' => ['position' => 'top', 'title' => 'MokoSuiteClient', 'access' => 6, 'ordering' => 0],
|
||||
'mod_mokosuiteclient_menu' => ['position' => 'menu', 'title' => 'MokoSuiteClient Menu', 'access' => 3, 'ordering' => 0],
|
||||
'mod_mokosuiteclient_cache' => ['position' => 'status', 'title' => 'MokoSuiteClient Cache Cleaner', 'access' => 3, 'ordering' => 0],
|
||||
];
|
||||
|
||||
try
|
||||
{
|
||||
$db = \Joomla\CMS\Factory::getDbo();
|
||||
$app = \Joomla\CMS\Factory::getApplication();
|
||||
|
||||
foreach ($modules as $element => $config)
|
||||
{
|
||||
// Check if extension is installed
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('extension_id')
|
||||
->from('#__extensions')
|
||||
->where('element = ' . $db->quote($element))
|
||||
->where('type = ' . $db->quote('module'))
|
||||
);
|
||||
if (!(int) $db->loadResult()) continue;
|
||||
|
||||
// Find existing module instance
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('id, published, position')
|
||||
->from('#__modules')
|
||||
->where('module = ' . $db->quote($element))
|
||||
->where('client_id = 1')
|
||||
->setLimit(1)
|
||||
);
|
||||
$mod = $db->loadObject();
|
||||
|
||||
$model = $app->bootComponent('com_modules')
|
||||
->getMVCFactory()
|
||||
->createModel('Module', 'Administrator', ['ignore_request' => true]);
|
||||
|
||||
if ($mod)
|
||||
{
|
||||
// Check if repair needed
|
||||
$needsFix = (int) $mod->published !== 1 || $mod->position !== $config['position'];
|
||||
|
||||
if (!$needsFix)
|
||||
{
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from('#__modules_menu')
|
||||
->where('moduleid = ' . (int) $mod->id)
|
||||
);
|
||||
$needsFix = (int) $db->loadResult() === 0;
|
||||
}
|
||||
|
||||
if ($needsFix)
|
||||
{
|
||||
$data = $model->getItem($mod->id)->getProperties();
|
||||
$data['published'] = 1;
|
||||
$data['position'] = $config['position'];
|
||||
$data['ordering'] = $config['ordering'] ?? 0;
|
||||
$data['assignment'] = 0;
|
||||
$model->save($data);
|
||||
}
|
||||
|
||||
// Ensure module is first in its position
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('MIN(' . $db->quoteName('ordering') . ')')
|
||||
->from('#__modules')
|
||||
->where($db->quoteName('position') . ' = ' . $db->quote($config['position']))
|
||||
->where($db->quoteName('client_id') . ' = 1')
|
||||
->where($db->quoteName('id') . ' != ' . (int) $mod->id)
|
||||
);
|
||||
$minOther = $db->loadResult();
|
||||
|
||||
if ($minOther !== null)
|
||||
{
|
||||
// Re-read current ordering for this module
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select($db->quoteName('ordering'))
|
||||
->from('#__modules')
|
||||
->where($db->quoteName('id') . ' = ' . (int) $mod->id)
|
||||
);
|
||||
$currentOrdering = (int) $db->loadResult();
|
||||
|
||||
if ($currentOrdering >= (int) $minOther)
|
||||
{
|
||||
$newOrdering = (int) $minOther - 1;
|
||||
$data = $model->getItem($mod->id)->getProperties();
|
||||
$data['ordering'] = $newOrdering;
|
||||
$data['assignment'] = 0;
|
||||
$model->save($data);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Module instance deleted — recreate it
|
||||
$data = [
|
||||
'title' => $config['title'],
|
||||
'module' => $element,
|
||||
'position' => $config['position'],
|
||||
'published' => 1,
|
||||
'access' => $config['access'],
|
||||
'ordering' => $config['ordering'] ?? 0,
|
||||
'showtitle' => 0,
|
||||
'client_id' => 1,
|
||||
'language' => '*',
|
||||
'params' => '{}',
|
||||
'assignment' => 0,
|
||||
];
|
||||
$model->save($data);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
// Silent — don't break the admin if self-heal fails
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Automation Engine Event Hooks (#151)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fire automation rules for user registration.
|
||||
*/
|
||||
public function onUserAfterSave($user, $isnew, $success, $msg): void
|
||||
{
|
||||
if (!$isnew || !$success) return;
|
||||
|
||||
class_exists(\Moko\Component\MokoSuiteClient\Administrator\Service\AutomationEngine::class, true) && \Moko\Component\MokoSuiteClient\Administrator\Service\AutomationEngine::fire('user_register', [
|
||||
'user_id' => (int) ($user['id'] ?? 0),
|
||||
'username' => $user['username'] ?? '',
|
||||
'email' => $user['email'] ?? '',
|
||||
'name' => $user['name'] ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire automation rules on article save.
|
||||
*/
|
||||
public function onContentAfterSave($context, $article, $isNew): void
|
||||
{
|
||||
if ($context !== 'com_content.article') return;
|
||||
|
||||
class_exists(\Moko\Component\MokoSuiteClient\Administrator\Service\AutomationEngine::class, true) && \Moko\Component\MokoSuiteClient\Administrator\Service\AutomationEngine::fire('content_save', [
|
||||
'article_id' => (int) ($article->id ?? 0),
|
||||
'title' => $article->title ?? '',
|
||||
'is_new' => $isNew ? '1' : '0',
|
||||
'catid' => (int) ($article->catid ?? 0),
|
||||
'user_id' => (int) ($article->modified_by ?? $article->created_by ?? 0),
|
||||
]);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Security Event Notifications (#147)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Notify on successful admin login.
|
||||
*/
|
||||
public function onUserAfterLogin($options): void
|
||||
{
|
||||
if (!($options['user'] ?? null)) return;
|
||||
$user = $options['user'];
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
$name = $user->username ?? $user->name ?? 'unknown';
|
||||
|
||||
// Fire automation for any login
|
||||
class_exists(\Moko\Component\MokoSuiteClient\Administrator\Service\AutomationEngine::class, true) && \Moko\Component\MokoSuiteClient\Administrator\Service\AutomationEngine::fire('user_login', [
|
||||
'user_id' => (int) ($user->id ?? 0),
|
||||
'username' => $name,
|
||||
'ip' => $ip,
|
||||
'client' => $this->app->isClient('administrator') ? 'admin' : 'site',
|
||||
]);
|
||||
|
||||
// Security notification for backend logins only
|
||||
if (!$this->app->isClient('administrator')) return;
|
||||
|
||||
class_exists(\Moko\Component\MokoSuiteClient\Administrator\Service\NotificationService::class, true) && \Moko\Component\MokoSuiteClient\Administrator\Service\NotificationService::securityAlert(
|
||||
'admin_login',
|
||||
"Admin login: {$name}",
|
||||
"User: {$name}\nIP: {$ip}\nTime: " . gmdate('Y-m-d H:i:s') . " UTC"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track failed login attempts and notify after threshold.
|
||||
*/
|
||||
public function onUserLoginFailure($response): void
|
||||
{
|
||||
if (!$this->app->isClient('administrator')) return;
|
||||
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
$username = $response['username'] ?? 'unknown';
|
||||
|
||||
// Track in session — notify after 3 failures from same IP
|
||||
$session = \Joomla\CMS\Factory::getSession();
|
||||
$key = 'mokosuiteclient.login_failures.' . md5($ip);
|
||||
$count = (int) $session->get($key, 0) + 1;
|
||||
$session->set($key, $count);
|
||||
|
||||
if ($count >= 3 && $count % 3 === 0)
|
||||
{
|
||||
class_exists(\Moko\Component\MokoSuiteClient\Administrator\Service\NotificationService::class, true) && \Moko\Component\MokoSuiteClient\Administrator\Service\NotificationService::securityAlert(
|
||||
'login_failure',
|
||||
"Failed login attempts: {$count} from {$ip}",
|
||||
"Username: {$username}\nIP: {$ip}\nAttempts: {$count}\nTime: " . gmdate('Y-m-d H:i:s') . " UTC"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Heartbeat Monitor ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Send heartbeat to MokoSuiteClientHQ when the package version changes.
|
||||
* Runs once per session on admin page load.
|
||||
*/
|
||||
private function checkHeartbeat(): void
|
||||
{
|
||||
try
|
||||
{
|
||||
if ($this->params->get('heartbeat_enabled', '1') === '0')
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$session = Factory::getSession();
|
||||
|
||||
if ($session->get('mokosuiteclient.heartbeat_sent', false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$lastVersion = $this->params->get('_last_heartbeat_version', '');
|
||||
$currentVersion = $this->getPluginVersion();
|
||||
|
||||
if ($lastVersion === $currentVersion)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$session->set('mokosuiteclient.heartbeat_sent', true);
|
||||
$this->sendRuntimeHeartbeat();
|
||||
|
||||
// Persist version marker with a targeted DB update to avoid overwriting
|
||||
// params that may have been modified by migrateMonitorParams() in the same request
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'));
|
||||
$freshParams = json_decode((string) $db->setQuery($query)->loadResult(), true) ?: [];
|
||||
$freshParams['_last_heartbeat_version'] = $currentVersion;
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($freshParams)))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
|
||||
)->execute();
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Heartbeat check failed: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send heartbeat data to MokoSuiteClientHQ.
|
||||
* RSA-signed: client signs domain|timestamp|token with its private key.
|
||||
*/
|
||||
private function sendRuntimeHeartbeat(): void
|
||||
{
|
||||
$baseUrl = rtrim($this->params->get('monitor_base_url', ''), '/');
|
||||
|
||||
// Fall back to manifest XML default
|
||||
if (empty($baseUrl))
|
||||
{
|
||||
$manifestFile = JPATH_PLUGINS . '/system/mokosuiteclient/mokosuiteclient.xml';
|
||||
|
||||
if (is_file($manifestFile))
|
||||
{
|
||||
$xml = simplexml_load_file($manifestFile);
|
||||
|
||||
if ($xml)
|
||||
{
|
||||
foreach ($xml->xpath('//field[@name="monitor_base_url"]') as $field)
|
||||
{
|
||||
$baseUrl = rtrim((string) $field['default'], '/');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($baseUrl))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$healthToken = $this->params->get('health_api_token', '');
|
||||
|
||||
if (empty($healthToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$siteUrl = rtrim(Uri::root(), '/');
|
||||
$domain = parse_url($siteUrl, PHP_URL_HOST) ?: '';
|
||||
|
||||
if (empty($domain))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$config = Factory::getConfig();
|
||||
$timestamp = time();
|
||||
|
||||
$payload = [
|
||||
'token' => $healthToken,
|
||||
'domain' => $domain,
|
||||
'site_name' => $config->get('sitename', 'Joomla'),
|
||||
'site_url' => $siteUrl,
|
||||
'joomla_version' => (new Version())->getShortVersion(),
|
||||
'php_version' => PHP_VERSION,
|
||||
'mokosuiteclient_version' => $this->getPluginVersion(),
|
||||
'timestamp' => $timestamp,
|
||||
'client_info' => [
|
||||
'company' => $config->get('sitename', ''),
|
||||
'email' => $config->get('mailfrom', ''),
|
||||
],
|
||||
];
|
||||
|
||||
// Include live health data
|
||||
$healthData = $this->fetchLocalHealth($siteUrl, $healthToken);
|
||||
|
||||
if ($healthData !== null)
|
||||
{
|
||||
$payload['health'] = $healthData;
|
||||
}
|
||||
|
||||
// RSA sign the request
|
||||
$headers = ['Content-Type: application/json'];
|
||||
$signature = $this->signHeartbeatRequest($domain, $timestamp, $healthToken);
|
||||
|
||||
if ($signature !== null)
|
||||
{
|
||||
$headers[] = 'X-MokoSuiteClient-Signature: ' . $signature;
|
||||
$headers[] = 'X-MokoSuiteClient-Timestamp: ' . $timestamp;
|
||||
}
|
||||
|
||||
$endpoint = $baseUrl . '/api/index.php/v1/mokosuiteclienthq/heartbeat';
|
||||
$json = json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
try
|
||||
{
|
||||
$http = \Joomla\CMS\Http\HttpFactory::getHttp(
|
||||
new \Joomla\Registry\Registry(['follow_location' => true, 'transport.curl' => ['certpath' => false]]),
|
||||
['curl', 'stream']
|
||||
);
|
||||
|
||||
$headerMap = [];
|
||||
|
||||
foreach ($headers as $h)
|
||||
{
|
||||
[$key, $val] = explode(': ', $h, 2);
|
||||
$headerMap[$key] = $val;
|
||||
}
|
||||
|
||||
$response = $http->post($endpoint, $json, $headerMap, 15);
|
||||
|
||||
if ($response->code >= 200 && $response->code < 300)
|
||||
{
|
||||
$this->app->enqueueMessage('MokoSuiteClientHQ heartbeat: site registered', 'message');
|
||||
}
|
||||
else
|
||||
{
|
||||
Log::add(
|
||||
\sprintf('Heartbeat HTTP %d: %s', $response->code, $response->body),
|
||||
Log::WARNING,
|
||||
'mokosuiteclient'
|
||||
);
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Heartbeat failed: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||
}
|
||||
}
|
||||
|
||||
private function signHeartbeatRequest(string $domain, int $timestamp, string $token): ?string
|
||||
{
|
||||
$signingKeyB64 = $this->params->get('monitor_signing_key', '');
|
||||
|
||||
// Fall back to manifest XML default
|
||||
if (empty($signingKeyB64))
|
||||
{
|
||||
$manifestFile = JPATH_PLUGINS . '/system/mokosuiteclient/mokosuiteclient.xml';
|
||||
|
||||
if (is_file($manifestFile))
|
||||
{
|
||||
$xml = simplexml_load_file($manifestFile);
|
||||
|
||||
if ($xml)
|
||||
{
|
||||
foreach ($xml->xpath('//field[@name="monitor_signing_key"]') as $field)
|
||||
{
|
||||
$signingKeyB64 = (string) $field['default'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($signingKeyB64))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
$privateKeyPem = base64_decode($signingKeyB64);
|
||||
|
||||
if (empty($privateKeyPem))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
$privateKey = openssl_pkey_get_private($privateKeyPem);
|
||||
|
||||
if ($privateKey === false)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
$message = $domain . '|' . $timestamp . '|' . $token;
|
||||
$signature = '';
|
||||
|
||||
if (openssl_sign($message, $signature, $privateKey, OPENSSL_ALGO_SHA256))
|
||||
{
|
||||
return base64_encode($signature);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function fetchLocalHealth(string $siteUrl, string $healthToken): ?array
|
||||
{
|
||||
try
|
||||
{
|
||||
$http = \Joomla\CMS\Http\HttpFactory::getHttp(
|
||||
new \Joomla\Registry\Registry(['follow_location' => true, 'transport.curl' => ['certpath' => false]]),
|
||||
['curl', 'stream']
|
||||
);
|
||||
|
||||
$response = $http->get(
|
||||
$siteUrl . '/?mokosuiteclient=health',
|
||||
['Authorization' => 'Bearer ' . $healthToken, 'Accept' => 'application/json'],
|
||||
10
|
||||
);
|
||||
|
||||
if ($response->code !== 200 || empty($response->body))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_decode($response->body, true) ?: null;
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Local health fetch failed: ' . $e->getMessage(), Log::DEBUG, 'mokosuiteclient');
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoSuiteClient
|
||||
* VERSION: 02.35.00
|
||||
* VERSION: 02.41.00
|
||||
* PATH: /src/Field/CopyableTokenField.php
|
||||
* BRIEF: Read-only token field with a copy-to-clipboard button
|
||||
*/
|
||||
|
||||
+8
@@ -39,3 +39,11 @@ PLG_SYSTEM_MOKOSUITECLIENT_ALIAS_ROBOTS_LABEL="Robots"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_ALIAS_ROBOTS_DESC="Meta robots directive for this alias domain. Use 'noindex, nofollow' to prevent search engines from indexing the alias."
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_ALIAS_REDIRECT_BACKEND_LABEL="Redirect Backend"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_ALIAS_REDIRECT_BACKEND_DESC="Redirect admin panel requests on this alias to the primary domain. Frontend stays on the alias domain."
|
||||
|
||||
; ===== Heartbeat Monitor fieldset =====
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_FIELDSET_MONITOR_LABEL="Heartbeat Monitor"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_FIELDSET_MONITOR_DESC="Settings for the MokoSuiteClientHQ heartbeat registration."
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_HEARTBEAT_LABEL="Enable Heartbeat"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_HEARTBEAT_DESC="Send heartbeat data to MokoSuiteClientHQ on install, update, and admin login."
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_BASE_URL_LABEL="HQ Base URL"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_BASE_URL_DESC="Base URL of the MokoSuiteClientHQ instance that receives heartbeat data."
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<license>GNU General Public License version 3 or later; see LICENSE.md</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.35.00</version>
|
||||
<version>02.41.00</version>
|
||||
<description>MokoSuiteClient core system plugin — coordinates feature plugins, heartbeat, health checks, and admin customizations.</description>
|
||||
<namespace path=".">Moko\Plugin\System\MokoSuiteClient</namespace>
|
||||
<scriptfile>script.php</scriptfile>
|
||||
@@ -80,6 +80,29 @@
|
||||
readonly="true"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="monitor"
|
||||
label="PLG_SYSTEM_MOKOSUITECLIENT_FIELDSET_MONITOR_LABEL"
|
||||
description="PLG_SYSTEM_MOKOSUITECLIENT_FIELDSET_MONITOR_DESC">
|
||||
|
||||
<field name="heartbeat_enabled" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOSUITECLIENT_HEARTBEAT_LABEL"
|
||||
description="PLG_SYSTEM_MOKOSUITECLIENT_HEARTBEAT_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
|
||||
<field name="monitor_base_url" type="url"
|
||||
default="https://waas.dev.mokoconsulting.tech"
|
||||
label="PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_BASE_URL_LABEL"
|
||||
description="PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_BASE_URL_DESC"
|
||||
filter="url" />
|
||||
|
||||
<field name="monitor_signing_key" type="hidden"
|
||||
default="LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tDQpNSUlFdmdJQkFEQU5CZ2txaGtpRzl3MEJBUUVGQUFTQ0JLZ3dnZ1NrQWdFQUFvSUJBUUMvcnVrWE0zZHB0aDg2DQpGSkRXTjM0ZjQ2cUtJem1SMmFtTWUyZ2dWbWxsWnFyMHJkRFk4OTdtQ05FRkk4Q0NwNGR5amkwOU5ETnAvalFxDQovL2JGdUFNOUFTZU5oQTlmRlpwSG5UMGkzY3N4V3RSS2NnMnRkR0wzUXhNRFVBeFJYQ1RSQXVPSWZybGp6Ky85DQpWZ0ZtWHU3M1VSaU9XY1lLeFErejFoZkRGK2ZxRTRlYW9QcUlsY2J5dmtKd2lkSkRWUEEwc2RtbVlUTFg2Q29xDQpQalVDRENlbkZoUXNteVMzM29KSXArK0c3ZzU5NmRYelZIczRQSjIwNnc0Z3JlckRRZk5GVytzZndHSnl3NjBrDQpUQTVmUzF2Wit4NEt2UUh6V1ErYS9xRS9sSGxFVzdOTWVJWExNWGczSDd1eXBabXlVU2t3S0k0djFQQXRGWmtkDQpBaVpPZWZpVkFnTUJBQUVDZ2dFQVI0VGJyVDR0NWJ5MDhIQW0wcTR3WVF4REhEbVlJbzNXdDZ5MURmYU11OVMzDQpDYW5TMm9oazJzaE9TcGhhU2hFajI3WjBKY2hYdjhYWURvbU1BZmVsN3I5eDZjQ2FhTVdUNEdCMU5Zckp1NDhBDQprV2NteTkwWitPNTZQZkZJeTJXdXV6dFRxaFdZb0ZDSTBOZlU2bGw5SzhpSFl6VWx1MzZSSklweWx5OXFPKyt4DQpmTUZYcUovSkk0bVp6NW0raDBnbFMvN21VZ0EvUTRjbVJnRHJ3dkc3bEpBRjhWSDBEdW1uRWJkWkZvSi9XbU9JDQpSTi9lemhqczYrbU9hTnUwQWRsclpLU3QwRWZVYjl3QTFLQm5JMVVDU2w0Y1lidXVpL29jOWo1aGl6RGJvRWRyDQpJL1U5Y2FYUmZvb0pMNlUwOXN1VTdyTlFLbFRhMXM4NVhvY3htT0JMK1FLQmdRRHg5QzB5MjQ5SG1paXJ2WExIDQpBUXdUTjRyMjdhUTZMMFc2SHdDNHdzMUhleDRpeWRXT1lIcWdBSnY4VHZyeVpHOW1SaFh1U1ROTjYxV1UvTWFNDQphQVYwVjJ4Y0RrdDNFUnhNak1XRmhXUTh0cjN2RUtqWjFnOVJXOGhiTE9VYXVCcmJhMlI4RWNZYXFLZXlxR3N4DQpCa0VLZlRIUzNmUysraXNLZ2EzUU1mcjB6d0tCZ1FES3o2SGVKZ0tKRTVMM1ppbkhxaUFyVm5SZ2pYcFZrMWpvDQp6VXh5eTkwNEhmNGlmVXNIZklpdzVpN0VNR0U0RE5ob2MvZUJxcW1oM1N2ejJMUDNzOHUrL0hVZFllVzJIV1hhDQpKZlpMRE5BM0U3WDNkSVJ6MFg5UTh2OHcxaFpQeUxYOUlYeUVyUTNGZHFVdyt1Tko1VFZJell0RHppNnRKTjkvDQpGZGlxS0Q2ZFd3S0JnUURnQnE5bS9LWmdyTnRsa1FkYVBaejVtaDhBWGE4RzlNaEIrZnpJRmc3T1ZhL2tsQzg1DQpJaG5JVm1nWHFPVndWQkJWaVNVN09lbllCc042TE1hR01MYUVMNEkwaGtQWG5pOHVyZFVodVEzRHJZeVZjejUwDQpYR0JZZTN3Njk0bTJRS3NWYVExa1YyeXZPR1AxNXoxQTZrS0V2TURLTnhzclRTVlhHQlZneFRaUlB3S0JnUURBDQp1RFVVcUFIWXlDVHJ1c1VRMm5UZk9iUTAyN3ZYL2NDSzJDdEJHc0FJUjFmcTVpeVozSmozb0lQb0lpRC81aFR1DQpqT1F3N3o5cWRJVURublRGZUxDdnQ2NkNVVGk3cVl2VGxDZEtnYzZKeDgwdWJDWkErRjZIU2FGOWdyS0k5aTBaDQpjT3ltRnR2elBCOFZRQk1qY1E0Rk0yeVc3aUlrbmRsVEppdFE1aFU1NlFLQmdEZ1JIOXBEcGZwWlZ2V2g2MldGDQp5OGZzWUo1ODhzQmRMUlpTYTRuNi9XbjdUcUp1bWg2aWpFcDVyZFdnQkVtaDlJSk9jRUlhZ05mK0s5MXdoaThvDQpTeW01ajJpL1pjVVFYNFJSTDNxQ1RZZWVQVnZ3RHc3aWNLWVowTGQ2S1pFMmdEaDRPbEg4ejU0Zkl3a2tMSzRFDQpCcmtJNWppa05QSkJFR25zTm9zU3pWN2QNCi0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0NCg=="
|
||||
filter="raw" />
|
||||
</fieldset>
|
||||
</fields>
|
||||
</config>
|
||||
</extension>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoSuiteClient
|
||||
* REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
* VERSION: 02.35.00
|
||||
* VERSION: 02.41.00
|
||||
* PATH: /src/script.php
|
||||
* BRIEF: Installation script for MokoSuiteClient plugin
|
||||
* NOTE: Handles installation, update, and uninstallation tasks including language override deployment
|
||||
@@ -527,7 +527,7 @@ class plgSystemMokoSuiteClientInstallerScript implements InstallerScriptInterfac
|
||||
}
|
||||
catch (\Exception $e)
|
||||
{
|
||||
// Don't break install if email fails
|
||||
Log::add('Install notification email failed: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoSuiteClient
|
||||
* REPO: https://github.com/mokoconsulting-tech/mokosuiteclient
|
||||
* VERSION: 02.35.00
|
||||
* VERSION: 02.41.00
|
||||
* PATH: /src/services/provider.php
|
||||
* BRIEF: Service provider for dependency injection in Joomla 5.x
|
||||
* NOTE: Registers the plugin with Joomla's DI container
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
; MokoSuiteClient Backup Bridge Plugin
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GPL-3.0-or-later
|
||||
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_BACKUP="System - MokoSuiteClient Backup"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_DESC="Detects MokoSuiteBackup and includes backup status in heartbeat payloads sent to MokoSuiteHQ."
|
||||
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_FIELDSET_BASIC="Backup Monitoring"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_FIELDSET_BASIC_DESC="Configure backup status collection for heartbeat reporting."
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_HEARTBEAT_LABEL="Include in Heartbeat"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_HEARTBEAT_DESC="Include MokoSuiteBackup status data in heartbeat payloads sent to MokoSuiteHQ."
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_STALE_DAYS_LABEL="Stale Backup Threshold (days)"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_STALE_DAYS_DESC="Number of days without a backup before status is marked as degraded. Default: 7."
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
; MokoSuiteClient Backup Bridge Plugin - System strings
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_BACKUP="System - MokoSuiteClient Backup"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_DESC="MokoSuiteBackup detection and heartbeat integration."
|
||||
@@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<extension type="plugin" group="system" method="upgrade">
|
||||
<name>System - MokoSuiteClient Backup</name>
|
||||
<element>mokosuiteclient_backup</element>
|
||||
<author>Moko Consulting</author>
|
||||
<creationDate>2026-06-18</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.84-dev</version>
|
||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientBackup</namespace>
|
||||
|
||||
<files>
|
||||
<folder>src</folder>
|
||||
<folder>services</folder>
|
||||
<folder>language</folder>
|
||||
</files>
|
||||
|
||||
<languages folder="language">
|
||||
<language tag="en-GB">en-GB/plg_system_mokosuiteclient_backup.ini</language>
|
||||
<language tag="en-GB">en-GB/plg_system_mokosuiteclient_backup.sys.ini</language>
|
||||
</languages>
|
||||
|
||||
<config>
|
||||
<fields name="params">
|
||||
<fieldset name="basic"
|
||||
label="PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_FIELDSET_BASIC"
|
||||
description="PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_FIELDSET_BASIC_DESC">
|
||||
|
||||
<field name="heartbeat_enabled" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_HEARTBEAT_LABEL"
|
||||
description="PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_HEARTBEAT_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
|
||||
<field name="stale_days" type="number" default="7"
|
||||
label="PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_STALE_DAYS_LABEL"
|
||||
description="PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_STALE_DAYS_DESC"
|
||||
min="1" max="90" step="1" />
|
||||
|
||||
</fieldset>
|
||||
</fields>
|
||||
</config>
|
||||
</extension>
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteClient
|
||||
* @subpackage plg_system_mokosuiteclient_backup
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
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\MokoSuiteClientBackup\Extension\Backup;
|
||||
|
||||
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 Backup($dispatcher, (array) PluginHelper::getPlugin('system', 'mokosuiteclient_backup'));
|
||||
$plugin->setApplication(Factory::getApplication());
|
||||
|
||||
return $plugin;
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteClient
|
||||
* @subpackage plg_system_mokosuiteclient_backup
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Plugin\System\MokoSuiteClientBackup\Extension;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
|
||||
/**
|
||||
* MokoSuiteClient Backup Bridge Plugin
|
||||
*
|
||||
* Detects whether MokoSuiteBackup is installed and collects backup
|
||||
* status data for inclusion in heartbeat payloads to MokoSuiteHQ.
|
||||
*
|
||||
* Prefers MokoSuiteBackup's own BackupStatusHelper when available,
|
||||
* falling back to a direct table query if the helper class is missing
|
||||
* (e.g. older versions of MokoSuiteBackup).
|
||||
*
|
||||
* @since 02.34.84
|
||||
*/
|
||||
class Backup extends CMSPlugin implements SubscriberInterface
|
||||
{
|
||||
protected $autoloadLanguage = true;
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
'onMokoSuiteClientCollectHeartbeat' => 'onCollectHeartbeat',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect backup status data for the heartbeat payload.
|
||||
*
|
||||
* Triggered by the monitor plugin before sending a heartbeat.
|
||||
* Appends a 'backup' key to the heartbeat data array.
|
||||
*/
|
||||
public function onCollectHeartbeat($event): void
|
||||
{
|
||||
if (!$this->params->get('heartbeat_enabled', 1))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
$data = $this->getBackupStatus();
|
||||
$event->addResult('backup', $data);
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Backup bridge: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||
|
||||
// Send explicit error so HQ knows collection failed,
|
||||
// rather than interpreting absence as "not installed"
|
||||
$event->addResult('backup', [
|
||||
'installed' => true,
|
||||
'status' => 'error',
|
||||
'message' => 'Failed to collect backup status',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if MokoSuiteBackup is installed.
|
||||
*
|
||||
* Queries the extensions table for the component, which is more
|
||||
* reliable than checking for database tables alone.
|
||||
*/
|
||||
public function isBackupInstalled(): bool
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokosuitebackup'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'));
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
return (int) $db->loadResult() > 0;
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backup status summary from MokoSuiteBackup.
|
||||
*
|
||||
* Prefers the BackupStatusHelper API when available. Falls back
|
||||
* to a direct database query for compatibility with older versions.
|
||||
*
|
||||
* @return array Backup status data for heartbeat inclusion.
|
||||
*/
|
||||
public function getBackupStatus(): array
|
||||
{
|
||||
if (!$this->isBackupInstalled())
|
||||
{
|
||||
return [
|
||||
'installed' => false,
|
||||
'status' => 'ok',
|
||||
];
|
||||
}
|
||||
|
||||
// Prefer MokoSuiteBackup's own helper (clean public API)
|
||||
$helperClass = 'Joomla\\Component\\MokoSuiteBackup\\Administrator\\Utility\\BackupStatusHelper';
|
||||
|
||||
if (class_exists($helperClass))
|
||||
{
|
||||
$staleDays = (int) $this->params->get('stale_days', 7);
|
||||
|
||||
return $helperClass::getStatus($staleDays);
|
||||
}
|
||||
|
||||
// Fallback: direct table query for older MokoSuiteBackup versions
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
$tables = $db->getTableList();
|
||||
$prefix = $db->getPrefix();
|
||||
|
||||
if (!in_array($prefix . 'mokosuitebackup_records', $tables, true))
|
||||
{
|
||||
return [
|
||||
'installed' => true,
|
||||
'status' => 'degraded',
|
||||
'message' => 'Backup tables not found',
|
||||
];
|
||||
}
|
||||
|
||||
return $this->queryBackupRecords($db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query MokoSuiteBackup records for the latest backup summary.
|
||||
*
|
||||
* Column names match the MokoSuiteBackup schema:
|
||||
* - backupstart/backupend (not created/modified)
|
||||
* - status: pending, running, complete, fail
|
||||
* - total_size in bytes
|
||||
*
|
||||
* @param DatabaseInterface $db Database driver.
|
||||
*
|
||||
* @return array Backup status array.
|
||||
*/
|
||||
private function queryBackupRecords(DatabaseInterface $db): array
|
||||
{
|
||||
$staleDays = (int) $this->params->get('stale_days', 7);
|
||||
|
||||
// Most recent backup record
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('id'),
|
||||
$db->quoteName('description'),
|
||||
$db->quoteName('status'),
|
||||
$db->quoteName('backup_type'),
|
||||
$db->quoteName('total_size'),
|
||||
$db->quoteName('backupstart'),
|
||||
$db->quoteName('backupend'),
|
||||
$db->quoteName('origin'),
|
||||
$db->quoteName('filesexist'),
|
||||
])
|
||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||
->order($db->quoteName('id') . ' DESC');
|
||||
|
||||
$db->setQuery($query, 0, 1);
|
||||
$latest = $db->loadObject();
|
||||
|
||||
if (!$latest)
|
||||
{
|
||||
return [
|
||||
'installed' => true,
|
||||
'status' => 'degraded',
|
||||
'message' => 'No backups found',
|
||||
];
|
||||
}
|
||||
|
||||
// Count completed backups
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||
->where($db->quoteName('status') . ' = ' . $db->quote('complete'))
|
||||
);
|
||||
$totalBackups = (int) $db->loadResult();
|
||||
|
||||
$cutoff = date('Y-m-d H:i:s', strtotime("-{$staleDays} days"));
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||
->where($db->quoteName('status') . ' = ' . $db->quote('complete'))
|
||||
->where($db->quoteName('backupstart') . ' >= ' . $db->quote($cutoff))
|
||||
);
|
||||
$recentBackups = (int) $db->loadResult();
|
||||
|
||||
// Failures in last 7 days
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||
->where($db->quoteName('status') . ' = ' . $db->quote('fail'))
|
||||
->where($db->quoteName('backupstart') . ' >= ' . $db->quote($cutoff))
|
||||
);
|
||||
$failCount7d = (int) $db->loadResult();
|
||||
|
||||
// Determine status
|
||||
$daysSince = 999;
|
||||
|
||||
if (!empty($latest->backupstart) && $latest->backupstart !== '0000-00-00 00:00:00')
|
||||
{
|
||||
$daysSince = (int) ((time() - strtotime($latest->backupstart)) / 86400);
|
||||
}
|
||||
|
||||
$status = 'ok';
|
||||
|
||||
if ($latest->status === 'fail')
|
||||
{
|
||||
$status = 'degraded';
|
||||
}
|
||||
elseif ($latest->status !== 'complete')
|
||||
{
|
||||
$status = ($latest->status === 'running') ? 'ok' : 'degraded';
|
||||
}
|
||||
elseif ($daysSince > $staleDays)
|
||||
{
|
||||
$status = 'degraded';
|
||||
}
|
||||
|
||||
$sizeMb = $latest->total_size
|
||||
? round($latest->total_size / 1048576)
|
||||
: null;
|
||||
|
||||
return [
|
||||
'installed' => true,
|
||||
'status' => $status,
|
||||
'last_backup' => $latest->backupstart,
|
||||
'last_status' => $latest->status,
|
||||
'last_size_mb' => $sizeMb,
|
||||
'days_since' => $daysSince,
|
||||
'backup_type' => $latest->backup_type,
|
||||
'origin' => $latest->origin,
|
||||
'total_backups' => $totalBackups,
|
||||
'recent_7d' => $recentBackups,
|
||||
'fail_count_7d' => $failCount7d,
|
||||
'files_exist' => (bool) $latest->filesexist,
|
||||
'description' => $latest->description,
|
||||
];
|
||||
}
|
||||
}
|
||||
Binary file not shown.
+29
@@ -0,0 +1,29 @@
|
||||
; MokoSuiteClient DB-IP Plugin
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GPL-3.0-or-later
|
||||
; IP Geolocation by DB-IP — https://db-ip.com
|
||||
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DBIP="System - MokoSuiteClient DB-IP"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DBIP_DESC="IP geolocation for MokoSuiteClient using DB-IP Lite databases. Ships with country-level data; city-level data is downloaded from CDN or loaded from a local file."
|
||||
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DBIP_FIELDSET_BASIC="DB-IP Settings"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DBIP_FIELDSET_BASIC_DESC="Configure IP geolocation database source and level."
|
||||
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DBIP_SOURCE_LABEL="Database Source"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DBIP_SOURCE_DESC="CDN downloads the city database automatically from the configured URL. Local uses a MMDB file you provide on the server."
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DBIP_SOURCE_CDN="CDN (auto-download)"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DBIP_SOURCE_LOCAL="Local file"
|
||||
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DBIP_DATABASE_LEVEL_LABEL="Database Level"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DBIP_DATABASE_LEVEL_DESC="Country is bundled (~8 MB). City provides region, city, and coordinates but requires a separate download (~125 MB)."
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DBIP_DATABASE_COUNTRY="Country (bundled)"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DBIP_DATABASE_CITY="City (remote download)"
|
||||
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DBIP_AUTO_UPDATE_LABEL="Auto-Update Database"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DBIP_AUTO_UPDATE_DESC="Automatically download the latest city database monthly when an admin visits the backend."
|
||||
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DBIP_CDN_URL_LABEL="CDN Download URL"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DBIP_CDN_URL_DESC="URL to download the city-level MMDB file. Default points to the MokoConsulting geoip-data repository."
|
||||
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DBIP_LOCAL_PATH_LABEL="Local MMDB Path"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DBIP_LOCAL_PATH_DESC="Absolute path to a DB-IP MMDB file on the server (e.g. /home/user/dbip-city-lite.mmdb)."
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
; MokoSuiteClient DB-IP Plugin (system strings)
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GPL-3.0-or-later
|
||||
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DBIP="System - MokoSuiteClient DB-IP"
|
||||
PLG_SYSTEM_MOKOSUITECLIENT_DBIP_DESC="IP geolocation for MokoSuiteClient using DB-IP Lite databases."
|
||||
@@ -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 - MokoSuiteClient DB-IP</name>
|
||||
<element>mokosuiteclient_dbip</element>
|
||||
<author>Moko Consulting</author>
|
||||
<creationDate>2026-06-07</creationDate>
|
||||
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.41.00</version>
|
||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_DBIP_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientDBIP</namespace>
|
||||
|
||||
<files>
|
||||
<folder>src</folder>
|
||||
<folder>services</folder>
|
||||
<folder>language</folder>
|
||||
<folder>lib</folder>
|
||||
<folder>data</folder>
|
||||
</files>
|
||||
|
||||
<languages folder="language">
|
||||
<language tag="en-GB">en-GB/plg_system_mokosuiteclient_dbip.ini</language>
|
||||
<language tag="en-GB">en-GB/plg_system_mokosuiteclient_dbip.sys.ini</language>
|
||||
</languages>
|
||||
|
||||
<config>
|
||||
<fields name="params">
|
||||
<fieldset name="basic"
|
||||
label="PLG_SYSTEM_MOKOSUITECLIENT_DBIP_FIELDSET_BASIC"
|
||||
description="PLG_SYSTEM_MOKOSUITECLIENT_DBIP_FIELDSET_BASIC_DESC">
|
||||
|
||||
<field name="database_source" type="list" default="cdn"
|
||||
label="PLG_SYSTEM_MOKOSUITECLIENT_DBIP_SOURCE_LABEL"
|
||||
description="PLG_SYSTEM_MOKOSUITECLIENT_DBIP_SOURCE_DESC">
|
||||
<option value="cdn">PLG_SYSTEM_MOKOSUITECLIENT_DBIP_SOURCE_CDN</option>
|
||||
<option value="local">PLG_SYSTEM_MOKOSUITECLIENT_DBIP_SOURCE_LOCAL</option>
|
||||
</field>
|
||||
|
||||
<field name="database_level" type="list" default="country"
|
||||
label="PLG_SYSTEM_MOKOSUITECLIENT_DBIP_DATABASE_LEVEL_LABEL"
|
||||
description="PLG_SYSTEM_MOKOSUITECLIENT_DBIP_DATABASE_LEVEL_DESC">
|
||||
<option value="country">PLG_SYSTEM_MOKOSUITECLIENT_DBIP_DATABASE_COUNTRY</option>
|
||||
<option value="city">PLG_SYSTEM_MOKOSUITECLIENT_DBIP_DATABASE_CITY</option>
|
||||
</field>
|
||||
|
||||
<field name="auto_update" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOSUITECLIENT_DBIP_AUTO_UPDATE_LABEL"
|
||||
description="PLG_SYSTEM_MOKOSUITECLIENT_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_MOKOSUITECLIENT_DBIP_CDN_URL_LABEL"
|
||||
description="PLG_SYSTEM_MOKOSUITECLIENT_DBIP_CDN_URL_DESC"
|
||||
filter="url"
|
||||
showon="database_source:cdn" />
|
||||
|
||||
<field name="local_path" type="text"
|
||||
default=""
|
||||
label="PLG_SYSTEM_MOKOSUITECLIENT_DBIP_LOCAL_PATH_LABEL"
|
||||
description="PLG_SYSTEM_MOKOSUITECLIENT_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.MokoSuiteClientDBIP
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Extension\PluginInterface;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Plugin\PluginHelper;
|
||||
use Joomla\DI\Container;
|
||||
use Joomla\DI\ServiceProviderInterface;
|
||||
use Joomla\Event\DispatcherInterface;
|
||||
use Moko\Plugin\System\MokoSuiteClientDBIP\Extension\DBIP;
|
||||
|
||||
return new class implements ServiceProviderInterface
|
||||
{
|
||||
public function register(Container $container): void
|
||||
{
|
||||
$container->set(
|
||||
PluginInterface::class,
|
||||
function (Container $container) {
|
||||
$dispatcher = $container->get(DispatcherInterface::class);
|
||||
$plugin = new DBIP($dispatcher, (array) PluginHelper::getPlugin('system', 'mokosuiteclient_dbip'));
|
||||
$plugin->setApplication(Factory::getApplication());
|
||||
|
||||
return $plugin;
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
/**
|
||||
* @package Moko.Plugin.System.MokoSuiteClientDBIP
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GPL-3.0-or-later
|
||||
*
|
||||
* IP Geolocation by DB-IP — https://db-ip.com
|
||||
*/
|
||||
|
||||
namespace Moko\Plugin\System\MokoSuiteClientDBIP\Extension;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
use Moko\Plugin\System\MokoSuiteClientDBIP\Helper\DBIPHelper;
|
||||
|
||||
class DBIP extends CMSPlugin implements SubscriberInterface
|
||||
{
|
||||
protected $autoloadLanguage = true;
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
'onAfterInitialise' => 'onAfterInitialise',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize DB-IP: set local path if configured, auto-download city DB if needed.
|
||||
*/
|
||||
public function onAfterInitialise(): void
|
||||
{
|
||||
$source = $this->params->get('database_source', 'cdn');
|
||||
$level = $this->params->get('database_level', 'country');
|
||||
|
||||
// If using a local MMDB file, configure the helper
|
||||
if ($source === 'local')
|
||||
{
|
||||
$localPath = $this->params->get('local_path', '');
|
||||
|
||||
if ($localPath !== '')
|
||||
{
|
||||
DBIPHelper::setLocalPath($localPath);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// CDN mode: auto-download city DB if selected and needed
|
||||
if ($level !== 'city' || !$this->params->get('auto_update', 1))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$cityPath = DBIPHelper::getCityDbPath();
|
||||
|
||||
if (file_exists($cityPath))
|
||||
{
|
||||
$age = time() - filemtime($cityPath);
|
||||
|
||||
if ($age < 86400 * 30)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Only download during admin page loads
|
||||
$app = $this->getApplication();
|
||||
|
||||
if (!$app->isClient('administrator'))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$url = $this->params->get(
|
||||
'cdn_url',
|
||||
'https://git.mokoconsulting.tech/MokoConsulting/geoip-data/releases/download/latest/dbip-city-lite.mmdb'
|
||||
);
|
||||
|
||||
DBIPHelper::downloadCityDb($url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
/**
|
||||
* @package Moko.Plugin.System.MokoSuiteClientDBIP
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GPL-3.0-or-later
|
||||
*
|
||||
* IP Geolocation by DB-IP — https://db-ip.com
|
||||
*/
|
||||
|
||||
namespace Moko\Plugin\System\MokoSuiteClientDBIP\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use MaxMind\Db\Reader;
|
||||
|
||||
class DBIPHelper
|
||||
{
|
||||
private static ?Reader $countryReader = null;
|
||||
|
||||
private static ?Reader $cityReader = null;
|
||||
|
||||
private static bool $libLoaded = false;
|
||||
|
||||
private static string $customLocalPath = '';
|
||||
|
||||
/**
|
||||
* Set a custom local path for the city database.
|
||||
*/
|
||||
public static function setLocalPath(string $path): void
|
||||
{
|
||||
self::$customLocalPath = $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the bundled country database.
|
||||
*/
|
||||
public static function getCountryDbPath(): string
|
||||
{
|
||||
return JPATH_PLUGINS . '/system/mokosuiteclient_dbip/data/dbip-country-lite.mmdb';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the city database.
|
||||
* Uses custom local path if set, otherwise the CDN cache location.
|
||||
*/
|
||||
public static function getCityDbPath(): string
|
||||
{
|
||||
if (self::$customLocalPath !== '' && file_exists(self::$customLocalPath))
|
||||
{
|
||||
return self::$customLocalPath;
|
||||
}
|
||||
|
||||
return JPATH_ADMINISTRATOR . '/cache/mokosuiteclient_dbip/dbip-city-lite.mmdb';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the MaxMind DB Reader library.
|
||||
*/
|
||||
private static function loadLib(): void
|
||||
{
|
||||
if (self::$libLoaded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$libPath = JPATH_PLUGINS . '/system/mokosuiteclient_dbip/lib';
|
||||
|
||||
require_once $libPath . '/MaxMind/Db/Reader.php';
|
||||
require_once $libPath . '/MaxMind/Db/Reader/Decoder.php';
|
||||
require_once $libPath . '/MaxMind/Db/Reader/InvalidDatabaseException.php';
|
||||
require_once $libPath . '/MaxMind/Db/Reader/Metadata.php';
|
||||
require_once $libPath . '/MaxMind/Db/Reader/Util.php';
|
||||
|
||||
self::$libLoaded = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up an IP address and return geolocation data.
|
||||
*
|
||||
* @param string $ip The IP address to look up.
|
||||
*
|
||||
* @return array|null Geolocation data or null if not found.
|
||||
*
|
||||
* Result keys (country DB): country_code, country_name, continent_code, continent_name
|
||||
* Result keys (city DB): + region, city, latitude, longitude, timezone
|
||||
*/
|
||||
public static function lookup(string $ip): ?array
|
||||
{
|
||||
try
|
||||
{
|
||||
self::loadLib();
|
||||
|
||||
// Try city database first
|
||||
$cityPath = self::getCityDbPath();
|
||||
|
||||
if (file_exists($cityPath))
|
||||
{
|
||||
if (self::$cityReader === null)
|
||||
{
|
||||
self::$cityReader = new Reader($cityPath);
|
||||
}
|
||||
|
||||
$record = self::$cityReader->get($ip);
|
||||
|
||||
if ($record !== null)
|
||||
{
|
||||
return self::normalizeCityRecord($record);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to bundled country database
|
||||
$countryPath = self::getCountryDbPath();
|
||||
|
||||
if (file_exists($countryPath))
|
||||
{
|
||||
if (self::$countryReader === null)
|
||||
{
|
||||
self::$countryReader = new Reader($countryPath);
|
||||
}
|
||||
|
||||
$record = self::$countryReader->get($ip);
|
||||
|
||||
if ($record !== null)
|
||||
{
|
||||
return self::normalizeCountryRecord($record);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
// Silent — don't break the site if DB-IP fails
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up country only (uses bundled DB, always available).
|
||||
*/
|
||||
public static function lookupCountry(string $ip): ?string
|
||||
{
|
||||
$result = self::lookup($ip);
|
||||
|
||||
return $result['country_code'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the city database is installed.
|
||||
*/
|
||||
public static function hasCityDb(): bool
|
||||
{
|
||||
return file_exists(self::getCityDbPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the city database from the configured URL.
|
||||
*
|
||||
* @param string $url The download URL for the city MMDB file.
|
||||
*
|
||||
* @return bool True on success.
|
||||
*/
|
||||
public static function downloadCityDb(string $url): bool
|
||||
{
|
||||
$destPath = JPATH_ADMINISTRATOR . '/cache/mokosuiteclient_dbip/dbip-city-lite.mmdb';
|
||||
$destDir = \dirname($destPath);
|
||||
|
||||
if (!is_dir($destDir))
|
||||
{
|
||||
mkdir($destDir, 0755, true);
|
||||
}
|
||||
|
||||
$tmpFile = $destPath . '.tmp';
|
||||
|
||||
try
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
$fp = fopen($tmpFile, 'wb');
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
\CURLOPT_FILE => $fp,
|
||||
\CURLOPT_FOLLOWLOCATION => true,
|
||||
\CURLOPT_TIMEOUT => 300,
|
||||
\CURLOPT_CONNECTTIMEOUT => 30,
|
||||
\CURLOPT_USERAGENT => 'MokoSuiteClient-DBIP/1.0',
|
||||
]);
|
||||
|
||||
$success = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, \CURLINFO_HTTP_CODE);
|
||||
|
||||
curl_close($ch);
|
||||
fclose($fp);
|
||||
|
||||
if ($success && $code === 200 && filesize($tmpFile) > 1024)
|
||||
{
|
||||
if (self::$cityReader !== null)
|
||||
{
|
||||
self::$cityReader->close();
|
||||
self::$cityReader = null;
|
||||
}
|
||||
|
||||
rename($tmpFile, $destPath);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@unlink($tmpFile);
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
@unlink($tmpFile);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a DB-IP city record into a flat array.
|
||||
*/
|
||||
private static function normalizeCityRecord(array $record): array
|
||||
{
|
||||
return [
|
||||
'country_code' => $record['country']['iso_code'] ?? '',
|
||||
'country_name' => $record['country']['names']['en'] ?? '',
|
||||
'continent_code' => $record['continent']['code'] ?? '',
|
||||
'continent_name' => $record['continent']['names']['en'] ?? '',
|
||||
'region' => $record['subdivisions'][0]['names']['en'] ?? '',
|
||||
'city' => $record['city']['names']['en'] ?? '',
|
||||
'latitude' => $record['location']['latitude'] ?? null,
|
||||
'longitude' => $record['location']['longitude'] ?? null,
|
||||
'timezone' => $record['location']['time_zone'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a DB-IP country record into a flat array.
|
||||
*/
|
||||
private static function normalizeCountryRecord(array $record): array
|
||||
{
|
||||
return [
|
||||
'country_code' => $record['country']['iso_code'] ?? '',
|
||||
'country_name' => $record['country']['names']['en'] ?? '',
|
||||
'continent_code' => $record['continent']['code'] ?? '',
|
||||
'continent_name' => $record['continent']['names']['en'] ?? '',
|
||||
'region' => '',
|
||||
'city' => '',
|
||||
'latitude' => null,
|
||||
'longitude' => null,
|
||||
'timezone' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Shut down readers.
|
||||
*/
|
||||
public static function close(): void
|
||||
{
|
||||
if (self::$countryReader !== null)
|
||||
{
|
||||
self::$countryReader->close();
|
||||
self::$countryReader = null;
|
||||
}
|
||||
|
||||
if (self::$cityReader !== null)
|
||||
{
|
||||
self::$cityReader->close();
|
||||
self::$cityReader = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.35.00</version>
|
||||
<version>02.41.00</version>
|
||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientDevTools</namespace>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.35.00</version>
|
||||
<version>02.41.00</version>
|
||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientFirewall</namespace>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.35.00</version>
|
||||
<version>02.41.00</version>
|
||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientLicense</namespace>
|
||||
<files><folder>src</folder><folder>services</folder><folder>language</folder></files>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.35.00</version>
|
||||
<version>02.41.00</version>
|
||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientMonitor</namespace>
|
||||
|
||||
|
||||
@@ -34,10 +34,58 @@ class Monitor extends CMSPlugin implements SubscriberInterface
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
'onExtensionAfterSave' => 'onExtensionAfterSave',
|
||||
'onExtensionAfterSave' => 'onExtensionAfterSave',
|
||||
'onAfterInitialise' => 'onAfterInitialise',
|
||||
'onExtensionAfterInstall' => 'onExtensionAfterInstall',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Send heartbeat on first admin page load after install/update.
|
||||
*/
|
||||
public function onAfterInitialise(): void
|
||||
{
|
||||
$app = $this->getApplication();
|
||||
if (!$app->isClient('administrator')) return;
|
||||
if (!$this->params->get('heartbeat_enabled', 1)) return;
|
||||
|
||||
$session = \Joomla\CMS\Factory::getSession();
|
||||
if ($session->get('mokosuiteclient.heartbeat_sent', false)) return;
|
||||
|
||||
// Check if version changed since last heartbeat
|
||||
$lastVersion = $this->params->get('_last_heartbeat_version', '');
|
||||
$currentVersion = $this->getMokoSuiteClientVersion();
|
||||
|
||||
if ($lastVersion !== $currentVersion)
|
||||
{
|
||||
$session->set('mokosuiteclient.heartbeat_sent', true);
|
||||
$this->sendHeartbeat();
|
||||
|
||||
// Store version so we don't re-send every session
|
||||
try
|
||||
{
|
||||
$this->params->set('_last_heartbeat_version', $currentVersion);
|
||||
|
||||
$extension = new \Joomla\CMS\Table\Extension(Factory::getDbo());
|
||||
$extension->load(['element' => 'mokosuiteclient_monitor', 'folder' => 'system', 'type' => 'plugin']);
|
||||
$extension->params = $this->params->toString();
|
||||
$extension->store();
|
||||
}
|
||||
catch (\Throwable $e) {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send heartbeat immediately after package install/update.
|
||||
*/
|
||||
public function onExtensionAfterInstall($installer, $eid): void
|
||||
{
|
||||
if (!$this->params->get('heartbeat_enabled', 1)) return;
|
||||
|
||||
try { $this->sendHeartbeat(); }
|
||||
catch (\Throwable $e) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* After saving this plugin or the core plugin, send heartbeat.
|
||||
*/
|
||||
@@ -146,46 +194,47 @@ class Monitor extends CMSPlugin implements SubscriberInterface
|
||||
$endpoint = $baseUrl . '/api/index.php/v1/mokosuiteclienthq/heartbeat';
|
||||
$json = json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$ch = curl_init($endpoint);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_POSTFIELDS => $json,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
]);
|
||||
try
|
||||
{
|
||||
$http = \Joomla\CMS\Http\HttpFactory::getHttp(
|
||||
new \Joomla\Registry\Registry(['follow_location' => true, 'transport.curl' => ['certpath' => false]]),
|
||||
['curl', 'stream']
|
||||
);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
$headerMap = [];
|
||||
foreach ($headers as $h)
|
||||
{
|
||||
[$key, $val] = explode(': ', $h, 2);
|
||||
$headerMap[$key] = $val;
|
||||
}
|
||||
|
||||
if ($error)
|
||||
{
|
||||
Log::add('Monitor heartbeat failed: ' . $error, Log::WARNING, 'mokosuiteclient');
|
||||
$response = $http->post($endpoint, $json, $headerMap, 15);
|
||||
$code = $response->code;
|
||||
$body = json_decode($response->body, true);
|
||||
|
||||
if ($code >= 200 && $code < 300)
|
||||
{
|
||||
$app->enqueueMessage(
|
||||
'MokoSuiteClientHQ heartbeat: ' . ($body['status'] ?? 'ok'),
|
||||
'message'
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log::add(
|
||||
\sprintf('Monitor heartbeat HTTP %d: %s', $code, $body['error'] ?? 'Unknown'),
|
||||
Log::WARNING,
|
||||
'mokosuiteclient'
|
||||
);
|
||||
$app->enqueueMessage(
|
||||
'MokoSuiteClientHQ heartbeat failed (HTTP ' . $code . ')',
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
}
|
||||
elseif ($code >= 200 && $code < 300)
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$body = json_decode($response, true);
|
||||
$app->enqueueMessage(
|
||||
'MokoSuiteClientHQ heartbeat: ' . ($body['status'] ?? 'ok'),
|
||||
'message'
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
$body = json_decode($response, true);
|
||||
Log::add(
|
||||
\sprintf('Monitor heartbeat HTTP %d: %s', $code, $body['error'] ?? 'Unknown'),
|
||||
Log::WARNING,
|
||||
'mokosuiteclient'
|
||||
);
|
||||
$app->enqueueMessage(
|
||||
'MokoSuiteClientHQ heartbeat failed (HTTP ' . $code . ')',
|
||||
'warning'
|
||||
);
|
||||
Log::add('Monitor heartbeat failed: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,30 +306,30 @@ class Monitor extends CMSPlugin implements SubscriberInterface
|
||||
*/
|
||||
private function fetchLocalHealth(string $siteUrl, string $healthToken): ?array
|
||||
{
|
||||
$url = $siteUrl . '/?mokosuiteclient=health';
|
||||
try
|
||||
{
|
||||
$http = \Joomla\CMS\Http\HttpFactory::getHttp(
|
||||
new \Joomla\Registry\Registry(['follow_location' => true, 'transport.curl' => ['certpath' => false]]),
|
||||
['curl', 'stream']
|
||||
);
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Bearer ' . $healthToken,
|
||||
'Accept: application/json',
|
||||
],
|
||||
]);
|
||||
$response = $http->get(
|
||||
$siteUrl . '/?mokosuiteclient=health',
|
||||
['Authorization' => 'Bearer ' . $healthToken, 'Accept' => 'application/json'],
|
||||
10
|
||||
);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
if ($response->code !== 200 || empty($response->body))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($code !== 200 || empty($response))
|
||||
return json_decode($response->body, true) ?: null;
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_decode($response, true) ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -290,17 +339,11 @@ class Monitor extends CMSPlugin implements SubscriberInterface
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select($db->quoteName('manifest_cache'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('pkg_mokosuiteclient'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('package'))
|
||||
);
|
||||
$mc = json_decode($db->loadResult() ?? '{}');
|
||||
$extension = new \Joomla\CMS\Table\Extension(Factory::getDbo());
|
||||
$extension->load(['element' => 'pkg_mokosuiteclient', 'type' => 'package']);
|
||||
$manifest = json_decode($extension->manifest_cache ?? '{}');
|
||||
|
||||
return $mc->version ?? '';
|
||||
return $manifest->version ?? '';
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.35.00</version>
|
||||
<version>02.41.00</version>
|
||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_OFFLINE_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientOffline</namespace>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.35.00</version>
|
||||
<version>02.41.00</version>
|
||||
<description>PLG_SYSTEM_MOKOSUITECLIENT_TENANT_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoSuiteClientTenant</namespace>
|
||||
|
||||
|
||||
+4
@@ -2,3 +2,7 @@ PLG_TASK_MOKOSUITECLIENT_TICKETS="Task - MokoSuiteClient Ticket Automation"
|
||||
PLG_TASK_MOKOSUITECLIENT_TICKETS_DESC="Runs scheduled helpdesk automation rules."
|
||||
PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOMATION_TITLE="MokoSuiteClient: Ticket Automation"
|
||||
PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOMATION_DESC="Runs time-based automation rules against open tickets (auto-close, SLA escalation, etc.)."
|
||||
PLG_TASK_MOKOSUITECLIENT_TICKETS_IMAP_POLL_TITLE="MokoSuiteClient: IMAP Email Polling"
|
||||
PLG_TASK_MOKOSUITECLIENT_TICKETS_IMAP_POLL_DESC="Polls an IMAP inbox for new emails and creates tickets or replies from unread messages."
|
||||
PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOCLOSE_TITLE="MokoSuiteClient: Auto-Close Resolved Tickets"
|
||||
PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOCLOSE_DESC="Automatically closes tickets that have been in resolved status longer than the configured number of days."
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.35.00</version>
|
||||
<version>02.41.00</version>
|
||||
<description>Runs scheduled helpdesk automation rules — auto-close resolved tickets, SLA breach escalation, and time-based actions.</description>
|
||||
<namespace path="src">Moko\Plugin\Task\MokoSuiteClientTickets</namespace>
|
||||
|
||||
|
||||
@@ -10,12 +10,16 @@ namespace Moko\Plugin\Task\MokoSuiteClientTickets\Extension;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent;
|
||||
use Joomla\Component\Scheduler\Administrator\Task\Status;
|
||||
use Joomla\Component\Scheduler\Administrator\Traits\TaskPluginTrait;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
use Moko\Component\MokoSuiteClient\Administrator\Model\TicketsModel;
|
||||
use Moko\Component\MokoSuiteClient\Administrator\Service\AttachmentService;
|
||||
use Moko\Component\MokoSuiteClient\Administrator\Service\NotificationService;
|
||||
|
||||
class TicketAutomation extends CMSPlugin implements SubscriberInterface
|
||||
{
|
||||
@@ -26,6 +30,14 @@ class TicketAutomation extends CMSPlugin implements SubscriberInterface
|
||||
'langConstPrefix' => 'PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOMATION',
|
||||
'method' => 'runAutomation',
|
||||
],
|
||||
'mokosuiteclient.ticket.imap_poll' => [
|
||||
'langConstPrefix' => 'PLG_TASK_MOKOSUITECLIENT_TICKETS_IMAP_POLL',
|
||||
'method' => 'runImapPoll',
|
||||
],
|
||||
'mokosuiteclient.ticket.autoclose' => [
|
||||
'langConstPrefix' => 'PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOCLOSE',
|
||||
'method' => 'runAutoClose',
|
||||
],
|
||||
];
|
||||
|
||||
protected $autoloadLanguage = true;
|
||||
@@ -62,4 +74,240 @@ class TicketAutomation extends CMSPlugin implements SubscriberInterface
|
||||
return Status::KNOCKOUT;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll IMAP inbox and create tickets from unread emails (#136).
|
||||
*/
|
||||
private function runImapPoll(ExecuteTaskEvent $event): int
|
||||
{
|
||||
$config = $this->getComponentConfig();
|
||||
$host = $config['imap_host'] ?? '';
|
||||
$port = (int) ($config['imap_port'] ?? 993);
|
||||
$user = $config['imap_user'] ?? '';
|
||||
$pass = $config['imap_password'] ?? '';
|
||||
$ssl = ($config['imap_ssl'] ?? '1') === '1';
|
||||
$folder = $config['imap_folder'] ?? 'INBOX';
|
||||
$processed = $config['imap_processed_folder'] ?? 'INBOX.Processed';
|
||||
$defaultCat = (int) ($config['default_category'] ?? 0) ?: null;
|
||||
|
||||
if (empty($host) || empty($user) || empty($pass))
|
||||
{
|
||||
$this->logTask('IMAP not configured — skipping', 'warning');
|
||||
return Status::OK;
|
||||
}
|
||||
|
||||
if (!function_exists('imap_open'))
|
||||
{
|
||||
$this->logTask('php-imap extension not available', 'error');
|
||||
return Status::KNOCKOUT;
|
||||
}
|
||||
|
||||
$mailbox = '{' . $host . ':' . $port . '/imap' . ($ssl ? '/ssl' : '') . '/novalidate-cert}' . $folder;
|
||||
$mbox = @imap_open($mailbox, $user, $pass);
|
||||
|
||||
if (!$mbox)
|
||||
{
|
||||
$this->logTask('IMAP connection failed: ' . imap_last_error(), 'error');
|
||||
return Status::KNOCKOUT;
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$created = 0;
|
||||
$replied = 0;
|
||||
|
||||
$emails = imap_search($mbox, 'UNSEEN');
|
||||
|
||||
if ($emails === false)
|
||||
{
|
||||
imap_close($mbox);
|
||||
$this->logTask('No new emails');
|
||||
return Status::OK;
|
||||
}
|
||||
|
||||
foreach ($emails as $msgNum)
|
||||
{
|
||||
try
|
||||
{
|
||||
$header = imap_headerinfo($mbox, $msgNum);
|
||||
$subject = isset($header->subject) ? imap_utf8($header->subject) : '(no subject)';
|
||||
$fromAddr = $header->from[0]->mailbox . '@' . $header->from[0]->host;
|
||||
$body = $this->getImapBody($mbox, $msgNum);
|
||||
|
||||
// Match sender to Joomla user
|
||||
$userId = $this->findUserByEmail($fromAddr);
|
||||
|
||||
// Check if this is a reply (subject contains [#123])
|
||||
$ticketId = 0;
|
||||
if (preg_match('/\[#(\d+)\]/', $subject, $m))
|
||||
{
|
||||
$ticketId = (int) $m[1];
|
||||
}
|
||||
|
||||
if ($ticketId > 0)
|
||||
{
|
||||
// Add as reply to existing ticket
|
||||
$reply = (object) [
|
||||
'ticket_id' => $ticketId,
|
||||
'user_id' => $userId,
|
||||
'body' => $body,
|
||||
'is_internal' => 0,
|
||||
'created' => Factory::getDate()->toSql(),
|
||||
];
|
||||
$db->insertObject('#__mokosuiteclient_ticket_replies', $reply, 'id');
|
||||
$replied++;
|
||||
|
||||
// Notify
|
||||
$db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuiteclient_tickets')->where('id = ' . $ticketId));
|
||||
$ticket = $db->loadObject();
|
||||
if ($ticket) {
|
||||
NotificationService::notify('ticket_replied', $ticket, ['reply_body' => $body]);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Create new ticket
|
||||
$ticket = (object) [
|
||||
'subject' => $subject,
|
||||
'body' => $body,
|
||||
'status' => 'open',
|
||||
'priority' => 'normal',
|
||||
'category_id' => $defaultCat,
|
||||
'created_by' => $userId,
|
||||
'created' => Factory::getDate()->toSql(),
|
||||
];
|
||||
$db->insertObject('#__mokosuiteclient_tickets', $ticket, 'id');
|
||||
$created++;
|
||||
|
||||
NotificationService::notify('ticket_created', $ticket);
|
||||
}
|
||||
|
||||
// Mark as seen / move to processed folder
|
||||
imap_setflag_full($mbox, (string) $msgNum, '\\Seen');
|
||||
|
||||
if ($processed && $processed !== $folder)
|
||||
{
|
||||
@imap_mail_move($mbox, (string) $msgNum, $processed);
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('IMAP message processing error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||
}
|
||||
}
|
||||
|
||||
imap_expunge($mbox);
|
||||
imap_close($mbox);
|
||||
|
||||
$this->logTask("IMAP poll: {$created} tickets created, {$replied} replies added");
|
||||
return Status::OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-close resolved tickets after configured days.
|
||||
*/
|
||||
private function runAutoClose(ExecuteTaskEvent $event): int
|
||||
{
|
||||
$config = $this->getComponentConfig();
|
||||
$days = (int) ($config['autoclose_days'] ?? 7);
|
||||
|
||||
if ($days <= 0)
|
||||
{
|
||||
$this->logTask('Auto-close disabled (days = 0)');
|
||||
return Status::OK;
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$cutoff = Factory::getDate('-' . $days . ' days')->toSql();
|
||||
|
||||
$db->setQuery(
|
||||
"UPDATE {$db->quoteName('#__mokosuiteclient_tickets')}"
|
||||
. " SET status = 'closed', closed = {$db->quote(Factory::getDate()->toSql())}"
|
||||
. " WHERE status = 'resolved'"
|
||||
. " AND resolved IS NOT NULL"
|
||||
. " AND resolved < {$db->quote($cutoff)}"
|
||||
);
|
||||
$db->execute();
|
||||
$closed = $db->getAffectedRows();
|
||||
|
||||
$this->logTask("Auto-close: {$closed} tickets closed (resolved > {$days} days ago)");
|
||||
return Status::OK;
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
private function getComponentConfig(): array
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('params')
|
||||
->from('#__extensions')
|
||||
->where('element = ' . $db->quote('com_mokosuiteclient'))
|
||||
->where('type = ' . $db->quote('component'))
|
||||
);
|
||||
return json_decode($db->loadResult() ?? '{}', true) ?: [];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Failed to load component config: ' . $e->getMessage(), Log::ERROR, 'mokosuiteclient');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private function findUserByEmail(string $email): int
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('id')
|
||||
->from('#__users')
|
||||
->where('email = ' . $db->quote($email))
|
||||
->setLimit(1)
|
||||
);
|
||||
return (int) $db->loadResult();
|
||||
}
|
||||
|
||||
private function getImapBody($mbox, int $msgNum): string
|
||||
{
|
||||
$structure = imap_fetchstructure($mbox, $msgNum);
|
||||
|
||||
// Simple single-part message
|
||||
if (empty($structure->parts))
|
||||
{
|
||||
$body = imap_fetchbody($mbox, $msgNum, '1');
|
||||
if ($structure->encoding === 3) $body = base64_decode($body);
|
||||
if ($structure->encoding === 4) $body = quoted_printable_decode($body);
|
||||
return trim(strip_tags($body));
|
||||
}
|
||||
|
||||
// Multipart — find text/plain or text/html
|
||||
$textBody = '';
|
||||
|
||||
foreach ($structure->parts as $i => $part)
|
||||
{
|
||||
$partNum = (string) ($i + 1);
|
||||
|
||||
if ($part->type === 0) // text
|
||||
{
|
||||
$content = imap_fetchbody($mbox, $msgNum, $partNum);
|
||||
if ($part->encoding === 3) $content = base64_decode($content);
|
||||
if ($part->encoding === 4) $content = quoted_printable_decode($content);
|
||||
|
||||
$subtype = strtolower($part->subtype ?? '');
|
||||
|
||||
if ($subtype === 'plain' && empty($textBody))
|
||||
{
|
||||
$textBody = $content;
|
||||
}
|
||||
elseif ($subtype === 'html' && empty($textBody))
|
||||
{
|
||||
$textBody = strip_tags($content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return trim($textBody);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.35.00</version>
|
||||
<version>02.41.00</version>
|
||||
<description>PLG_TASK_MOKOSUITECLIENTDEMO_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\Task\MokoSuiteClientDemo</namespace>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* INGROUP: MokoSuiteClient
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient
|
||||
* PATH: /src/packages/plg_system_mokosuiteclient/Service/DemoResetService.php
|
||||
* VERSION: 02.35.00
|
||||
* VERSION: 02.41.00
|
||||
* BRIEF: Content-only snapshot/restore for demo site reset
|
||||
*/
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.35.00</version>
|
||||
<version>02.41.00</version>
|
||||
<description>PLG_TASK_MOKOSUITECLIENTSYNC_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\Task\MokoSuiteClientSync</namespace>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* INGROUP: MokoSuiteClient
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient
|
||||
* PATH: /src/packages/plg_system_mokosuiteclient/Service/ContentSyncReceiver.php
|
||||
* VERSION: 02.35.00
|
||||
* VERSION: 02.41.00
|
||||
* BRIEF: Receiver-side content sync — applies incoming payload to local DB
|
||||
*/
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* INGROUP: MokoSuiteClient
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient
|
||||
* PATH: /src/packages/plg_system_mokosuiteclient/Service/ContentSyncService.php
|
||||
* VERSION: 02.35.00
|
||||
* VERSION: 02.41.00
|
||||
* BRIEF: Sender-side content sync — builds payload and pushes to remote sites
|
||||
*/
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.35.00</version>
|
||||
<version>02.41.00</version>
|
||||
<description>Joomla Web Services API routes for MokoSuiteClient site management — health checks, cache, updates, backups, and site info.</description>
|
||||
<namespace path="src">Moko\Plugin\WebServices\MokoSuiteClient</namespace>
|
||||
<files>
|
||||
|
||||
@@ -124,5 +124,53 @@ final class MokoSuiteClientApi extends CMSPlugin implements SubscriberInterface
|
||||
'provision',
|
||||
['component' => 'com_mokosuiteclient']
|
||||
);
|
||||
|
||||
// User management API (#31)
|
||||
$router->createCRUDRoutes(
|
||||
'v1/mokosuiteclient/users',
|
||||
'users',
|
||||
['component' => 'com_mokosuiteclient']
|
||||
);
|
||||
|
||||
foreach (['reset-passwords' => 'resetPasswords', 'reset-2fa' => 'reset2fa', 'disable-all' => 'disableAll', 'enable-all' => 'enableAll', 'force-logout' => 'forceLogout'] as $slug => $task)
|
||||
{
|
||||
$router->addRoute(
|
||||
new \Joomla\Router\Route(
|
||||
['POST'],
|
||||
'v1/mokosuiteclient/users/' . $slug,
|
||||
'users.' . $task,
|
||||
[],
|
||||
['component' => 'com_mokosuiteclient']
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$router->addRoute(
|
||||
new \Joomla\Router\Route(
|
||||
['GET'],
|
||||
'v1/mokosuiteclient/users/export',
|
||||
'users.export',
|
||||
[],
|
||||
['component' => 'com_mokosuiteclient']
|
||||
)
|
||||
);
|
||||
|
||||
// Helpdesk Tickets API (#142)
|
||||
$router->createCRUDRoutes(
|
||||
'v1/mokosuiteclient/tickets',
|
||||
'tickets',
|
||||
['component' => 'com_mokosuiteclient']
|
||||
);
|
||||
|
||||
// Ticket reply (custom route — POST only)
|
||||
$router->addRoute(
|
||||
new \Joomla\Router\Route(
|
||||
['POST'],
|
||||
'v1/mokosuiteclient/tickets/:id/reply',
|
||||
'tickets.reply',
|
||||
['id' => '(\d+)'],
|
||||
['component' => 'com_mokosuiteclient']
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<extension type="package" method="upgrade">
|
||||
<name>Package - MokoSuiteClient</name>
|
||||
<packagename>mokosuiteclient</packagename>
|
||||
<version>02.35.00</version>
|
||||
<version>02.41.00</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
@@ -25,6 +25,7 @@
|
||||
<file type="module" id="mod_mokosuiteclient_menu" client="administrator">mod_mokosuiteclient_menu.zip</file>
|
||||
<file type="module" id="mod_mokosuiteclient_cache" client="administrator">mod_mokosuiteclient_cache.zip</file>
|
||||
<file type="module" id="mod_mokosuiteclient_categories" client="administrator">mod_mokosuiteclient_categories.zip</file>
|
||||
<file type="plugin" id="plg_system_mokosuiteclient_backup" group="system">plg_system_mokosuiteclient_backup.zip</file>
|
||||
<file type="plugin" id="plg_webservices_mokosuiteclient" group="webservices">plg_webservices_mokosuiteclient.zip</file>
|
||||
<file type="plugin" id="plg_task_mokosuiteclientdemo" group="task">plg_task_mokosuiteclientdemo.zip</file>
|
||||
<file type="plugin" id="plg_task_mokosuiteclientsync" group="task">plg_task_mokosuiteclientsync.zip</file>
|
||||
|
||||
+214
-176
@@ -20,7 +20,7 @@ use Joomla\CMS\Log\Log;
|
||||
*
|
||||
* @since 2.2.0
|
||||
*/
|
||||
class Pkg_MokosuiteInstallerScript
|
||||
class Pkg_MokosuiteclientInstallerScript
|
||||
{
|
||||
/**
|
||||
* Runs after package installation/update.
|
||||
@@ -70,6 +70,10 @@ class Pkg_MokosuiteInstallerScript
|
||||
// Remove legacy extensions and migrate settings before retiring
|
||||
$this->cleanupLegacyExtensions();
|
||||
$this->migrateStandalonePlugins();
|
||||
|
||||
// Migrate monitor params into core plugin BEFORE monitor row is deleted
|
||||
$this->migrateMonitorParams();
|
||||
|
||||
$this->removeRetiredExtensions();
|
||||
|
||||
$this->enablePlugin('system', 'mokosuiteclient');
|
||||
@@ -77,6 +81,8 @@ class Pkg_MokosuiteInstallerScript
|
||||
$this->enablePlugin('system', 'mokosuiteclient_tenant');
|
||||
$this->enablePlugin('system', 'mokosuiteclient_devtools');
|
||||
$this->enablePlugin('system', 'mokosuiteclient_offline');
|
||||
$this->enablePlugin('system', 'mokosuiteclient_dbip');
|
||||
$this->enablePlugin('system', 'mokosuiteclient_backup');
|
||||
$this->enablePlugin('webservices', 'mokosuiteclient');
|
||||
$this->enablePlugin('task', 'mokosuiteclientdemo');
|
||||
$this->enablePlugin('task', 'mokosuiteclientsync');
|
||||
@@ -467,6 +473,31 @@ class Pkg_MokosuiteInstallerScript
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote($element));
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
|
||||
if ($db->getAffectedRows() > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Row may exist with empty element (DEFAULT '' from preflight ALTER).
|
||||
// Fix the element value and enable in one pass.
|
||||
$manifestName = 'plg_' . $group . '_' . $element;
|
||||
$fix = $db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('element') . ' = ' . $db->quote($element))
|
||||
->set($db->quoteName('enabled') . ' = 1')
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote($group))
|
||||
->where('(' . $db->quoteName('element') . ' = ' . $db->quote('')
|
||||
. ' OR ' . $db->quoteName('element') . ' IS NULL)')
|
||||
->where($db->quoteName('name') . ' = ' . $db->quote($manifestName));
|
||||
$db->setQuery($fix);
|
||||
$db->execute();
|
||||
|
||||
if ($db->getAffectedRows() > 0)
|
||||
{
|
||||
Log::add('Fixed empty element for plugin ' . $group . '/' . $element, Log::NOTICE, 'mokosuiteclient');
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
@@ -504,7 +535,8 @@ class Pkg_MokosuiteInstallerScript
|
||||
$db->quote('mokosuiteclientdemo'),
|
||||
$db->quote('mokosuiteclientsync'),
|
||||
$db->quote('mokosuiteclient_tickets'),
|
||||
$db->quote('mokoonyx'),
|
||||
$db->quote('mokosuiteclient_backup'),
|
||||
$db->quote('mokoonyx'),
|
||||
];
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
@@ -799,35 +831,43 @@ class Pkg_MokosuiteInstallerScript
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Get health token from core plugin
|
||||
// All heartbeat config now lives in the core plugin params
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'));
|
||||
$coreParams = json_decode((string) $db->setQuery($query)->loadResult());
|
||||
$rawParams = (string) $db->setQuery($query)->loadResult();
|
||||
$coreParams = json_decode($rawParams);
|
||||
|
||||
if (!$coreParams)
|
||||
{
|
||||
Log::add('Heartbeat skipped: core plugin params empty or not found', Log::WARNING, 'mokosuiteclient');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$healthToken = $coreParams->health_api_token ?? '';
|
||||
|
||||
if (empty($healthToken))
|
||||
{
|
||||
Log::add('Heartbeat skipped: health_api_token not configured', Log::INFO, 'mokosuiteclient');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Get base URL and signing key from monitor plugin
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient_monitor'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'));
|
||||
$monitorParams = json_decode((string) $db->setQuery($query)->loadResult());
|
||||
$baseUrl = rtrim($monitorParams->base_url ?? '', '/');
|
||||
if (($coreParams->heartbeat_enabled ?? '1') === '0')
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to manifest XML default if not yet saved in params
|
||||
$baseUrl = rtrim($coreParams->monitor_base_url ?? '', '/');
|
||||
|
||||
// Fall back to manifest XML default
|
||||
if (empty($baseUrl))
|
||||
{
|
||||
$manifestFile = JPATH_PLUGINS . '/system/mokosuiteclient_monitor/mokosuiteclient_monitor.xml';
|
||||
$manifestFile = JPATH_PLUGINS . '/system/mokosuiteclient/mokosuiteclient.xml';
|
||||
|
||||
if (is_file($manifestFile))
|
||||
{
|
||||
@@ -835,7 +875,7 @@ class Pkg_MokosuiteInstallerScript
|
||||
|
||||
if ($xml)
|
||||
{
|
||||
foreach ($xml->xpath('//field[@name="base_url"]') as $field)
|
||||
foreach ($xml->xpath('//field[@name="monitor_base_url"]') as $field)
|
||||
{
|
||||
$baseUrl = rtrim((string) $field['default'], '/');
|
||||
break;
|
||||
@@ -846,9 +886,13 @@ class Pkg_MokosuiteInstallerScript
|
||||
|
||||
if (empty($baseUrl))
|
||||
{
|
||||
Log::add('Heartbeat skipped: monitor_base_url not configured and manifest fallback failed', Log::WARNING, 'mokosuiteclient');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Log::add('Heartbeat sending to: ' . $baseUrl, Log::INFO, 'mokosuiteclient');
|
||||
|
||||
$siteUrl = rtrim(\Joomla\CMS\Uri\Uri::root(), '/');
|
||||
$domain = parse_url($siteUrl, PHP_URL_HOST) ?: '';
|
||||
$timestamp = time();
|
||||
@@ -865,12 +909,12 @@ class Pkg_MokosuiteInstallerScript
|
||||
|
||||
$headers = ['Content-Type: application/json'];
|
||||
|
||||
// RSA sign the request — fall back to manifest XML default
|
||||
$signingKeyB64 = $monitorParams->signing_key ?? '';
|
||||
$signingKeyB64 = $coreParams->monitor_signing_key ?? '';
|
||||
|
||||
// Fall back to manifest XML default
|
||||
if (empty($signingKeyB64))
|
||||
{
|
||||
$manifestFile = JPATH_PLUGINS . '/system/mokosuiteclient_monitor/mokosuiteclient_monitor.xml';
|
||||
$manifestFile = JPATH_PLUGINS . '/system/mokosuiteclient/mokosuiteclient.xml';
|
||||
|
||||
if (is_file($manifestFile))
|
||||
{
|
||||
@@ -878,7 +922,7 @@ class Pkg_MokosuiteInstallerScript
|
||||
|
||||
if ($xml)
|
||||
{
|
||||
foreach ($xml->xpath('//field[@name="signing_key"]') as $field)
|
||||
foreach ($xml->xpath('//field[@name="monitor_signing_key"]') as $field)
|
||||
{
|
||||
$signingKeyB64 = (string) $field['default'];
|
||||
break;
|
||||
@@ -920,16 +964,25 @@ class Pkg_MokosuiteInstallerScript
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($code >= 200 && $code < 300)
|
||||
if ($error)
|
||||
{
|
||||
Log::add('Heartbeat connection failed: ' . $error, Log::WARNING, 'mokosuiteclient');
|
||||
}
|
||||
elseif ($code >= 200 && $code < 300)
|
||||
{
|
||||
Factory::getApplication()->enqueueMessage('MokoSuiteClientHQ heartbeat: site registered', 'message');
|
||||
}
|
||||
else
|
||||
{
|
||||
Log::add(sprintf('Heartbeat HTTP %d: %s', $code, $response), Log::WARNING, 'mokosuiteclient');
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
// Silent failure — heartbeat is non-critical
|
||||
Log::add('Heartbeat failed: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -943,194 +996,114 @@ class Pkg_MokosuiteInstallerScript
|
||||
*/
|
||||
private function setupCpanelModule(): void
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Enable the module
|
||||
$query = $db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('enabled') . ' = 1')
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('module'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mod_mokosuiteclient_cpanel'));
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
|
||||
// Check if a module instance already exists in #__modules
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__modules'))
|
||||
->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokosuiteclient_cpanel'));
|
||||
$db->setQuery($query);
|
||||
|
||||
if ((int) $db->loadResult() > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the module instance on the cpanel position
|
||||
$module = (object) [
|
||||
'title' => 'MokoSuiteClient',
|
||||
'note' => '',
|
||||
'content' => '',
|
||||
'ordering' => 0,
|
||||
'position' => 'top',
|
||||
'checked_out' => null,
|
||||
'checked_out_time' => null,
|
||||
'publish_up' => null,
|
||||
'publish_down' => null,
|
||||
'published' => 1,
|
||||
'module' => 'mod_mokosuiteclient_cpanel',
|
||||
'access' => 6, // Super Users only
|
||||
'showtitle' => 0,
|
||||
'params' => '{"show_health":"1","show_plugins":"1"}',
|
||||
'client_id' => 1, // Administrator
|
||||
'language' => '*',
|
||||
];
|
||||
|
||||
$db->insertObject('#__modules', $module, 'id');
|
||||
$moduleId = (int) $module->id;
|
||||
|
||||
if ($moduleId)
|
||||
{
|
||||
// Assign to all admin pages
|
||||
$map = (object) [
|
||||
'moduleid' => $moduleId,
|
||||
'menuid' => 0, // 0 = all pages
|
||||
];
|
||||
$db->insertObject('#__modules_menu', $map);
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('CPanel module setup error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||
}
|
||||
$this->ensureAdminModule('mod_mokosuiteclient_cpanel', 'MokoSuiteClient', 'top', 3, 0, '{"show_health":"1","show_plugins":"1"}');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the MokoSuiteClient admin sidebar menu module at position 0.
|
||||
*/
|
||||
private function setupAdminMenuModule(): void
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$this->ensureAdminModule('mod_mokosuiteclient_menu', 'MokoSuiteClient Menu', 'menu', 3, -1);
|
||||
}
|
||||
|
||||
// Enable the module extension
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('enabled') . ' = 1')
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('module'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mod_mokosuiteclient_menu'))
|
||||
)->execute();
|
||||
|
||||
// Check if module instance exists
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__modules'))
|
||||
->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokosuiteclient_menu'))
|
||||
);
|
||||
|
||||
if ((int) $db->loadResult() > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$module = (object) [
|
||||
'title' => 'MokoSuiteClient Menu',
|
||||
'note' => '',
|
||||
'content' => '',
|
||||
'ordering' => 0,
|
||||
'position' => 'menu',
|
||||
'checked_out' => null,
|
||||
'checked_out_time' => null,
|
||||
'publish_up' => null,
|
||||
'publish_down' => null,
|
||||
'published' => 1,
|
||||
'module' => 'mod_mokosuiteclient_menu',
|
||||
'access' => 3,
|
||||
'showtitle' => 0,
|
||||
'params' => '{}',
|
||||
'client_id' => 1,
|
||||
'language' => '*',
|
||||
];
|
||||
|
||||
$db->insertObject('#__modules', $module, 'id');
|
||||
|
||||
if ((int) $module->id)
|
||||
{
|
||||
$db->insertObject('#__modules_menu', (object) ['moduleid' => (int) $module->id, 'menuid' => 0]);
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Admin menu module setup error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||
}
|
||||
private function setupCacheModule(): void
|
||||
{
|
||||
$this->ensureAdminModule('mod_mokosuiteclient_cache', 'MokoSuiteClient Cache Cleaner', 'status', 3, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the cache cleaner module in the admin status bar position.
|
||||
* Ensure an admin module is published at the correct position using Joomla's ModuleModel.
|
||||
*
|
||||
* Uses the Joomla MVC save pipeline so that #__modules_menu mappings,
|
||||
* checked_out, and all internal bookkeeping are handled correctly.
|
||||
*/
|
||||
private function setupCacheModule(): void
|
||||
private function ensureAdminModule(string $element, string $title, string $position, int $access = 3, int $ordering = 0, string $params = '{}'): void
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Enable the module extension
|
||||
// Enable the extension entry
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('enabled') . ' = 1')
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('module'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mod_mokosuiteclient_cache'))
|
||||
->update('#__extensions')
|
||||
->set('enabled = 1')
|
||||
->where('type = ' . $db->quote('module'))
|
||||
->where('element = ' . $db->quote($element))
|
||||
)->execute();
|
||||
|
||||
// Check if module instance exists
|
||||
// Find existing module instance
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__modules'))
|
||||
->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokosuiteclient_cache'))
|
||||
->select('id')
|
||||
->from('#__modules')
|
||||
->where('module = ' . $db->quote($element))
|
||||
->setLimit(1)
|
||||
);
|
||||
$moduleId = (int) $db->loadResult();
|
||||
|
||||
if ((int) $db->loadResult() > 0)
|
||||
if ($moduleId > 0)
|
||||
{
|
||||
// Module exists — ensure it stays published with correct position
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__modules'))
|
||||
->set($db->quoteName('published') . ' = 1')
|
||||
->set($db->quoteName('position') . ' = ' . $db->quote($position))
|
||||
->set($db->quoteName('ordering') . ' = ' . (int) $ordering)
|
||||
->set($db->quoteName('access') . ' = ' . (int) $access)
|
||||
->set($db->quoteName('checked_out') . ' = NULL')
|
||||
->set($db->quoteName('checked_out_time') . ' = NULL')
|
||||
->where($db->quoteName('id') . ' = ' . $moduleId)
|
||||
)->execute();
|
||||
|
||||
// Ensure module-menu mapping exists (0 = all pages)
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__modules_menu'))
|
||||
->where($db->quoteName('moduleid') . ' = ' . $moduleId)
|
||||
);
|
||||
|
||||
if ((int) $db->loadResult() === 0)
|
||||
{
|
||||
$db->setQuery(
|
||||
"INSERT IGNORE INTO " . $db->quoteName('#__modules_menu')
|
||||
. " (moduleid, menuid) VALUES (" . $moduleId . ", 0)"
|
||||
)->execute();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$module = (object) [
|
||||
'title' => 'MokoSuiteClient Cache Cleaner',
|
||||
'note' => '',
|
||||
'content' => '',
|
||||
'ordering' => 8,
|
||||
'position' => 'status',
|
||||
'checked_out' => null,
|
||||
'checked_out_time' => null,
|
||||
'publish_up' => null,
|
||||
'publish_down' => null,
|
||||
'published' => 1,
|
||||
'module' => 'mod_mokosuiteclient_cache',
|
||||
'access' => 3,
|
||||
'showtitle' => 0,
|
||||
'params' => '{}',
|
||||
'client_id' => 1,
|
||||
'language' => '*',
|
||||
// Module doesn't exist — create via ModuleModel
|
||||
$data = [
|
||||
'title' => $title,
|
||||
'module' => $element,
|
||||
'position' => $position,
|
||||
'published' => 1,
|
||||
'access' => $access,
|
||||
'ordering' => $ordering,
|
||||
'showtitle' => 0,
|
||||
'client_id' => 1,
|
||||
'language' => '*',
|
||||
'params' => $params,
|
||||
'assignment' => 0,
|
||||
];
|
||||
|
||||
$db->insertObject('#__modules', $module, 'id');
|
||||
$app = Factory::getApplication();
|
||||
|
||||
if ((int) $module->id)
|
||||
/** @var \Joomla\Component\Modules\Administrator\Model\ModuleModel $model */
|
||||
$model = $app->bootComponent('com_modules')
|
||||
->getMVCFactory()
|
||||
->createModel('Module', 'Administrator', ['ignore_request' => true]);
|
||||
|
||||
if (!$model->save($data))
|
||||
{
|
||||
$mm = (object) ['moduleid' => (int) $module->id, 'menuid' => 0];
|
||||
$db->insertObject('#__modules_menu', $mm, 'moduleid');
|
||||
Log::add("Module setup ({$element}): " . $model->getError(), Log::WARNING, 'mokosuiteclient');
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Cache module setup error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||
Log::add("Module setup ({$element}): " . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1599,6 +1572,71 @@ class Pkg_MokosuiteInstallerScript
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate monitor plugin params (base_url, signing_key) into the core plugin.
|
||||
* The monitor plugin is retired but its config must survive in the core plugin.
|
||||
*/
|
||||
private function migrateMonitorParams(): void
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Read core plugin params
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'));
|
||||
$coreParams = json_decode((string) $db->setQuery($query)->loadResult(), true) ?: [];
|
||||
|
||||
if (!empty($coreParams['_monitor_migrated']))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Read monitor plugin params (may already be gone)
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient_monitor'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'));
|
||||
$monitorJson = (string) $db->setQuery($query)->loadResult();
|
||||
$monitorParams = json_decode($monitorJson, true) ?: [];
|
||||
|
||||
$keyMap = [
|
||||
'base_url' => 'monitor_base_url',
|
||||
'signing_key' => 'monitor_signing_key',
|
||||
'heartbeat_enabled' => 'heartbeat_enabled',
|
||||
];
|
||||
|
||||
foreach ($keyMap as $old => $new)
|
||||
{
|
||||
if (!empty($monitorParams[$old]) && empty($coreParams[$new]))
|
||||
{
|
||||
$coreParams[$new] = $monitorParams[$old];
|
||||
}
|
||||
}
|
||||
|
||||
$coreParams['_monitor_migrated'] = 1;
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($coreParams)))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mokosuiteclient'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
|
||||
)->execute();
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Monitor param migration error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Warn after install/update if no license key (dlid) is configured on the update site.
|
||||
*/
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
|
||||
SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later
|
||||
|
||||
FILE INFORMATION
|
||||
DEFGROUP: Joomla.Component
|
||||
INGROUP: MokoWaaS
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
|
||||
VERSION: 02.35.00
|
||||
PATH: /mokowaas.xml
|
||||
BRIEF: Component manifest for MokoWaaS admin dashboard and REST API
|
||||
-->
|
||||
<extension type="component" method="upgrade">
|
||||
<name>MokoWaaS</name>
|
||||
<author>Moko Consulting</author>
|
||||
<creationDate>2026-06-02</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.00</version>
|
||||
<description>MokoWaaS admin dashboard and REST API. Provides a control panel for managing MokoWaaS feature plugins, site health monitoring, and remote management endpoints.</description>
|
||||
|
||||
<namespace path="src">Moko\Component\MokoWaaS</namespace>
|
||||
|
||||
<administration>
|
||||
<menu img="class:cogs">MokoWaaS</menu>
|
||||
<files folder="admin">
|
||||
<folder>language</folder>
|
||||
<folder>services</folder>
|
||||
<folder>src</folder>
|
||||
<folder>tmpl</folder>
|
||||
</files>
|
||||
</administration>
|
||||
|
||||
<api>
|
||||
<files folder="api">
|
||||
<folder>src</folder>
|
||||
</files>
|
||||
</api>
|
||||
|
||||
<media destination="com_mokowaas" folder="media">
|
||||
<folder>css</folder>
|
||||
<folder>js</folder>
|
||||
</media>
|
||||
</extension>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,72 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoWaaS
|
||||
* VERSION: 02.35.00
|
||||
* PATH: /src/Field/AllowedIpsField.php
|
||||
* BRIEF: Custom form field that displays the current IP whitelist
|
||||
*/
|
||||
|
||||
namespace Moko\Plugin\System\MokoWaaS\Field;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Form\FormField;
|
||||
|
||||
class AllowedIpsField extends FormField
|
||||
{
|
||||
protected $type = 'AllowedIps';
|
||||
|
||||
protected function getInput()
|
||||
{
|
||||
$config = Factory::getApplication()->getConfig();
|
||||
$allowedRaw = $config->get('mokowaas_allowed_ips', '');
|
||||
$currentIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
|
||||
if (empty($allowedRaw))
|
||||
{
|
||||
$status = '<span class="badge bg-danger">Not configured</span>';
|
||||
$ipList = '<em>No IPs set — emergency access is blocked.</em>';
|
||||
}
|
||||
else
|
||||
{
|
||||
$ips = array_map('trim', explode(',', $allowedRaw));
|
||||
$status = '<span class="badge bg-success">'
|
||||
. count($ips) . ' IP(s) configured</span>';
|
||||
$ipItems = [];
|
||||
|
||||
foreach ($ips as $ip)
|
||||
{
|
||||
$match = ($ip === $currentIp)
|
||||
? ' <span class="badge bg-info">your IP</span>'
|
||||
: '';
|
||||
$ipItems[] = '<code>' . htmlspecialchars($ip)
|
||||
. '</code>' . $match;
|
||||
}
|
||||
|
||||
$ipList = implode(', ', $ipItems);
|
||||
}
|
||||
|
||||
$yourIp = '<code>' . htmlspecialchars($currentIp) . '</code>';
|
||||
|
||||
return '<div class="alert alert-info mb-0">'
|
||||
. '<strong>IP Whitelist:</strong> ' . $status . '<br>'
|
||||
. '<strong>Allowed IPs:</strong> ' . $ipList . '<br>'
|
||||
. '<strong>Your current IP:</strong> ' . $yourIp . '<br>'
|
||||
. '<small class="text-muted">Set <code>public '
|
||||
. '$mokowaas_allowed_ips = \'1.2.3.4,5.6.7.8\';</code>'
|
||||
. ' in configuration.php to change.</small>'
|
||||
. '</div>';
|
||||
}
|
||||
|
||||
protected function getLabel()
|
||||
{
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoWaaS
|
||||
* VERSION: 02.35.00
|
||||
* PATH: /src/Field/CurrentIpField.php
|
||||
* BRIEF: Read-only field that displays the current user's IP address
|
||||
*/
|
||||
|
||||
namespace Moko\Plugin\System\MokoWaaS\Field;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Form\FormField;
|
||||
|
||||
class CurrentIpField extends FormField
|
||||
{
|
||||
protected $type = 'CurrentIp';
|
||||
|
||||
protected function getInput()
|
||||
{
|
||||
$currentIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
|
||||
return '<div class="alert alert-info mb-0 py-2">'
|
||||
. '<strong>Your current IP:</strong> '
|
||||
. '<code>' . htmlspecialchars($currentIp) . '</code> '
|
||||
. '<small class="text-muted">— add this to the table below to keep your session alive.</small>'
|
||||
. '</div>';
|
||||
}
|
||||
|
||||
protected function getLabel()
|
||||
{
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage plg_system_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoWaaS
|
||||
* VERSION: 02.35.00
|
||||
* PATH: /src/Field/DemoTaskInfoField.php
|
||||
* BRIEF: Read-only field showing scheduled task info with link to manage it
|
||||
*/
|
||||
|
||||
namespace Moko\Plugin\System\MokoWaaS\Field;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Form\FormField;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
/**
|
||||
* Displays the demo reset scheduled task status: schedule, next run,
|
||||
* last run, and a direct link to edit the task in Joomla's Scheduler.
|
||||
*
|
||||
* @since 02.29.00
|
||||
*/
|
||||
class DemoTaskInfoField extends FormField
|
||||
{
|
||||
protected $type = 'DemoTaskInfo';
|
||||
|
||||
protected function getInput()
|
||||
{
|
||||
// Query the scheduled task — if it exists and is enabled, demo mode is on
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__scheduler_tasks'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('mokowaas.demo.reset'));
|
||||
|
||||
$db->setQuery($query);
|
||||
$task = $db->loadAssoc();
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$task = null;
|
||||
}
|
||||
|
||||
$newTaskLink = Route::_('index.php?option=com_scheduler&task=task.add');
|
||||
|
||||
if (!$task)
|
||||
{
|
||||
return '<div class="alert alert-info mb-0 py-2">'
|
||||
. 'No demo reset task configured. '
|
||||
. '<a href="' . $newTaskLink . '" class="alert-link">Create a Scheduled Task</a> '
|
||||
. 'and select <strong>MokoWaaS Demo Reset</strong> to enable demo mode.</div>';
|
||||
}
|
||||
|
||||
$taskId = (int) $task['id'];
|
||||
$state = (int) $task['state'];
|
||||
$siteTimezone = Factory::getApplication()->get('offset', 'UTC');
|
||||
|
||||
// Parse schedule from execution_rules
|
||||
$rules = json_decode($task['execution_rules'] ?? '{}', true);
|
||||
$ruleType = $rules['rule-type'] ?? '';
|
||||
|
||||
switch ($ruleType)
|
||||
{
|
||||
case 'cron-expression':
|
||||
$schedule = $rules['cron-expression'] ?? '';
|
||||
$friendlySchedule = $this->friendlySchedule($schedule);
|
||||
break;
|
||||
|
||||
case 'interval-minutes':
|
||||
$mins = (int) ($rules['interval-minutes'] ?? 0);
|
||||
|
||||
if ($mins >= 1440 && $mins % 1440 === 0)
|
||||
{
|
||||
$days = $mins / 1440;
|
||||
$schedule = 'Every ' . $days . ' day' . ($days > 1 ? 's' : '');
|
||||
}
|
||||
elseif ($mins >= 60 && $mins % 60 === 0)
|
||||
{
|
||||
$hours = $mins / 60;
|
||||
$schedule = 'Every ' . $hours . ' hour' . ($hours > 1 ? 's' : '');
|
||||
}
|
||||
else
|
||||
{
|
||||
$schedule = 'Every ' . $mins . ' minute' . ($mins !== 1 ? 's' : '');
|
||||
}
|
||||
|
||||
$friendlySchedule = $schedule;
|
||||
break;
|
||||
|
||||
case 'interval-hours':
|
||||
$hours = (int) ($rules['interval-hours'] ?? 0);
|
||||
$schedule = 'Every ' . $hours . ' hour' . ($hours !== 1 ? 's' : '');
|
||||
$friendlySchedule = $schedule;
|
||||
break;
|
||||
|
||||
case 'interval-days':
|
||||
$days = (int) ($rules['interval-days'] ?? 0);
|
||||
$schedule = 'Every ' . $days . ' day' . ($days !== 1 ? 's' : '');
|
||||
$friendlySchedule = $schedule;
|
||||
break;
|
||||
|
||||
default:
|
||||
$schedule = $ruleType ?: 'Not set';
|
||||
$friendlySchedule = 'Custom';
|
||||
}
|
||||
|
||||
// Next execution
|
||||
$nextExec = $task['next_execution'] ?? '';
|
||||
$nextFormatted = 'Not scheduled';
|
||||
$nextBadge = '';
|
||||
|
||||
if (!empty($nextExec) && $nextExec !== '0000-00-00 00:00:00')
|
||||
{
|
||||
try
|
||||
{
|
||||
$dt = new \DateTime($nextExec, new \DateTimeZone('UTC'));
|
||||
$dt->setTimezone(new \DateTimeZone($siteTimezone));
|
||||
$nextFormatted = $dt->format('M j, Y g:i A T');
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$nextFormatted = $nextExec;
|
||||
}
|
||||
|
||||
$diff = strtotime($nextExec . ' UTC') - time();
|
||||
|
||||
if ($diff <= 0)
|
||||
{
|
||||
$nextBadge = '<span class="badge bg-warning text-dark">DUE</span>';
|
||||
}
|
||||
elseif ($diff < 3600)
|
||||
{
|
||||
$nextBadge = '<span class="badge bg-info">in ' . (int) ceil($diff / 60) . ' min</span>';
|
||||
}
|
||||
elseif ($diff < 86400)
|
||||
{
|
||||
$nextBadge = '<span class="badge bg-info">in ' . round($diff / 3600, 1) . 'h</span>';
|
||||
}
|
||||
else
|
||||
{
|
||||
$nextBadge = '<span class="badge bg-secondary">in ' . round($diff / 86400, 1) . 'd</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// Last execution
|
||||
$lastExec = $task['last_execution'] ?? '';
|
||||
$lastFormatted = 'Never';
|
||||
|
||||
if (!empty($lastExec) && $lastExec !== '0000-00-00 00:00:00')
|
||||
{
|
||||
try
|
||||
{
|
||||
$dt = new \DateTime($lastExec, new \DateTimeZone('UTC'));
|
||||
$dt->setTimezone(new \DateTimeZone($siteTimezone));
|
||||
$lastFormatted = $dt->format('M j, Y g:i A T');
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$lastFormatted = $lastExec;
|
||||
}
|
||||
}
|
||||
|
||||
// State badge
|
||||
$stateBadge = $state === 1
|
||||
? '<span class="badge bg-success">Enabled</span>'
|
||||
: '<span class="badge bg-danger">Disabled</span>';
|
||||
|
||||
// Link to edit the task
|
||||
$editLink = Route::_('index.php?option=com_scheduler&task=task.edit&id=' . $taskId);
|
||||
|
||||
// Task params — default to On when keys are missing (matches form defaults)
|
||||
$taskParams = json_decode($task['params'] ?? '{}', true) ?: [];
|
||||
$bannerOn = !isset($taskParams['banner_enabled']) || (int) $taskParams['banner_enabled'] === 1;
|
||||
$mediaOn = !isset($taskParams['include_media']) || (int) $taskParams['include_media'] === 1;
|
||||
$countdownOn = !isset($taskParams['show_countdown']) || (int) $taskParams['show_countdown'] === 1;
|
||||
|
||||
// Check if snapshot exists
|
||||
$snapshotExists = is_dir(JPATH_ROOT . '/mokowaas-snapshots/default');
|
||||
|
||||
// Build info card
|
||||
return '<div class="card card-body bg-light py-2 px-3 mb-0">'
|
||||
. '<table class="table table-sm table-borderless mb-1" style="max-width:550px">'
|
||||
. '<tr><td class="text-muted" style="width:130px">Status</td><td>' . $stateBadge . '</td></tr>'
|
||||
. '<tr><td class="text-muted">Schedule</td><td>' . htmlspecialchars($friendlySchedule) . '</td></tr>'
|
||||
. '<tr><td class="text-muted">Next Reset</td><td>' . htmlspecialchars($nextFormatted) . ' ' . $nextBadge . '</td></tr>'
|
||||
. '<tr><td class="text-muted">Last Reset</td><td>' . htmlspecialchars($lastFormatted) . '</td></tr>'
|
||||
. '<tr><td class="text-muted">Runs</td><td>' . (int) ($task['times_executed'] ?? 0) . ' executed, ' . (int) ($task['times_failed'] ?? 0) . ' failed</td></tr>'
|
||||
. '<tr><td class="text-muted">Baseline</td><td>' . ($snapshotExists ? '<span class="badge bg-success">Saved</span>' : '<span class="badge bg-warning text-dark">Not taken yet</span>') . '</td></tr>'
|
||||
. '<tr><td class="text-muted">Banner</td><td>' . ($bannerOn ? 'On' : 'Off') . ($countdownOn ? ' + countdown' : '') . '</td></tr>'
|
||||
. '<tr><td class="text-muted">Images</td><td>' . ($mediaOn ? 'Included' : 'Excluded') . '</td></tr>'
|
||||
. '</table>'
|
||||
. '<a href="' . $editLink . '" class="btn btn-sm btn-outline-primary">'
|
||||
. '<span class="icon-cog" aria-hidden="true"></span> Manage Scheduled Task</a>'
|
||||
. '</div>';
|
||||
}
|
||||
|
||||
protected function getLabel()
|
||||
{
|
||||
return '<label class="form-label"><strong>Scheduled Reset</strong></label>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a cron expression to a human-readable string.
|
||||
*
|
||||
* @param string $cron Cron expression
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function friendlySchedule(string $cron): string
|
||||
{
|
||||
$map = [
|
||||
'* * * * *' => 'Every minute',
|
||||
'*/5 * * * *' => 'Every 5 minutes',
|
||||
'*/15 * * * *' => 'Every 15 minutes',
|
||||
'*/30 * * * *' => 'Every 30 minutes',
|
||||
'0 */1 * * *' => 'Every hour',
|
||||
'0 */4 * * *' => 'Every 4 hours',
|
||||
'0 */6 * * *' => 'Every 6 hours',
|
||||
'0 */12 * * *' => 'Every 12 hours',
|
||||
'0 0 * * *' => 'Daily at midnight',
|
||||
'0 6 * * *' => 'Daily at 6:00 AM',
|
||||
'0 0 * * 0' => 'Weekly (Sunday)',
|
||||
'0 0 1 * *' => 'Monthly (1st)',
|
||||
];
|
||||
|
||||
return $map[$cron] ?? 'Custom';
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage plg_system_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoWaaS
|
||||
* VERSION: 02.35.00
|
||||
* PATH: /src/Field/NextResetField.php
|
||||
* BRIEF: Read-only field showing next reset time from Joomla scheduled task
|
||||
*/
|
||||
|
||||
namespace Moko\Plugin\System\MokoWaaS\Field;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Form\FormField;
|
||||
|
||||
/**
|
||||
* Pulls the next execution time directly from the Joomla scheduled task
|
||||
* (#__scheduler_tasks) and displays it formatted in the site timezone.
|
||||
*
|
||||
* @since 02.29.00
|
||||
*/
|
||||
class NextResetField extends FormField
|
||||
{
|
||||
protected $type = 'NextReset';
|
||||
|
||||
protected function getInput()
|
||||
{
|
||||
// Check if demo mode is enabled
|
||||
$demoEnabled = false;
|
||||
|
||||
if ($this->form)
|
||||
{
|
||||
$demoEnabled = (int) $this->form->getValue('demo_mode_enabled', 'params', 0) === 1;
|
||||
}
|
||||
|
||||
if (!$demoEnabled)
|
||||
{
|
||||
return '<span class="form-control-plaintext text-muted">Demo mode is off</span>'
|
||||
. '<input type="hidden" name="' . $this->name . '" value="" />';
|
||||
}
|
||||
|
||||
// Query the actual next_execution from the scheduled task
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('next_execution'),
|
||||
$db->quoteName('last_execution'),
|
||||
$db->quoteName('state'),
|
||||
])
|
||||
->from($db->quoteName('#__scheduler_tasks'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('mokowaas.demo.reset'));
|
||||
|
||||
$db->setQuery($query);
|
||||
$task = $db->loadAssoc();
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$task = null;
|
||||
}
|
||||
|
||||
if (!$task)
|
||||
{
|
||||
return '<div class="alert alert-secondary mb-0 py-2">No scheduled task found — save to create one automatically.</div>'
|
||||
. '<input type="hidden" name="' . $this->name . '" value="" />';
|
||||
}
|
||||
|
||||
if ((int) $task['state'] !== 1)
|
||||
{
|
||||
return '<div class="alert alert-warning mb-0 py-2">Scheduled task is disabled.</div>'
|
||||
. '<input type="hidden" name="' . $this->name . '" value="" />';
|
||||
}
|
||||
|
||||
$nextExec = $task['next_execution'];
|
||||
$lastExec = $task['last_execution'];
|
||||
|
||||
if (empty($nextExec) || $nextExec === '0000-00-00 00:00:00')
|
||||
{
|
||||
return '<div class="alert alert-secondary mb-0 py-2">Waiting for first run...</div>'
|
||||
. '<input type="hidden" name="' . $this->name . '" value="" />';
|
||||
}
|
||||
|
||||
// Convert to site timezone
|
||||
$utcTimestamp = strtotime($nextExec);
|
||||
$siteTimezone = Factory::getApplication()->get('offset', 'UTC');
|
||||
|
||||
try
|
||||
{
|
||||
$dt = new \DateTime('@' . $utcTimestamp);
|
||||
$dt->setTimezone(new \DateTimeZone($siteTimezone));
|
||||
$formatted = $dt->format('l, F j, Y \a\t g:i A T');
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$formatted = $nextExec . ' UTC';
|
||||
}
|
||||
|
||||
// Relative time
|
||||
$diff = $utcTimestamp - time();
|
||||
$relative = '';
|
||||
|
||||
if ($diff <= 0)
|
||||
{
|
||||
$relative = '<span class="badge bg-warning text-dark">overdue</span>';
|
||||
}
|
||||
elseif ($diff < 3600)
|
||||
{
|
||||
$mins = (int) ceil($diff / 60);
|
||||
$relative = '<span class="badge bg-info">in ' . $mins . ' min</span>';
|
||||
}
|
||||
elseif ($diff < 86400)
|
||||
{
|
||||
$hours = round($diff / 3600, 1);
|
||||
$relative = '<span class="badge bg-info">in ' . $hours . 'h</span>';
|
||||
}
|
||||
else
|
||||
{
|
||||
$days = round($diff / 86400, 1);
|
||||
$relative = '<span class="badge bg-secondary">in ' . $days . 'd</span>';
|
||||
}
|
||||
|
||||
// Last run info
|
||||
$lastInfo = '';
|
||||
|
||||
if (!empty($lastExec) && $lastExec !== '0000-00-00 00:00:00')
|
||||
{
|
||||
try
|
||||
{
|
||||
$lastDt = new \DateTime($lastExec);
|
||||
$lastDt->setTimezone(new \DateTimeZone($siteTimezone));
|
||||
$lastInfo = '<small class="text-muted ms-2">Last run: ' . $lastDt->format('M j, g:i A') . '</small>';
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
// skip
|
||||
}
|
||||
}
|
||||
|
||||
return '<div class="d-flex align-items-center gap-2 flex-wrap">'
|
||||
. '<span class="form-control-plaintext" style="font-weight:500">'
|
||||
. '<span class="icon-calendar" aria-hidden="true"></span> '
|
||||
. htmlspecialchars($formatted) . '</span> '
|
||||
. $relative
|
||||
. $lastInfo
|
||||
. '<input type="hidden" name="' . $this->name . '" value="' . htmlspecialchars($nextExec) . '" />'
|
||||
. '</div>';
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage plg_system_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoWaaS
|
||||
* VERSION: 02.35.00
|
||||
* PATH: /src/Field/SnapshotTablesField.php
|
||||
* BRIEF: Multi-select list field that loads DB tables with sensible defaults
|
||||
*/
|
||||
|
||||
namespace Moko\Plugin\System\MokoWaaS\Field;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Form\FormField;
|
||||
|
||||
/**
|
||||
* Renders a multi-select list box of all Joomla database tables, with
|
||||
* content-related tables pre-selected by default.
|
||||
*
|
||||
* @since 02.26.00
|
||||
*/
|
||||
class SnapshotTablesField extends FormField
|
||||
{
|
||||
protected $type = 'SnapshotTables';
|
||||
|
||||
/**
|
||||
* Tables selected by default when no value is stored yet.
|
||||
*
|
||||
* @var array
|
||||
* @since 02.25.00
|
||||
*/
|
||||
private const DEFAULT_TABLES = [
|
||||
'#__content',
|
||||
'#__categories',
|
||||
'#__fields',
|
||||
'#__fields_values',
|
||||
'#__fields_groups',
|
||||
'#__menu',
|
||||
'#__menu_types',
|
||||
'#__modules',
|
||||
'#__modules_menu',
|
||||
'#__users',
|
||||
'#__user_usergroup_map',
|
||||
'#__user_profiles',
|
||||
'#__tags',
|
||||
'#__contentitem_tag_map',
|
||||
'#__assets',
|
||||
];
|
||||
|
||||
/**
|
||||
* Table suffixes grouped by category.
|
||||
*
|
||||
* @var array
|
||||
* @since 02.25.00
|
||||
*/
|
||||
private const TABLE_GROUPS = [
|
||||
'Content' => ['content', 'categories', 'fields', 'fields_values', 'fields_groups', 'tags', 'contentitem_tag_map', 'ucm_content', 'ucm_history'],
|
||||
'Users' => ['users', 'user_usergroup_map', 'user_profiles', 'usergroups', 'user_keys', 'user_mfa'],
|
||||
'Menus' => ['menu', 'menu_types'],
|
||||
'Modules' => ['modules', 'modules_menu'],
|
||||
'Assets' => ['assets'],
|
||||
];
|
||||
|
||||
protected function getInput()
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$prefix = $db->getPrefix();
|
||||
$tables = $db->getTableList();
|
||||
|
||||
// Resolve selected values
|
||||
$selected = $this->value;
|
||||
|
||||
if ($selected === null || $selected === '')
|
||||
{
|
||||
$selected = self::DEFAULT_TABLES;
|
||||
}
|
||||
elseif (is_string($selected))
|
||||
{
|
||||
$selected = array_filter(array_map('trim', explode("\n", $selected)));
|
||||
}
|
||||
|
||||
$selected = (array) $selected;
|
||||
|
||||
// Flatten nested arrays from broken save format [["#__content"],["#__categories"]]
|
||||
$selected = array_map(function ($v) {
|
||||
return is_array($v) ? reset($v) : $v;
|
||||
}, $selected);
|
||||
|
||||
// Group tables
|
||||
$grouped = [];
|
||||
|
||||
foreach ($tables as $table)
|
||||
{
|
||||
if (strpos($table, $prefix) !== 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$suffix = substr($table, strlen($prefix));
|
||||
$logical = '#__' . $suffix;
|
||||
$group = 'Other';
|
||||
|
||||
foreach (self::TABLE_GROUPS as $groupName => $patterns)
|
||||
{
|
||||
if (in_array($suffix, $patterns, true))
|
||||
{
|
||||
$group = $groupName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$grouped[$group][] = $logical;
|
||||
}
|
||||
|
||||
// Build HTML select with optgroups
|
||||
$size = (int) ($this->element['size'] ?? 15);
|
||||
$html = '<select name="' . $this->name . '" id="' . $this->id . '"'
|
||||
. ' multiple="multiple" size="' . $size . '"'
|
||||
. ' class="form-select">';
|
||||
|
||||
$priority = ['Content', 'Users', 'Menus', 'Modules', 'Assets'];
|
||||
|
||||
foreach ($priority as $g)
|
||||
{
|
||||
if (!empty($grouped[$g]))
|
||||
{
|
||||
$html .= '<optgroup label="' . $g . '">';
|
||||
|
||||
foreach ($grouped[$g] as $t)
|
||||
{
|
||||
$sel = in_array($t, $selected, true) ? ' selected="selected"' : '';
|
||||
$html .= '<option value="' . htmlspecialchars($t) . '"' . $sel . '>' . htmlspecialchars($t) . '</option>';
|
||||
}
|
||||
|
||||
$html .= '</optgroup>';
|
||||
unset($grouped[$g]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($grouped['Other']))
|
||||
{
|
||||
$html .= '<optgroup label="Other">';
|
||||
|
||||
foreach ($grouped['Other'] as $t)
|
||||
{
|
||||
$sel = in_array($t, $selected, true) ? ' selected="selected"' : '';
|
||||
$html .= '<option value="' . htmlspecialchars($t) . '"' . $sel . '>' . htmlspecialchars($t) . '</option>';
|
||||
}
|
||||
|
||||
$html .= '</optgroup>';
|
||||
}
|
||||
|
||||
$html .= '</select>';
|
||||
|
||||
// "Reset to defaults" link
|
||||
$defaultsJson = htmlspecialchars(json_encode(self::DEFAULT_TABLES), ENT_QUOTES, 'UTF-8');
|
||||
$html .= '<div class="mt-1">'
|
||||
. '<a href="#" class="small" onclick="'
|
||||
. 'var sel=document.getElementById(\'' . $this->id . '\');'
|
||||
. 'var defs=' . $defaultsJson . ';'
|
||||
. 'Array.from(sel.options).forEach(function(o){o.selected=defs.indexOf(o.value)!==-1;});'
|
||||
. 'return false;'
|
||||
. '"><span class="icon-refresh" aria-hidden="true"></span> Reset to defaults</a>'
|
||||
. '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
|
||||
|
||||
This file is part of a Moko Consulting project.
|
||||
|
||||
SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later
|
||||
|
||||
This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the IMPLIED WARRANTY of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License (./LICENSE.md).
|
||||
|
||||
# FILE INFORMATION
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.35.00
|
||||
PATH: /src/mokowaas.xml
|
||||
BRIEF: Plugin manifest for MokoWaaS system plugin
|
||||
NOTE: Defines installation metadata, files, and configuration for Joomla
|
||||
-->
|
||||
<extension type="plugin" group="system" method="upgrade">
|
||||
<name>System - MokoWaaS</name>
|
||||
<element>mokowaas</element>
|
||||
<author>Moko Consulting</author>
|
||||
<creationDate>2026-05-22</creationDate>
|
||||
<copyright>Copyright (C) 2025 Moko Consulting. All rights reserved.</copyright>
|
||||
<license>GNU General Public License version 3 or later; see LICENSE.md</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.34.00</version>
|
||||
<description>This plugin rebrands the Joomla system interface with MokoWaaS identity. It applies language overrides and ensures consistent branding across the platform.</description>
|
||||
<namespace path=".">Moko\Plugin\System\MokoWaaS</namespace>
|
||||
<scriptfile>script.php</scriptfile>
|
||||
|
||||
<files>
|
||||
<filename plugin="mokowaas">script.php</filename>
|
||||
<folder>Extension</folder>
|
||||
<folder>Field</folder>
|
||||
<folder>Helper</folder>
|
||||
<folder>Service</folder>
|
||||
<folder>forms</folder>
|
||||
<folder>payload</folder>
|
||||
<folder>services</folder>
|
||||
<folder>language</folder>
|
||||
<folder>administrator</folder>
|
||||
</files>
|
||||
|
||||
<media destination="plg_system_mokowaas" folder="media">
|
||||
<filename>index.html</filename>
|
||||
<filename>favicon.ico</filename>
|
||||
<filename>favicon.svg</filename>
|
||||
<filename>favicon_256.png</filename>
|
||||
<filename>logo.png</filename>
|
||||
</media>
|
||||
|
||||
<languages folder="language">
|
||||
<language tag="en-GB">en-GB/plg_system_mokowaas.ini</language>
|
||||
<language tag="en-US">en-US/plg_system_mokowaas.ini</language>
|
||||
</languages>
|
||||
|
||||
<languages folder="administrator/language">
|
||||
<language tag="en-GB">en-GB/plg_system_mokowaas.sys.ini</language>
|
||||
<language tag="en-US">en-US/plg_system_mokowaas.sys.ini</language>
|
||||
</languages>
|
||||
|
||||
<administration>
|
||||
<files folder="administrator">
|
||||
<folder>language</folder>
|
||||
</files>
|
||||
</administration>
|
||||
|
||||
<config>
|
||||
<fields name="params"
|
||||
addfieldprefix="Moko\Plugin\System\MokoWaaS\Field"
|
||||
>
|
||||
<fieldset name="basic">
|
||||
<field
|
||||
name="health_api_token"
|
||||
type="CopyableToken"
|
||||
label="PLG_SYSTEM_MOKOWAAS_HEALTH_TOKEN_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_HEALTH_TOKEN_DESC"
|
||||
default=""
|
||||
filter="raw"
|
||||
readonly="true"
|
||||
/>
|
||||
<field name="dev_mode" type="radio" default="0"
|
||||
label="PLG_SYSTEM_MOKOWAAS_DEV_MODE_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_DEV_MODE_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="reset_hits"
|
||||
type="radio"
|
||||
label="PLG_SYSTEM_MOKOWAAS_RESET_HITS_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_RESET_HITS_DESC"
|
||||
default="0"
|
||||
class="btn-group btn-group-yesno"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="delete_versions"
|
||||
type="radio"
|
||||
label="PLG_SYSTEM_MOKOWAAS_DELETE_VERSIONS_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_DELETE_VERSIONS_DESC"
|
||||
default="0"
|
||||
class="btn-group btn-group-yesno"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
<fieldset name="tenant_restrictions"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIELDSET_TENANT_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIELDSET_TENANT_DESC"
|
||||
>
|
||||
<field name="restrict_installer" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_RESTRICT_INSTALLER_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_RESTRICT_INSTALLER_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="allow_extension_updates" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_ALLOW_UPDATES_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_ALLOW_UPDATES_DESC"
|
||||
class="btn-group btn-group-yesno"
|
||||
showon="restrict_installer:1">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="hide_sysinfo" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_HIDE_SYSINFO_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_HIDE_SYSINFO_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="restrict_global_config" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_RESTRICT_CONFIG_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_RESTRICT_CONFIG_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="restrict_template_editing" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_RESTRICT_TEMPLATE_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_RESTRICT_TEMPLATE_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="disable_install_url" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_DISABLE_INSTALL_URL_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_DISABLE_INSTALL_URL_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="hidden_menu_items" type="textarea"
|
||||
label="PLG_SYSTEM_MOKOWAAS_HIDDEN_MENUS_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_HIDDEN_MENUS_DESC"
|
||||
rows="5" filter="raw" />
|
||||
</fieldset>
|
||||
<fieldset name="demo_mode"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIELDSET_DEMO_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIELDSET_DEMO_DESC"
|
||||
addfieldprefix="Moko\Plugin\System\MokoWaaS\Field"
|
||||
>
|
||||
<field name="demo_scheduled_task" type="DemoTaskInfo"
|
||||
label="PLG_SYSTEM_MOKOWAAS_DEMO_TASK_INFO_LABEL"
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset name="security"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIELDSET_SECURITY_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIELDSET_SECURITY_DESC"
|
||||
addfieldprefix="Moko\Plugin\System\MokoWaaS\Field"
|
||||
>
|
||||
<field
|
||||
name="emergency_access"
|
||||
type="radio"
|
||||
label="PLG_SYSTEM_MOKOWAAS_EMERGENCY_ACCESS_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_EMERGENCY_ACCESS_DESC"
|
||||
default="1"
|
||||
class="btn-group btn-group-yesno"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="allowed_ips_display"
|
||||
type="AllowedIps"
|
||||
label=""
|
||||
/>
|
||||
<field name="force_https" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FORCE_HTTPS_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FORCE_HTTPS_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="admin_session_timeout" type="number"
|
||||
label="PLG_SYSTEM_MOKOWAAS_SESSION_TIMEOUT_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_SESSION_TIMEOUT_DESC"
|
||||
default="60" hint="Minutes (0 = Joomla default)" />
|
||||
<field
|
||||
name="current_ip_display"
|
||||
type="CurrentIp"
|
||||
label=""
|
||||
/>
|
||||
<field
|
||||
name="trusted_ips"
|
||||
type="subform"
|
||||
label="PLG_SYSTEM_MOKOWAAS_TRUSTED_IPS_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_TRUSTED_IPS_DESC"
|
||||
formsource="plugins/system/mokowaas/forms/trusted_ip_entry.xml"
|
||||
multiple="true"
|
||||
layout="joomla.form.field.subform.repeatable-table"
|
||||
groupByFieldset="false"
|
||||
buttons="add,remove,move"
|
||||
/>
|
||||
<field name="password_min_length" type="number" default="12"
|
||||
label="PLG_SYSTEM_MOKOWAAS_PASSWORD_LENGTH_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_PASSWORD_LENGTH_DESC" />
|
||||
<field name="password_require_uppercase" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_PASSWORD_UPPER_LABEL"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="password_require_number" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_PASSWORD_NUMBER_LABEL"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="password_require_special" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_PASSWORD_SPECIAL_LABEL"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="upload_allowed_types" type="text"
|
||||
label="PLG_SYSTEM_MOKOWAAS_UPLOAD_TYPES_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_UPLOAD_TYPES_DESC"
|
||||
default="jpg,jpeg,png,gif,webp,svg,pdf,doc,docx,xls,xlsx" />
|
||||
<field name="upload_max_size_mb" type="number"
|
||||
label="PLG_SYSTEM_MOKOWAAS_UPLOAD_SIZE_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_UPLOAD_SIZE_DESC"
|
||||
default="100" />
|
||||
</fieldset>
|
||||
</fields>
|
||||
</config>
|
||||
</extension>
|
||||
Reference in New Issue
Block a user