diff --git a/.mokogitea/CLAUDE.md b/.mokogitea/CLAUDE.md index ef8175ef..5a7679e0 100644 --- a/.mokogitea/CLAUDE.md +++ b/.mokogitea/CLAUDE.md @@ -38,7 +38,7 @@ Joomla **package** (`pkg_mokosuiteclient`) with 17 sub-extensions: ### Component (`com_mokosuiteclient`) - Admin dashboard with plugin management, WAF charts, extension catalog -- Helpdesk ticketing system +- Content tools: snippets, templates, replacements, conditions, articles anywhere, users anywhere - REST API controllers ### Modules @@ -50,7 +50,6 @@ Joomla **package** (`pkg_mokosuiteclient`) with 17 sub-extensions: ### Task Plugins - `plg_task_mokosuiteclientdemo` — scheduled demo site reset - `plg_task_mokosuiteclientsync` — scheduled content sync -- `plg_task_mokosuiteclient_tickets` — ticket automation ### Update Server diff --git a/.mokogitea/workflows/deploy-manual.yml b/.mokogitea/workflows/deploy-manual.yml deleted file mode 100644 index bb133edd..00000000 --- a/.mokogitea/workflows/deploy-manual.yml +++ /dev/null @@ -1,126 +0,0 @@ -# Copyright (C) 2026 Moko Consulting -# -# SPDX-License-Identifier: GPL-3.0-or-later -# -# FILE INFORMATION -# DEFGROUP: Gitea.Workflow -# INGROUP: MokoStandards.Deploy -# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API -# PATH: /templates/workflows/joomla/deploy-manual.yml.template -# VERSION: 04.07.00 -# BRIEF: Manual SFTP deploy to dev server for Joomla repos - -name: "Universal: Deploy to Dev (Manual)" - -on: - workflow_dispatch: - inputs: - clear_remote: - description: 'Delete all remote files before uploading' - required: false - default: 'false' - type: boolean - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -permissions: - contents: read - -jobs: - deploy: - name: SFTP Deploy to Dev - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Setup PHP - run: | - php -v && composer --version - - - name: Setup MokoStandards tools - env: - GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }} - MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }} - MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }} - COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}' - run: | - git clone --depth 1 --branch main --quiet \ - "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \ - /tmp/mokostandards-api 2>/dev/null || true - if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then - cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true - fi - - - name: Check FTP configuration - id: check - env: - HOST: ${{ vars.DEV_FTP_HOST }} - PATH_VAR: ${{ vars.DEV_FTP_PATH }} - PORT: ${{ vars.DEV_FTP_PORT }} - run: | - if [ -z "$HOST" ] || [ -z "$PATH_VAR" ]; then - echo "DEV_FTP_HOST or DEV_FTP_PATH not configured -- cannot deploy" - echo "skip=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - echo "skip=false" >> "$GITHUB_OUTPUT" - echo "host=$HOST" >> "$GITHUB_OUTPUT" - - REMOTE="${PATH_VAR%/}" - echo "remote=$REMOTE" >> "$GITHUB_OUTPUT" - - [ -z "$PORT" ] && PORT="22" - echo "port=$PORT" >> "$GITHUB_OUTPUT" - - - name: Deploy via SFTP - if: steps.check.outputs.skip != 'true' - env: - SFTP_KEY: ${{ secrets.DEV_FTP_KEY }} - SFTP_PASS: ${{ secrets.DEV_FTP_PASSWORD }} - SFTP_USER: ${{ vars.DEV_FTP_USERNAME }} - run: | - SOURCE_DIR="src" - [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs" - [ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ -- nothing to deploy"; exit 0; } - - printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \ - "${{ steps.check.outputs.host }}" "${{ steps.check.outputs.port }}" "$SFTP_USER" "${{ steps.check.outputs.remote }}" \ - > /tmp/sftp-config.json - - if [ -n "$SFTP_KEY" ]; then - echo "$SFTP_KEY" > /tmp/deploy_key - chmod 600 /tmp/deploy_key - printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json - else - printf ',"password":"%s"}' "$SFTP_PASS" >> /tmp/sftp-config.json - fi - - DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json) - [ "${{ inputs.clear_remote }}" = "true" ] && DEPLOY_ARGS+=(--clear-remote) - - PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true) - if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then - php /tmp/mokostandards-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}" - else - php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}" - fi - - rm -f /tmp/deploy_key /tmp/sftp-config.json - - - name: Summary - if: always() - run: | - if [ "${{ steps.check.outputs.skip }}" = "true" ]; then - echo "### Deploy Skipped -- FTP not configured" >> $GITHUB_STEP_SUMMARY - else - echo "### Manual Dev Deploy Complete" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY - echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY - echo "| Host | \`${{ steps.check.outputs.host }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| Remote | \`${{ steps.check.outputs.remote }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| Clear | ${{ inputs.clear_remote }} |" >> $GITHUB_STEP_SUMMARY - fi diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index c24f59b6..f808deda 100644 --- a/.mokogitea/workflows/issue-branch.yml +++ b/.mokogitea/workflows/issue-branch.yml @@ -5,7 +5,7 @@ # FILE INFORMATION # DEFGROUP: Gitea.Workflow # INGROUP: moko-platform.Automation -# VERSION: 02.47.48 +# VERSION: 02.47.81 # BRIEF: Auto-create feature branch when an issue is opened name: "Universal: Issue Branch" diff --git a/.mokogitea/workflows/security-audit.yml b/.mokogitea/workflows/security-audit.yml deleted file mode 100644 index 789325a2..00000000 --- a/.mokogitea/workflows/security-audit.yml +++ /dev/null @@ -1,82 +0,0 @@ -# Copyright (C) 2026 Moko Consulting -# -# SPDX-License-Identifier: GPL-3.0-or-later -# -# FILE INFORMATION -# DEFGROUP: Gitea.Workflow -# INGROUP: MokoStandards.Security -# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards -# PATH: /.gitea/workflows/security-audit.yml -# VERSION: 01.00.00 -# BRIEF: Dependency vulnerability scanning for composer and npm packages - -name: "Universal: Security Audit" - -on: - schedule: - - cron: '0 6 * * 1' # Weekly on Monday at 06:00 UTC - pull_request: - branches: - - main - paths: - - 'composer.json' - - 'composer.lock' - - 'package.json' - - 'package-lock.json' - workflow_dispatch: - -permissions: - contents: read - -env: - NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }} - NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }} - -jobs: - audit: - name: Dependency Audit - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Composer audit - if: hashFiles('composer.lock') != '' - run: | - echo "=== Composer Security Audit ===" - if ! command -v composer &> /dev/null; then - sudo apt-get update -qq - sudo apt-get install -y -qq php-cli composer >/dev/null 2>&1 - fi - composer audit --format=plain 2>&1 | tee /tmp/composer-audit.txt - RESULT=$? - if [ $RESULT -ne 0 ]; then - echo "::warning::Composer vulnerabilities found" - echo "composer_vulnerable=true" >> "$GITHUB_ENV" - else - echo "No known vulnerabilities in composer dependencies" - fi - - - name: NPM audit - if: hashFiles('package-lock.json') != '' - run: | - echo "=== NPM Security Audit ===" - npm audit --production 2>&1 | tee /tmp/npm-audit.txt || true - if npm audit --production 2>&1 | grep -q "found 0 vulnerabilities"; then - echo "No known vulnerabilities in npm dependencies" - else - echo "::warning::NPM vulnerabilities found" - echo "npm_vulnerable=true" >> "$GITHUB_ENV" - fi - - - name: Notify on vulnerabilities - if: env.composer_vulnerable == 'true' || env.npm_vulnerable == 'true' - run: | - REPO="${{ github.event.repository.name }}" - curl -sS \ - -H "Title: ${REPO} has vulnerable dependencies" \ - -H "Tags: lock,warning" \ - -H "Priority: high" \ - -d "Security audit found vulnerabilities. Review dependency updates." \ - "${NTFY_URL}/${NTFY_TOPIC}" || true diff --git a/CHANGELOG.md b/CHANGELOG.md index 0255a01c..8686f3ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ INGROUP: MokoSuiteClient.Documentation REPO: https://github.com/mokoconsulting-tech/mokosuiteclient PATH: ./CHANGELOG.md - VERSION: 02.47.48 + VERSION: 02.47.81 BRIEF: Version history using `Keep a Changelog` --> diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 551d08bc..57c3a0cd 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -14,7 +14,7 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuiteClient.Documentation REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - VERSION: 02.47.48 + VERSION: 02.47.81 PATH: ./CODE_OF_CONDUCT.md BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default --> diff --git a/GOVERNANCE.md b/GOVERNANCE.md index daf9a055..77c69ab6 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -19,7 +19,7 @@ DEFGROUP: mokoconsulting-tech.MokoSuiteClientBrand INGROUP: MokoStandards.Governance REPO: https://github.com/mokoconsulting-tech/MokoSuiteClientBrand - VERSION: 02.47.48 + VERSION: 02.47.81 PATH: /GOVERNANCE.md BRIEF: Project governance rules, roles, and decision process for MokoSuiteClientBrand --> diff --git a/LICENSE.md b/LICENSE.md index f7f03e47..d9c35cae 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -15,7 +15,7 @@ INGROUP: MokoSuiteClient.Documentation REPO: https://github.com/mokoconsulting-tech/mokosuiteclient PATH: ./LICENSE.md - VERSION: 02.47.48 + VERSION: 02.47.81 BRIEF: Project license (GPL-3.0-or-later) --> GNU GENERAL PUBLIC LICENSE diff --git a/README.md b/README.md index 260363a6..4469ff7e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuiteClient REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient - VERSION: 02.47.48 + VERSION: 02.47.81 PATH: /README.md BRIEF: MokoSuiteClient platform plugin for Joomla --> diff --git a/SECURITY.md b/SECURITY.md index 5efee604..322eb5f4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -23,7 +23,7 @@ DEFGROUP: [PROJECT_NAME] INGROUP: [PROJECT_NAME].Documentation REPO: [REPOSITORY_URL] PATH: /SECURITY.md -VERSION: 02.47.48 +VERSION: 02.47.81 BRIEF: Security vulnerability reporting and handling policy --> diff --git a/docs/guides/build-guide.md b/docs/guides/build-guide.md index a141e915..59cc058e 100644 --- a/docs/guides/build-guide.md +++ b/docs/guides/build-guide.md @@ -11,13 +11,13 @@ INGROUP: MokoSuiteClient.Build REPO: https://github.com/mokoconsulting-tech/mokosuiteclient FILE: build-guide.md - VERSION: 02.47.48 + VERSION: 02.47.81 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.47.48) +# MokoSuiteClient Build Guide (VERSION: 02.47.81) ## 1. Purpose diff --git a/docs/guides/configuration-guide.md b/docs/guides/configuration-guide.md index 492fd1be..4532c6e9 100644 --- a/docs/guides/configuration-guide.md +++ b/docs/guides/configuration-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuiteClient.Guides REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - VERSION: 02.47.48 + VERSION: 02.47.81 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.47.48) +# MokoSuiteClient Configuration Guide (VERSION: 02.47.81) ## 1. Objective diff --git a/docs/guides/installation-guide.md b/docs/guides/installation-guide.md index fb2d2b9d..e9fac73b 100644 --- a/docs/guides/installation-guide.md +++ b/docs/guides/installation-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuiteClient.Guides REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - VERSION: 02.47.48 + VERSION: 02.47.81 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.47.48) +# MokoSuiteClient Installation Guide (VERSION: 02.47.81) ## Introduction diff --git a/docs/guides/operations-guide.md b/docs/guides/operations-guide.md index 9fb283f3..0a7958e2 100644 --- a/docs/guides/operations-guide.md +++ b/docs/guides/operations-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuiteClient.Guides REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - VERSION: 02.47.48 + VERSION: 02.47.81 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.47.48) +# MokoSuiteClient Operations Guide (VERSION: 02.47.81) ## Introduction diff --git a/docs/guides/rollback-and-recovery-guide.md b/docs/guides/rollback-and-recovery-guide.md index d4589ffa..3a0b129a 100644 --- a/docs/guides/rollback-and-recovery-guide.md +++ b/docs/guides/rollback-and-recovery-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuiteClient.Guides REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - VERSION: 02.47.48 + VERSION: 02.47.81 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.47.48) +# MokoSuiteClient Rollback and Recovery Guide (VERSION: 02.47.81) ## Introduction diff --git a/docs/guides/testing-guide.md b/docs/guides/testing-guide.md index 254cc67b..8e7b963f 100644 --- a/docs/guides/testing-guide.md +++ b/docs/guides/testing-guide.md @@ -7,13 +7,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuiteClient.Guides REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - VERSION: 02.47.48 + VERSION: 02.47.81 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.47.48) +# MokoSuiteClient Testing Guide (VERSION: 02.47.81) ## 1. Prerequisites diff --git a/docs/guides/troubleshooting-guide.md b/docs/guides/troubleshooting-guide.md index 622233a3..53ccd159 100644 --- a/docs/guides/troubleshooting-guide.md +++ b/docs/guides/troubleshooting-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuiteClient.Guides REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - VERSION: 02.47.48 + VERSION: 02.47.81 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.47.48) +# MokoSuiteClient Troubleshooting Guide (VERSION: 02.47.81) ## Introduction diff --git a/docs/guides/upgrade-and-versioning-guide.md b/docs/guides/upgrade-and-versioning-guide.md index 954d33a8..3d7f832e 100644 --- a/docs/guides/upgrade-and-versioning-guide.md +++ b/docs/guides/upgrade-and-versioning-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuiteClient.Guides REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - VERSION: 02.47.48 + VERSION: 02.47.81 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.47.48) +# MokoSuiteClient Upgrade and Versioning Guide (VERSION: 02.47.81) ## Introduction diff --git a/docs/index.md b/docs/index.md index 80439ffd..fb5daffd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoSuiteClient.Documentation REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - VERSION: 02.47.48 + VERSION: 02.47.81 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.47.48) +# MokoSuiteClient Documentation Index (VERSION: 02.47.81) ## Introduction diff --git a/docs/plugin-basic.md b/docs/plugin-basic.md index 966fb7b9..6890322a 100644 --- a/docs/plugin-basic.md +++ b/docs/plugin-basic.md @@ -11,12 +11,12 @@ INGROUP: MokoSuiteClient REPO: https://github.com/mokoconsulting-tech/mokosuiteclient PATH: /docs/plugin-basic.md - VERSION: 02.47.48 + VERSION: 02.47.81 BRIEF: Baseline documentation for the MokoSuiteClient system plugin NOTE: Foundational reference for internal and external stakeholders --> -# MokoSuiteClient Plugin Overview (VERSION: 02.47.48) +# MokoSuiteClient Plugin Overview (VERSION: 02.47.81) ## Introduction diff --git a/docs/update-server.md b/docs/update-server.md index de1fb183..a9076cd5 100644 --- a/docs/update-server.md +++ b/docs/update-server.md @@ -10,7 +10,7 @@ DEFGROUP: MokoSuiteClient.Documentation INGROUP: MokoStandards.Templates REPO: https://github.com/mokoconsulting-tech/MokoSuiteClient PATH: /docs/update-server.md -VERSION: 02.47.48 +VERSION: 02.47.81 BRIEF: How this extension's Joomla update server file (update.xml) is managed --> diff --git a/source/packages/com_mokosuiteclient/admin/access.xml b/source/packages/com_mokosuiteclient/admin/access.xml index 41d8a779..ca153162 100644 --- a/source/packages/com_mokosuiteclient/admin/access.xml +++ b/source/packages/com_mokosuiteclient/admin/access.xml @@ -1,15 +1,29 @@
+ + + - - - - - + + + + + + + + + + + + + + + +
diff --git a/source/packages/com_mokosuiteclient/admin/catalog.xml b/source/packages/com_mokosuiteclient/admin/catalog.xml index d18e918a..b1598f85 100644 --- a/source/packages/com_mokosuiteclient/admin/catalog.xml +++ b/source/packages/com_mokosuiteclient/admin/catalog.xml @@ -1,122 +1,219 @@ + MokoSuiteClient pkg_mokosuiteclient package - Admin dashboard, security firewall, tenant restrictions, health monitoring, and REST API. + Admin dashboard, security firewall, tenant restrictions, health monitoring, content tools, and REST API. icon-shield-alt Platform -
https://mokoconsulting.tech/support/products/mokosuiteclient-platform
true - https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/raw/branch/dev/updates.xml + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient/raw/branch/main/updates.xml
- MokoSuiteClientHQ - pkg_mokosuiteclienthq + MokoSuiteHQ + pkg_mokosuitehq package - Centralized control panel for managing all MokoSuiteClient client installations. + Centralized control panel for managing all MokoSuite client installations. icon-tachometer-alt Platform -
https://mokoconsulting.tech/support/products/mokosuiteclient-base
- https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClientHQ/raw/branch/dev/updates.xml + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteHQ/raw/branch/main/updates.xml
- MokoOnyx - mokoonyx - template - Modern Joomla site template with dark mode, custom layouts, and MokoSuiteClient integration. - icon-paint-brush - Templates -
https://mokoconsulting.tech/support/products/mokoonyx-template
- https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/raw/branch/dev/updates.xml -
- - MokoJoomOpenGraph - pkg_mokoog - package - Open Graph, Twitter Card, and social sharing meta tags for articles, categories, and pages. - icon-share-alt - SEO -
https://mokoconsulting.tech/support/products/mokojoomopengraph
- https://git.mokoconsulting.tech/MokoConsulting/MokoJoomOpenGraph/raw/branch/dev/updates.xml -
- - MokoSuiteClientBackup - pkg_mokojoombackup + MokoSuiteBackup + pkg_mokosuitebackup package Full-site backup and restore for Joomla — database, files, and configuration. icon-archive - Tools -
https://mokoconsulting.tech/support/products/mokosuiteclientbackup
- https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClientBackup/raw/branch/dev/updates.xml + Platform + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/raw/branch/main/updates.xml
+ + + + MokoSuiteCRM + pkg_mokosuitecrm + package + Layer 1 — Contacts, deals pipeline, activities, e-signature, email integration, helpdesk. + icon-address-book + Business + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCRM/raw/branch/main/updates.xml + + + MokoSuiteERP + pkg_mokosuiteerp + package + Layer 2 — Products, orders, invoicing, inventory, warehouses, accounting, payments. + icon-briefcase + Business + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteERP/raw/branch/main/updates.xml + + + MokoSuiteShop + pkg_mokosuiteshop + package + Layer 3 — Product catalog, shopping cart, checkout, coupons. Requires MokoSuiteERP. + icon-shopping-cart + Business + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteShop/raw/branch/main/updates.xml + + + MokoSuitePOS + pkg_mokosuitepos + package + Layer 3 — Touch-screen POS, multi-terminal, cash register, receipt printing. + icon-calculator + Business + https://git.mokoconsulting.tech/MokoConsulting/MokoSuitePOS/raw/branch/main/updates.xml + + + MokoSuiteMRP + pkg_mokosuitemrp + package + Layer 3 — BOM, manufacturing orders, workstation management, production scheduling. + icon-cog + Business + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteMRP/raw/branch/main/updates.xml + + + MokoSuiteHRM + pkg_mokosuitehrm + package + Layer 3 — Human Resource Management: employees, leave, expenses, payroll, recruiting. + icon-users + Business + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteHRM/raw/branch/main/updates.xml + + + MokoSuiteRestaurant + pkg_mokosuiterestaurant + package + Layer 4 — Floor plan, table management, kitchen display, split bills, online ordering. + icon-utensils + Industry + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteRestaurant/raw/branch/main/updates.xml + + + MokoSuiteChild + pkg_mokosuitechild + package + Layer 2 — Child Care Management: enrollment, attendance, billing, parent portal. + icon-child + Industry + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteChild/raw/branch/main/updates.xml + + + MokoSuiteNPO + pkg_mokosuitenpo + package + Nonprofit management: donors, donations, campaigns, grants, volunteers, events. + icon-heart + Industry + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteNPO/raw/branch/main/updates.xml + + + MokoSuiteField + pkg_mokosuitefield + package + Field Service — dispatch, work orders, scheduling, mobile tech, plumbing/HVAC. + icon-wrench + Industry + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteField/raw/branch/main/updates.xml + + + MokoSuiteCreate + pkg_mokosuitecreate + package + Layer 2 — Creative Agency: projects, tasks, timesheets, client proofing. + icon-paint-brush + Industry + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCreate/raw/branch/main/updates.xml + + + + + MokoSuiteForms + pkg_mokosuiteforms + package + Form builder — custom forms, submissions, notifications, and data exports. + icon-list-alt + Content + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteForms/raw/branch/main/updates.xml + + + MokoSuiteCommunity + pkg_mokosuitecommunity + package + Community profiles, connections, and activity streams for Joomla. + icon-users + Content + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCommunity/raw/branch/main/updates.xml + + + MokoSuiteCross + pkg_mokosuitecross + package + Cross-posting Joomla content to social media, email marketing, and chat platforms. + icon-share-alt + Content + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCross/raw/branch/main/updates.xml + + + MokoSuiteOpenGraph + pkg_mokosuiteopengraph + package + Open Graph, Twitter Card, JSON-LD structured data, and social sharing meta tags. + icon-share-alt + SEO + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteOpenGraph/raw/branch/main/updates.xml + + + MokoSuiteStoreLocator + pkg_mokosuitestorelocator + package + Interactive map, location search, and admin management for store locations. + icon-map-marker-alt + Content + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteStoreLocator/raw/branch/main/updates.xml + + + MokoJoomHero mod_mokojoomhero module - Random hero image module from a configurable folder. + Hero module — image slideshow, video backgrounds, solid color/gradient, parallax. icon-image Modules -
https://mokoconsulting.tech/support/products/mokojoomhero
- https://git.mokoconsulting.tech/MokoConsulting/MokoJoomHero/raw/branch/dev/updates.xml + https://git.mokoconsulting.tech/MokoConsulting/MokoJoomHero/raw/branch/main/updates.xml
+ + - MokoJoomCommunity - pkg_mokojoomcommunity - package - Community Builder integration package with custom fields and user management. - icon-users - Community -
https://mokoconsulting.tech/support/products/mokojoomcommunity
- https://git.mokoconsulting.tech/MokoConsulting/MokoJoomCommunity/raw/branch/dev/updates.xml -
- - MokoJoomCross - plg_system_mokojoomcross - plugin - Cross-extension integration plugin for Joomla component interoperability. - icon-link - Plugins -
https://mokoconsulting.tech/support/products/mokojoomcross
- https://git.mokoconsulting.tech/MokoConsulting/MokoJoomCross/raw/branch/dev/updates.xml -
- - MokoJoomStoreLocator - mod_mokojoomstorelocator - module - Store locator module with Google Maps integration and search. - icon-map-marker-alt - Modules -
https://mokoconsulting.tech/support/products/mokojoomstorelocator
- https://git.mokoconsulting.tech/MokoConsulting/MokoJoomStoreLocator/raw/branch/dev/updates.xml -
- - DPCalendar API - mokodpcalendarapi - plugin - Web Services plugin exposing DPCalendar events and calendars via REST API. - icon-calendar - Plugins -
https://mokoconsulting.tech/support/products/mokodpcalendarapi
- https://git.mokoconsulting.tech/MokoConsulting/MokoDPCalendarAPI/raw/branch/dev/updates.xml -
- - Gallery Calendar - mokogallerycalendar - plugin - JoomGallery and DPCalendar integration — link galleries to events. - icon-images - Plugins -
https://mokoconsulting.tech/support/products/mokogallerycalendar
- https://git.mokoconsulting.tech/MokoConsulting/MokoGalleryCalendar/raw/branch/dev/updates.xml + MokoOnyx + mokoonyx + template + Modern Joomla site template with dark mode, custom layouts, and MokoSuite integration. + icon-paint-brush + Templates + https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/raw/branch/main/updates.xml
diff --git a/source/packages/com_mokosuiteclient/admin/config.xml b/source/packages/com_mokosuiteclient/admin/config.xml index 82ebee95..c00ca1e4 100644 --- a/source/packages/com_mokosuiteclient/admin/config.xml +++ b/source/packages/com_mokosuiteclient/admin/config.xml @@ -1,17 +1,17 @@
- + hint="MokoSuite" />
-
+
@@ -40,13 +40,13 @@ label="ntfy Server URL" description="Full URL to your ntfy server." showon="ntfy_enabled:1" /> - -
-
- - - - - - - - - - - +
+ + + + + + + +
-
- - - - - - - - - - +
+ + + +
required permission. */ private const VIEW_ACL = [ - 'dashboard' => 'mokosuiteclient.dashboard', - 'extensions' => 'mokosuiteclient.extensions', - 'htaccess' => 'mokosuiteclient.htaccess', - 'tickets' => 'mokosuiteclient.tickets', - 'ticket' => 'mokosuiteclient.tickets', - 'privacy' => 'core.admin', - 'waflog' => 'core.admin', - 'categories' => 'mokosuiteclient.tickets', - 'canned' => 'mokosuiteclient.tickets', - 'automation' => 'core.admin', - 'database' => 'core.admin', - 'cleanup' => 'mokosuiteclient.cache', - 'ticketsettings' => 'core.admin', + 'dashboard' => 'mokosuiteclient.dashboard', + 'extensions' => 'mokosuiteclient.extensions', + 'htaccess' => 'mokosuiteclient.htaccess', + 'privacy' => 'core.admin', + 'waflog' => 'mokosuiteclient.security.waflog', + 'automation' => 'core.admin', + 'database' => 'core.admin', + 'cleanup' => 'mokosuiteclient.cache', + 'snippets' => 'mokosuiteclient.snippets.manage', + 'templates' => 'mokosuiteclient.templates.manage', + 'replacements' => 'mokosuiteclient.replacements.manage', + 'conditions' => 'mokosuiteclient.conditions.manage', ]; public function display($cachable = false, $urlparams = []) @@ -142,6 +141,22 @@ class DisplayController extends BaseController $domain = parse_url($siteUrl, PHP_URL_HOST) ?: ''; $timestamp = time(); + // Discover all MokoSuite ecosystem packages for HQ + $mokoPackages = []; + try { + $pkgDb = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); + $pkgQuery = $pkgDb->getQuery(true) + ->select([$pkgDb->quoteName('element'), $pkgDb->quoteName('manifest_cache')]) + ->from($pkgDb->quoteName('#__extensions')) + ->where('(' . $pkgDb->quoteName('element') . ' LIKE ' . $pkgDb->quote('pkg_mokosuite%') + . ' OR ' . $pkgDb->quoteName('element') . ' LIKE ' . $pkgDb->quote('pkg_mokojoom%') . ')'); + $pkgDb->setQuery($pkgQuery); + foreach ($pkgDb->loadObjectList() ?: [] as $pkg) { + $m = json_decode($pkg->manifest_cache ?? '{}'); + $mokoPackages[$pkg->element] = $m->version ?? ''; + } + } catch (\Throwable $e) {} + $payload = json_encode([ 'token' => $healthToken, 'domain' => $domain, @@ -150,6 +165,7 @@ class DisplayController extends BaseController 'joomla_version' => (new \Joomla\CMS\Version())->getShortVersion(), 'php_version' => PHP_VERSION, 'timestamp' => $timestamp, + 'moko_packages' => $mokoPackages, ], JSON_UNESCAPED_SLASHES); // RSA sign the request @@ -348,186 +364,67 @@ class DisplayController extends BaseController } // ================================================================== - // Tickets + // Support PIN // ================================================================== - public function createTicket() + public function requestPin() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); - if (!$this->checkAcl('mokosuiteclient.tickets.create')) + if (!$this->checkAcl('mokosuiteclient.dashboard')) { $this->jsonForbidden(); - return; - } - - $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), - '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'), - ])); - } - - public function addTicketReply() - { - Session::checkToken() or die(Text::_('JINVALID_TOKEN')); - - if (!$this->checkAcl('mokosuiteclient.tickets')) - { - $this->jsonForbidden(); - return; - } - - $input = Factory::getApplication()->getInput(); - - $this->jsonResponse($this->getModel('Tickets')->addReply( - $input->getInt('ticket_id', 0), - $input->getRaw('body', ''), - (bool) $input->getInt('is_internal', 0) - )); - } - - public function updateTicketStatus() - { - Session::checkToken() or die(Text::_('JINVALID_TOKEN')); - - if (!$this->checkAcl('mokosuiteclient.tickets')) - { - $this->jsonForbidden(); - return; - } - - $input = Factory::getApplication()->getInput(); - - $this->jsonResponse($this->getModel('Tickets')->updateStatus( - $input->getInt('ticket_id', 0), - $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 - // ================================================================== - - public function searchKb() - { - $query = Factory::getApplication()->getInput()->getString('q', ''); - - if (strlen($query) < 3) - { - $this->jsonResponse(['results' => []]); return; } try { - $db = Factory::getDbo(); - $escaped = $db->quote('%' . $db->escape($query, true) . '%'); - - $results = $db->setQuery( + $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); + $db->setQuery( $db->getQuery(true) - ->select([$db->quoteName('l.title'), $db->quoteName('l.url'), $db->quoteName('l.description')]) - ->from($db->quoteName('#__finder_links', 'l')) - ->where($db->quoteName('l.published') . ' = 1') - ->where('(' . $db->quoteName('l.title') . ' LIKE ' . $escaped - . ' OR ' . $db->quoteName('l.description') . ' LIKE ' . $escaped . ')') - ->order($db->quoteName('l.title') . ' ASC') - ->setLimit(8) - )->loadObjectList() ?: []; + ->select([$db->quoteName('extension_id'), $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')) + ); + $ext = $db->loadObject(); - foreach ($results as $r) + if (!$ext) { - $r->description = mb_substr(strip_tags($r->description ?? ''), 0, 150); + $this->jsonResponse(['success' => false, 'message' => 'Core plugin not found.']); + return; } - $this->jsonResponse(['results' => $results]); + $params = json_decode($ext->params, true) ?: []; + $token = $params['health_api_token'] ?? ''; + + if (empty($token)) + { + $this->jsonResponse(['success' => false, 'message' => 'Health token not configured.']); + return; + } + + $now = time(); + $params['support_pin_requested_at'] = $now; + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($params))) + ->where($db->quoteName('extension_id') . ' = ' . (int) $ext->extension_id) + )->execute(); + + $pinTtl = 72 * 3600; + $window = floor($now / $pinTtl); + $hash = hash_hmac('sha256', (string) $window, $token); + $pin = 'MOKO-' . strtoupper(substr($hash, 0, 4)) . '-' . strtoupper(substr($hash, 4, 4)); + + $this->jsonResponse(['success' => true, 'pin' => $pin, 'message' => 'PIN generated — valid for 72 hours.']); } catch (\Throwable $e) { - Log::add('KB search failed: ' . $e->getMessage(), Log::ERROR, 'mokosuiteclient'); - $this->jsonResponse(['results' => [], 'error' => 'Search unavailable']); + $this->jsonResponse(['success' => false, 'message' => 'Error: ' . $e->getMessage()]); } } @@ -568,218 +465,6 @@ class DisplayController extends BaseController $this->jsonResponse($model->cleanDirectory($dirKey)); } - // ================================================================== - // Helpdesk CRUD (#137, #138, #139) - // ================================================================== - - public function saveCategory() - { - Session::checkToken() or die(Text::_('JINVALID_TOKEN')); - if (!$this->checkAcl('mokosuiteclient.tickets')) { $this->jsonForbidden(); return; } - $input = Factory::getApplication()->getInput(); - $db = Factory::getDbo(); - $id = $input->getInt('id', 0); - $data = (object) [ - 'title' => $input->getString('title', ''), - 'alias' => \Joomla\CMS\Filter\OutputFilter::stringURLSafe($input->getString('title', '')), - 'sla_response_minutes' => $input->getInt('sla_response_minutes', 480), - 'sla_resolution_minutes' => $input->getInt('sla_resolution_minutes', 2880), - 'auto_assign_user' => $input->getInt('auto_assign_user', 0) ?: null, - 'published' => $input->getInt('published', 1), - ]; - if ($id) { - $data->id = $id; - $db->updateObject('#__mokosuiteclient_ticket_categories', $data, 'id'); - } else { - $data->ordering = 0; - $db->insertObject('#__mokosuiteclient_ticket_categories', $data, 'id'); - } - $this->jsonResponse(['success' => true, 'message' => 'Category saved.', 'id' => (int) $data->id]); - } - - public function deleteCategory() - { - Session::checkToken() or die(Text::_('JINVALID_TOKEN')); - 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(); return; } - $input = Factory::getApplication()->getInput(); - $db = Factory::getDbo(); - $data = (object) [ - 'title' => $input->getString('title', ''), - 'body' => $input->getRaw('body', ''), - 'category_id' => $input->getInt('category_id', 0) ?: null, - 'ordering' => 0, - ]; - $id = $input->getInt('id', 0); - if ($id) { $data->id = $id; $db->updateObject('#__mokosuiteclient_ticket_canned', $data, 'id'); } - else { $db->insertObject('#__mokosuiteclient_ticket_canned', $data, 'id'); } - $this->jsonResponse(['success' => true, 'message' => 'Canned response saved.', 'id' => (int) $data->id]); - } - - public function deleteCanned() - { - Session::checkToken() or die(Text::_('JINVALID_TOKEN')); - 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(); return; } - $input = Factory::getApplication()->getInput(); - $db = Factory::getDbo(); - $data = (object) [ - 'title' => $input->getString('title', ''), - '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, - ]; - $id = $input->getInt('id', 0); - if ($id) { $data->id = $id; $db->updateObject('#__mokosuiteclient_ticket_automation', $data, 'id'); } - else { $db->insertObject('#__mokosuiteclient_ticket_automation', $data, 'id'); } - $this->jsonResponse(['success' => true, 'message' => 'Rule saved.', 'id' => (int) $data->id]); - } - - public function deleteAutomation() - { - Session::checkToken() or die(Text::_('JINVALID_TOKEN')); - 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.']); - } - - public function toggleAutomation() - { - Session::checkToken() or die(Text::_('JINVALID_TOKEN')); - if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; } - $input = Factory::getApplication()->getInput(); - $db = Factory::getDbo(); - $db->setQuery($db->getQuery(true)->update('#__mokosuiteclient_ticket_automation') - ->set('enabled = ' . $input->getInt('enabled', 0)) - ->where('id = ' . $input->getInt('id', 0)))->execute(); - $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) // ================================================================== @@ -891,7 +576,7 @@ class DisplayController extends BaseController { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); - if (!$this->checkAcl('core.admin')) + if (!$this->checkAcl('mokosuiteclient.security.waflog')) { $this->jsonForbidden(); return; @@ -907,7 +592,7 @@ class DisplayController extends BaseController { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); - if (!$this->checkAcl('core.admin')) + if (!$this->checkAcl('mokosuiteclient.security.waflog')) { $this->jsonForbidden(); return; @@ -991,19 +676,6 @@ class DisplayController extends BaseController // Importers // ================================================================== - public function importAts() - { - Session::checkToken() or die(Text::_('JINVALID_TOKEN')); - - if (!$this->checkAcl('mokosuiteclient.tickets')) - { - $this->jsonForbidden(); - return; - } - - $this->jsonResponse($this->getModel('Import')->importAts()); - } - public function importAdminTools() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); diff --git a/source/packages/com_mokosuiteclient/admin/src/Helper/ConditionsHelper.php b/source/packages/com_mokosuiteclient/admin/src/Helper/ConditionsHelper.php new file mode 100644 index 00000000..de825860 --- /dev/null +++ b/source/packages/com_mokosuiteclient/admin/src/Helper/ConditionsHelper.php @@ -0,0 +1,525 @@ + + */ + private static array $cache = []; + + /** + * Check whether a condition set passes. + * + * @param int $conditionId The condition record ID. + * + * @return bool True when the condition passes (content should display). + */ + public static function pass(int $conditionId): bool + { + if (isset(self::$cache[$conditionId])) { + return self::$cache[$conditionId]; + } + + $condition = self::load($conditionId); + + if ($condition === null || !(int) $condition->published) { + self::$cache[$conditionId] = false; + return false; + } + + $groups = $condition->groups ?? []; + + if (empty($groups)) { + // No groups means no restrictions — pass. + self::$cache[$conditionId] = true; + return true; + } + + $matchAll = (bool) $condition->match_all; + + foreach ($groups as $group) { + $groupResult = self::passGroup($group); + + if ($matchAll && !$groupResult) { + self::$cache[$conditionId] = false; + return false; + } + + if (!$matchAll && $groupResult) { + self::$cache[$conditionId] = true; + return true; + } + } + + // match_all: all passed; match_any: none passed. + $result = $matchAll; + self::$cache[$conditionId] = $result; + + return $result; + } + + /** + * Load a condition with its groups and rules from the database. + * + * @param int $conditionId The condition record ID. + * + * @return object|null The condition object with nested groups/rules, or null. + */ + public static function load(int $conditionId): ?object + { + $db = Factory::getContainer()->get('DatabaseDriver'); + + // Load the condition record. + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuiteclient_conditions')) + ->where($db->quoteName('id') . ' = :id') + ->bind(':id', $conditionId, \Joomla\Database\ParameterType::INTEGER); + + $condition = $db->setQuery($query)->loadObject(); + + if ($condition === null) { + return null; + } + + // Load groups. + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuiteclient_conditions_groups')) + ->where($db->quoteName('condition_id') . ' = :cid') + ->bind(':cid', $conditionId, \Joomla\Database\ParameterType::INTEGER) + ->order($db->quoteName('ordering') . ' ASC'); + + $groups = $db->setQuery($query)->loadObjectList(); + + // Load rules for each group. + foreach ($groups as $group) { + $groupId = (int) $group->id; + + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuiteclient_conditions_rules')) + ->where($db->quoteName('group_id') . ' = :gid') + ->bind(':gid', $groupId, \Joomla\Database\ParameterType::INTEGER) + ->order($db->quoteName('ordering') . ' ASC'); + + $group->rules = $db->setQuery($query)->loadObjectList(); + + // Decode params JSON on each rule. + foreach ($group->rules as $rule) { + $rule->params = json_decode($rule->params ?: '{}'); + } + } + + $condition->groups = $groups; + + return $condition; + } + + /** + * Evaluate a single group (AND/OR its rules). + * + * @param object $group The group object with a rules array. + * + * @return bool + */ + private static function passGroup(object $group): bool + { + $rules = $group->rules ?? []; + + if (empty($rules)) { + return true; + } + + $matchAll = (bool) $group->match_all; + + foreach ($rules as $rule) { + $ruleResult = self::passRule($rule); + + // If the rule is an exclusion, invert the result. + if ((int) $rule->exclude) { + $ruleResult = !$ruleResult; + } + + if ($matchAll && !$ruleResult) { + return false; + } + + if (!$matchAll && $ruleResult) { + return true; + } + } + + return $matchAll; + } + + /** + * Evaluate a single rule by dispatching to the right type handler. + * + * @param object $rule The rule object (type, params decoded). + * + * @return bool + */ + private static function passRule(object $rule): bool + { + $params = $rule->params ?? new \stdClass(); + + return match ($rule->type) { + 'menu__menu_item' => self::evalMenuMenuItem($params), + 'menu__home_page' => self::evalMenuHomePage($params), + 'visitor__user_group' => self::evalVisitorUserGroup($params), + 'visitor__access_level' => self::evalVisitorAccessLevel($params), + 'date__date' => self::evalDateDate($params), + 'date__day' => self::evalDateDay($params), + 'other__url' => self::evalOtherUrl($params), + default => false, + }; + } + + // ------------------------------------------------------------------ + // Rule type evaluators + // ------------------------------------------------------------------ + + /** + * menu__menu_item — check if current menu item ID is in selection. + */ + private static function evalMenuMenuItem(object $params): bool + { + $selection = self::toIntArray($params->selection ?? []); + + if (empty($selection)) { + return true; + } + + $app = Factory::getApplication(); + $itemId = (int) $app->getInput()->getInt('Itemid', 0); + + return \in_array($itemId, $selection, true); + } + + /** + * menu__home_page — check if current page is the site home page. + */ + private static function evalMenuHomePage(object $params): bool + { + $app = Factory::getApplication(); + $menu = $app->getMenu(); + + if ($menu === null) { + return false; + } + + $active = $menu->getActive(); + $default = $menu->getDefault($app->getLanguage()->getTag()); + + $isHome = ($active !== null && $default !== null && $active->id === $default->id); + + // params->selection can be [1] for "is home" or [0] for "is not home". + $want = (bool) ($params->selection[0] ?? true); + + return $isHome === $want; + } + + /** + * visitor__user_group — check if current user belongs to specified groups. + */ + private static function evalVisitorUserGroup(object $params): bool + { + $selection = self::toIntArray($params->selection ?? []); + + if (empty($selection)) { + return true; + } + + $user = Factory::getApplication()->getIdentity(); + $userGroups = $user ? $user->getAuthorisedGroups() : []; + + $comparison = $params->comparison ?? 'any'; + + if ($comparison === 'all') { + return empty(array_diff($selection, $userGroups)); + } + + // Default: any + return !empty(array_intersect($selection, $userGroups)); + } + + /** + * visitor__access_level — check if current user has specified access levels. + */ + private static function evalVisitorAccessLevel(object $params): bool + { + $selection = self::toIntArray($params->selection ?? []); + + if (empty($selection)) { + return true; + } + + $user = Factory::getApplication()->getIdentity(); + $accessLevels = $user ? $user->getAuthorisedViewLevels() : []; + + $comparison = $params->comparison ?? 'any'; + + if ($comparison === 'all') { + return empty(array_diff($selection, $accessLevels)); + } + + return !empty(array_intersect($selection, $accessLevels)); + } + + /** + * date__date — check if current date is before/after/between specified dates. + * + * params->comparison: 'before', 'after', 'between' + * params->selection: [start_date] or [start_date, end_date] + */ + private static function evalDateDate(object $params): bool + { + $comparison = $params->comparison ?? 'after'; + $selection = (array) ($params->selection ?? []); + + if (empty($selection)) { + return true; + } + + $now = Factory::getDate()->toUnix(); + + return match ($comparison) { + 'before' => $now < strtotime($selection[0]), + 'after' => $now > strtotime($selection[0]), + 'between' => isset($selection[1]) + && $now >= strtotime($selection[0]) + && $now <= strtotime($selection[1]), + default => false, + }; + } + + /** + * date__day — check if current day of week matches selection. + * + * params->selection: array of day numbers (1=Monday .. 7=Sunday, ISO-8601). + */ + private static function evalDateDay(object $params): bool + { + $selection = self::toIntArray($params->selection ?? []); + + if (empty($selection)) { + return true; + } + + $today = (int) Factory::getDate()->format('N'); // 1=Mon, 7=Sun + + return \in_array($today, $selection, true); + } + + /** + * other__url — check if current URL matches a regex pattern. + * + * params->selection: array of regex patterns (without delimiters). + */ + private static function evalOtherUrl(object $params): bool + { + $patterns = (array) ($params->selection ?? []); + + if (empty($patterns)) { + return true; + } + + $url = Uri::getInstance()->toString(); + + foreach ($patterns as $pattern) { + $pattern = trim($pattern); + + if ($pattern === '') { + continue; + } + + // Wrap in delimiters, escape internal delimiter. + $safePattern = str_replace('#', '\\#', $pattern); + if (@preg_match('#' . $safePattern . '#i', $url)) { + return true; + } + } + + return false; + } + + // ------------------------------------------------------------------ + // Mapping helpers + // ------------------------------------------------------------------ + + /** + * Get all condition IDs mapped to a specific extension/item pair. + * + * @param string $extension The extension identifier (e.g. 'mod_custom'). + * @param int $itemId The item ID within that extension. + * + * @return int[] Array of condition IDs. + */ + public static function getConditionsForItem(string $extension, int $itemId): array + { + $db = Factory::getContainer()->get('DatabaseDriver'); + + $query = $db->getQuery(true) + ->select($db->quoteName('condition_id')) + ->from($db->quoteName('#__mokosuiteclient_conditions_map')) + ->where($db->quoteName('extension') . ' = :ext') + ->where($db->quoteName('item_id') . ' = :iid') + ->bind(':ext', $extension) + ->bind(':iid', $itemId, \Joomla\Database\ParameterType::INTEGER); + + return $db->setQuery($query)->loadColumn(); + } + + /** + * Check if an item should display based on its mapped conditions. + * + * If no conditions are mapped, the item displays (returns true). + * If conditions are mapped, ALL must pass for the item to display. + * + * @param string $extension The extension identifier. + * @param int $itemId The item ID. + * + * @return bool + */ + public static function shouldDisplay(string $extension, int $itemId): bool + { + $conditionIds = self::getConditionsForItem($extension, $itemId); + + if (empty($conditionIds)) { + return true; + } + + foreach ($conditionIds as $conditionId) { + if (!self::pass((int) $conditionId)) { + return false; + } + } + + return true; + } + + /** + * Evaluate a condition by its alias string. + * + * @param string $alias The condition alias. + * + * @return bool True when the condition passes. + * + * @since 02.48.00 + */ + public static function passByAlias(string $alias): bool + { + $id = self::resolveAlias($alias); + + if ($id === null) { + return false; + } + + return self::pass($id); + } + + /** + * Resolve a condition reference that may be an integer ID or an alias string. + * + * @param string $ref The reference (numeric ID or alias). + * + * @return int|null The condition ID, or null if not found. + * + * @since 02.48.00 + */ + public static function resolveAlias(string $ref): ?int + { + if (is_numeric($ref)) { + return (int) $ref; + } + + $db = Factory::getContainer()->get('DatabaseDriver'); + + $query = $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName('#__mokosuiteclient_conditions')) + ->where($db->quoteName('alias') . ' = :alias') + ->bind(':alias', $ref); + + $id = $db->setQuery($query)->loadResult(); + + return $id !== null ? (int) $id : null; + } + + /** + * Evaluate a single inline rule (public wrapper around passRule). + * + * @param string $type The rule type (e.g. 'visitor__access_level'). + * @param object $params The rule params object. + * + * @return bool + * + * @since 02.48.00 + */ + public static function evaluateInlineRule(string $type, object $params): bool + { + $rule = (object) [ + 'type' => $type, + 'params' => $params, + ]; + + return self::passRule($rule); + } + + /** + * Clear the evaluation cache (useful between requests in testing). + * + * @return void + */ + public static function clearCache(): void + { + self::$cache = []; + } + + // ------------------------------------------------------------------ + // Internal utilities + // ------------------------------------------------------------------ + + /** + * Normalize a mixed selection value into an array of integers. + * + * @param mixed $value Scalar, array, or object. + * + * @return int[] + */ + private static function toIntArray(mixed $value): array + { + if (\is_object($value)) { + $value = (array) $value; + } + + if (!\is_array($value)) { + $value = [$value]; + } + + return array_map('intval', array_values($value)); + } +} diff --git a/source/packages/com_mokosuiteclient/admin/src/Model/DashboardModel.php b/source/packages/com_mokosuiteclient/admin/src/Model/DashboardModel.php index 48c28a7d..5fc4f9b0 100644 --- a/source/packages/com_mokosuiteclient/admin/src/Model/DashboardModel.php +++ b/source/packages/com_mokosuiteclient/admin/src/Model/DashboardModel.php @@ -213,30 +213,46 @@ class DashboardModel extends BaseDatabaseModel } /** - * Get installed MokoSuiteClient component and modules with versions. + * Discover all installed MokoSuite ecosystem extensions. * - * @return array Array of extension objects with name, element, type, version. + * Fuzzy-matches packages, components, modules, plugins, and libraries + * by element name containing "mokosuite", "mokosuiteclient", "mokojoom", + * or "moko" prefix patterns. + * + * @return array Extension objects with name, element, type, version, enabled, family. */ public function getMokoExtensions(): array { $db = $this->getDatabase(); + $el = $db->quoteName('element'); + + // Fuzzy match: any extension whose element contains moko patterns + $patterns = [ + $el . ' LIKE ' . $db->quote('pkg_mokosuite%'), + $el . ' LIKE ' . $db->quote('com_mokosuite%'), + $el . ' LIKE ' . $db->quote('mod_mokosuite%'), + $el . ' LIKE ' . $db->quote('mokosuite%'), + $el . ' LIKE ' . $db->quote('mokosuiteclient%'), + $el . ' LIKE ' . $db->quote('pkg_mokojoom%'), + $el . ' LIKE ' . $db->quote('com_mokojoom%'), + $el . ' LIKE ' . $db->quote('mod_mokojoom%'), + $el . ' LIKE ' . $db->quote('mokojoom%'), + $el . ' LIKE ' . $db->quote('plg_%_mokosuite%'), + $el . ' LIKE ' . $db->quote('plg_%_mokojoom%'), + ]; + $query = $db->getQuery(true) ->select([ + $db->quoteName('extension_id'), $db->quoteName('element'), $db->quoteName('name'), $db->quoteName('type'), + $db->quoteName('folder'), $db->quoteName('enabled'), $db->quoteName('manifest_cache'), ]) ->from($db->quoteName('#__extensions')) - ->where('(' - // The component - . '(' . $db->quoteName('type') . ' = ' . $db->quote('component') - . ' AND ' . $db->quoteName('element') . ' = ' . $db->quote('com_mokosuiteclient') . ')' - // Admin modules - . ' OR (' . $db->quoteName('type') . ' = ' . $db->quote('module') - . ' AND ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mod_mokosuiteclient%') . ')' - . ')') + ->where('(' . implode(' OR ', $patterns) . ')') ->order($db->quoteName('type') . ' ASC, ' . $db->quoteName('element') . ' ASC'); $db->setQuery($query); @@ -248,12 +264,27 @@ class DashboardModel extends BaseDatabaseModel { $manifest = json_decode($row->manifest_cache ?? '{}'); + // Determine product family from element name + $family = 'mokosuite'; + if (stripos($row->element, 'mokosuiteclient') !== false) { + $family = 'mokosuiteclient'; + } elseif (stripos($row->element, 'mokosuitehq') !== false) { + $family = 'mokosuitehq'; + } elseif (stripos($row->element, 'mokosuitecrm') !== false) { + $family = 'mokosuitecrm'; + } elseif (stripos($row->element, 'mokojoom') !== false) { + $family = 'mokojoom'; + } + $extensions[] = (object) [ - 'element' => $row->element, - 'name' => $manifest->name ?? $row->name, - 'type' => $row->type, - 'version' => $manifest->version ?? '', - 'enabled' => (int) $row->enabled, + 'extension_id' => (int) $row->extension_id, + 'element' => $row->element, + 'name' => $manifest->name ?? $row->name, + 'type' => $row->type, + 'folder' => $row->folder ?? '', + 'version' => $manifest->version ?? '', + 'enabled' => (int) $row->enabled, + 'family' => $family, ]; } diff --git a/source/packages/com_mokosuiteclient/admin/src/Model/ExtensionsModel.php b/source/packages/com_mokosuiteclient/admin/src/Model/ExtensionsModel.php index bfd02aa8..cf8915b0 100644 --- a/source/packages/com_mokosuiteclient/admin/src/Model/ExtensionsModel.php +++ b/source/packages/com_mokosuiteclient/admin/src/Model/ExtensionsModel.php @@ -112,7 +112,7 @@ class ExtensionsModel extends BaseDatabaseModel curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); curl_setopt($ch, CURLOPT_TIMEOUT, 120); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); $data = curl_exec($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); @@ -221,7 +221,7 @@ class ExtensionsModel extends BaseDatabaseModel curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 10); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); $response = curl_exec($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); @@ -238,8 +238,15 @@ class ExtensionsModel extends BaseDatabaseModel return []; } - // Determine site's update channel preference - $channel = 'dev'; // default to dev — show everything + // Dev channel only available on Moko domains; all others forced to stable + $isMokoDomain = (bool) preg_match('/\.mokoconsulting\.tech$/i', $_SERVER['HTTP_HOST'] ?? ''); + $channel = 'stable'; + if ($isMokoDomain) { + try { + $channel = \Joomla\CMS\Component\ComponentHelper::getParams('com_installer') + ->get('update_channel', 'stable') ?: 'stable'; + } catch (\Throwable $e) {} + } $hasStable = false; $hasDev = false; @@ -269,7 +276,18 @@ class ExtensionsModel extends BaseDatabaseModel $hasDev = true; } - if ($ver === '' || version_compare($ver, $bestVersion, '<=')) + if ($ver === '') + { + continue; + } + + // Respect update channel: stable channel skips dev-tagged versions + if ($channel === 'stable' && $tag === 'dev') + { + continue; + } + + if (version_compare($ver, $bestVersion, '<=')) { continue; } diff --git a/source/packages/com_mokosuiteclient/admin/src/Service/AttachmentService.php b/source/packages/com_mokosuiteclient/admin/src/Service/AttachmentService.php deleted file mode 100644 index e150db3c..00000000 --- a/source/packages/com_mokosuiteclient/admin/src/Service/AttachmentService.php +++ /dev/null @@ -1,183 +0,0 @@ - [$files['name']], - 'type' => [$files['type']], - 'tmp_name' => [$files['tmp_name']], - 'error' => [$files['error']], - 'size' => [$files['size']], - ]; - } - - $ticketDir = self::STORAGE_DIR . '/' . $ticketId; - - if (!is_dir($ticketDir) && !Folder::create($ticketDir)) { - Log::add("Failed to create attachment directory: {$ticketDir}", Log::ERROR, 'mokosuiteclient'); - return []; - } - - $userId = (int) Factory::getUser()->id; - $db = Factory::getDbo(); - - for ($i = 0, $count = count($files['name']); $i < $count; $i++) - { - if ($files['error'][$i] !== UPLOAD_ERR_OK) { - Log::add("Attachment upload error for '{$files['name'][$i]}': PHP error code {$files['error'][$i]}", Log::WARNING, 'mokosuiteclient'); - continue; - } - - $originalName = File::makeSafe($files['name'][$i]); - $ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION)); - - // Validate extension - if (!in_array($ext, self::ALLOWED_EXTENSIONS, true)) { - Log::add("Attachment rejected: disallowed extension .{$ext}", Log::WARNING, 'mokosuiteclient'); - continue; - } - - // Validate size - if ($files['size'][$i] > self::MAX_FILE_SIZE) { - Log::add("Attachment rejected: file too large ({$files['size'][$i]} bytes)", Log::WARNING, 'mokosuiteclient'); - continue; - } - - // Generate unique filename to prevent overwrites - $storedName = uniqid('att_', true) . '.' . $ext; - $destPath = $ticketDir . '/' . $storedName; - - if (!File::upload($files['tmp_name'][$i], $destPath)) { - Log::add("Attachment upload failed: {$originalName}", Log::ERROR, 'mokosuiteclient'); - continue; - } - - $record = (object) [ - 'ticket_id' => $ticketId, - 'reply_id' => $replyId, - 'filename' => $originalName, - 'filepath' => $ticketId . '/' . $storedName, - 'filesize' => $files['size'][$i], - 'mimetype' => mime_content_type($destPath) ?: 'application/octet-stream', - 'uploaded_by' => $userId, - 'created' => Factory::getDate()->toSql(), - ]; - - $db->insertObject('#__mokosuiteclient_ticket_attachments', $record, 'id'); - $saved[] = $record; - } - - return $saved; - } - - /** - * Get attachments for a ticket. - */ - public static function getForTicket(int $ticketId): array - { - $db = Factory::getDbo(); - $db->setQuery( - $db->getQuery(true) - ->select('a.*, u.name AS uploader_name') - ->from($db->quoteName('#__mokosuiteclient_ticket_attachments', 'a')) - ->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = a.uploaded_by') - ->where($db->quoteName('a.ticket_id') . ' = ' . $ticketId) - ->order('a.created ASC') - ); - return $db->loadObjectList() ?: []; - } - - /** - * Get the absolute filesystem path for an attachment. - */ - public static function getAbsolutePath(object $attachment): ?string - { - $path = realpath(self::STORAGE_DIR . '/' . $attachment->filepath); - if ($path === false || !str_starts_with($path, realpath(self::STORAGE_DIR))) { - return null; - } - return $path; - } - - /** - * Delete an attachment (file + DB record). - */ - public static function delete(int $attachmentId): bool - { - $db = Factory::getDbo(); - $db->setQuery( - $db->getQuery(true) - ->select('*') - ->from('#__mokosuiteclient_ticket_attachments') - ->where('id = ' . $attachmentId) - ); - $att = $db->loadObject(); - - if (!$att) { - return false; - } - - $path = self::STORAGE_DIR . '/' . $att->filepath; - - if (file_exists($path)) { - File::delete($path); - } - - $db->setQuery( - $db->getQuery(true) - ->delete('#__mokosuiteclient_ticket_attachments') - ->where('id = ' . $attachmentId) - )->execute(); - - return true; - } - - /** - * Format file size for display. - */ - public static function formatSize(int $bytes): string - { - if ($bytes < 1024) return $bytes . ' B'; - if ($bytes < 1048576) return round($bytes / 1024, 1) . ' KB'; - return round($bytes / 1048576, 1) . ' MB'; - } -} diff --git a/source/packages/com_mokosuiteclient/admin/src/Service/AutomationEngine.php b/source/packages/com_mokosuiteclient/admin/src/Service/AutomationEngine.php deleted file mode 100644 index 37d7773c..00000000 --- a/source/packages/com_mokosuiteclient/admin/src/Service/AutomationEngine.php +++ /dev/null @@ -1,280 +0,0 @@ -conditions, true) ?: []; - $actions = json_decode($rule->actions, true) ?: []; - - if (self::evaluateConditions($conditions, $context)) - { - self::executeActions($actions, $rule, $context); - } - } - } - catch (\Throwable $e) - { - Log::add('Automation engine error: ' . $e->getMessage(), Log::ERROR, 'mokosuiteclient'); - } - } - - /** - * Get active automation rules for a trigger event. - */ - private static function getActiveRules(string $event): array - { - $db = Factory::getDbo(); - $db->setQuery( - $db->getQuery(true) - ->select('*') - ->from('#__mokosuiteclient_ticket_automation') - ->where($db->quoteName('trigger_event') . ' = ' . $db->quote($event)) - ->where($db->quoteName('enabled') . ' = 1') - ->order('ordering ASC') - ); - return $db->loadObjectList() ?: []; - } - - /** - * Evaluate all conditions (AND logic). - */ - private static function evaluateConditions(array $conditions, array $context): bool - { - foreach ($conditions as $c) - { - $field = $c['field'] ?? ''; - $op = $c['op'] ?? 'eq'; - $expected = $c['value'] ?? ''; - $actual = $context[$field] ?? ''; - - switch ($op) - { - case 'eq': if ((string) $actual !== (string) $expected) return false; break; - case 'neq': if ((string) $actual === (string) $expected) return false; break; - case 'gt': if ((float) $actual <= (float) $expected) return false; break; - case 'lt': if ((float) $actual >= (float) $expected) return false; break; - case 'in': - $values = array_map('trim', explode(',', $expected)); - if (!in_array((string) $actual, $values, true)) return false; - break; - case 'not_in': - $values = array_map('trim', explode(',', $expected)); - if (in_array((string) $actual, $values, true)) return false; - break; - } - } - return true; - } - - /** - * Execute actions for a matched rule. - */ - private static function executeActions(array $actions, object $rule, array $context): void - { - $db = Factory::getDbo(); - $ticketId = (int) ($context['ticket_id'] ?? $context['id'] ?? 0); - - foreach ($actions as $action) - { - $type = $action['type'] ?? ''; - $value = $action['value'] ?? ''; - - try - { - switch ($type) - { - case 'set_status': - if ($ticketId) { - $statusId = self::resolveStatusId($db, $value); - $sets = "status = {$db->quote($value)}, modified = {$db->quote(Factory::getDate()->toSql())}"; - if ($statusId) { $sets .= ", status_id = {$statusId}"; } - $db->setQuery("UPDATE {$db->quoteName('#__mokosuiteclient_tickets')} SET {$sets} WHERE id = {$ticketId}")->execute(); - } - break; - - case 'set_priority': - if ($ticketId) { - $priorityId = self::resolvePriorityId($db, $value); - $sets = "priority = {$db->quote($value)}, modified = {$db->quote(Factory::getDate()->toSql())}"; - if ($priorityId) { $sets .= ", priority_id = {$priorityId}"; } - $db->setQuery("UPDATE {$db->quoteName('#__mokosuiteclient_tickets')} SET {$sets} WHERE id = {$ticketId}")->execute(); - } - break; - - case 'assign': - $assignId = (int) $value; - if ($ticketId && $assignId > 0) { - $db->setQuery("UPDATE {$db->quoteName('#__mokosuiteclient_tickets')} SET assigned_to = {$assignId}, modified = {$db->quote(Factory::getDate()->toSql())} WHERE id = {$ticketId}")->execute(); - } - break; - - case 'add_note': - if ($ticketId) { - $note = (object) [ - 'ticket_id' => $ticketId, - 'user_id' => 0, - 'body' => $value ?: '[Automation: ' . ($rule->title ?? '') . ']', - 'is_internal' => 1, - 'created' => Factory::getDate()->toSql(), - ]; - $db->insertObject('#__mokosuiteclient_ticket_replies', $note); - } - break; - - case 'send_email': - NotificationService::securityAlert( - 'automation', - 'Automation: ' . ($rule->title ?? ''), - $value ?: 'Rule triggered for ticket #' . $ticketId - ); - break; - - case 'send_ntfy': - NotificationService::pushNtfySecurity( - 'automation', - 'Automation: ' . ($rule->title ?? ''), - $value ?: 'Rule triggered for ticket #' . $ticketId - ); - break; - - case 'close': - if ($ticketId) { - $closedId = self::resolveClosedStatusId($db); - $sets = "status = 'closed', closed = {$db->quote(Factory::getDate()->toSql())}, modified = {$db->quote(Factory::getDate()->toSql())}"; - if ($closedId) { $sets .= ", status_id = {$closedId}"; } - $db->setQuery("UPDATE {$db->quoteName('#__mokosuiteclient_tickets')} SET {$sets} WHERE id = {$ticketId}")->execute(); - } - break; - - case 'create_ticket': - self::createTicketFromAutomation($rule, $context, $value); - break; - } - } - catch (\Throwable $e) - { - Log::add("Automation action '{$type}' failed for rule #{$rule->id}: " . $e->getMessage(), Log::ERROR, 'mokosuiteclient'); - } - } - } - - /** - * Create a ticket from automation (with behavior: append/always_new/skip_if_open). - */ - private static function resolveStatusId($db, string $alias): int - { - return (int) $db->setQuery( - $db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_statuses') - ->where($db->quoteName('alias') . ' = ' . $db->quote($alias)), 0, 1 - )->loadResult(); - } - - private static function resolvePriorityId($db, string $alias): int - { - return (int) $db->setQuery( - $db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_priorities') - ->where($db->quoteName('alias') . ' = ' . $db->quote($alias)), 0, 1 - )->loadResult(); - } - - private static function resolveClosedStatusId($db): int - { - return (int) $db->setQuery( - $db->getQuery(true)->select('id')->from('#__mokosuiteclient_ticket_statuses') - ->where($db->quoteName('is_closed') . ' = 1'), 0, 1 - )->loadResult(); - } - - private static function createTicketFromAutomation(object $rule, array $context, string $subject): void - { - $db = Factory::getDbo(); - $behavior = $rule->behavior ?? 'append'; - $userId = (int) ($context['user_id'] ?? 0); - $catId = (int) ($context['category_id'] ?? 0); - - if ($behavior !== 'always_new' && $userId > 0) - { - // Check for existing open ticket (check both status ENUM and status_id) - $query = $db->getQuery(true) - ->select('t.id') - ->from($db->quoteName('#__mokosuiteclient_tickets', 't')) - ->join('LEFT', $db->quoteName('#__mokosuiteclient_ticket_statuses', 's') . ' ON t.status_id = s.id') - ->where('t.created_by = ' . $userId) - ->where("(s.id IS NULL AND t.status NOT IN ('closed', 'resolved')) OR (s.id IS NOT NULL AND s.is_closed = 0)"); - - if ($catId > 0) { - $query->where('category_id = ' . $catId); - } - - $db->setQuery($query, 0, 1); - $existingId = (int) $db->loadResult(); - - if ($existingId > 0) - { - if ($behavior === 'skip_if_open') return; - - // append — add reply to existing ticket - $reply = (object) [ - 'ticket_id' => $existingId, - 'user_id' => 0, - 'body' => $subject ?: '[Automation: ' . ($rule->title ?? '') . ']', - 'is_internal' => 1, - 'created' => Factory::getDate()->toSql(), - ]; - $db->insertObject('#__mokosuiteclient_ticket_replies', $reply); - return; - } - } - - // Create new ticket - $openStatusId = self::resolveStatusId($db, 'open') ?: null; - $normalPriorityId = self::resolvePriorityId($db, $context['priority'] ?? 'normal') ?: null; - $ticket = (object) [ - 'subject' => $subject ?: 'Automation: ' . ($rule->title ?? ''), - 'body' => $context['body'] ?? '', - 'status' => 'open', - 'status_id' => $openStatusId, - 'priority' => $context['priority'] ?? 'normal', - 'priority_id' => $normalPriorityId, - 'category_id' => $catId ?: null, - 'created_by' => $userId, - 'created' => Factory::getDate()->toSql(), - ]; - $db->insertObject('#__mokosuiteclient_tickets', $ticket, 'id'); - } -} diff --git a/source/packages/com_mokosuiteclient/admin/src/Service/NotificationService.php b/source/packages/com_mokosuiteclient/admin/src/Service/NotificationService.php deleted file mode 100644 index caba2318..00000000 --- a/source/packages/com_mokosuiteclient/admin/src/Service/NotificationService.php +++ /dev/null @@ -1,581 +0,0 @@ -isHtml(false); - $mailer->setSubject($subject); - $mailer->setBody($body); - - foreach ($recipients as $email) - { - $email = trim($email); - - if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) - { - continue; - } - - try - { - $mailer->clearAddresses(); - $mailer->addRecipient($email); - $mailer->Send(); - } - catch (\Throwable $e) - { - Log::add('Notification send failed to ' . $email . ': ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); - } - } - - // Push notification via ntfy - self::pushNtfy($event, $ticket, $subject); - } - catch (\Throwable $e) - { - Log::add('Notification error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); - } - } - - /** - * Determine recipients based on event type and ticket data. - */ - private static function getRecipients(string $event, object $ticket): array - { - $emails = []; - - // Get notification config from component params - $config = self::getNotificationConfig(); - - // Always notify configured admin emails - $adminEmails = array_filter(array_map('trim', explode(',', $config['admin_emails'] ?? ''))); - $emails = array_merge($emails, $adminEmails); - - // Always notify configured admin user IDs - $adminUserIds = array_filter(array_map('intval', explode(',', $config['admin_user_ids'] ?? ''))); - - foreach ($adminUserIds as $uid) - { - $email = self::getUserEmail($uid); - - if ($email) - { - $emails[] = $email; - } - } - - switch ($event) - { - case 'ticket_created': - // Notify assigned user if any - if (!empty($ticket->assigned_to)) - { - $email = self::getUserEmail((int) $ticket->assigned_to); - - if ($email) - { - $emails[] = $email; - } - } - break; - - case 'ticket_replied': - // Notify ticket creator (customer gets notified of staff reply) - if (!empty($ticket->created_by)) - { - $email = self::getUserEmail((int) $ticket->created_by); - - if ($email) - { - $emails[] = $email; - } - } - - // Notify assigned user - if (!empty($ticket->assigned_to)) - { - $email = self::getUserEmail((int) $ticket->assigned_to); - - if ($email) - { - $emails[] = $email; - } - } - break; - - case 'status_changed': - // Notify ticket creator - if (!empty($ticket->created_by)) - { - $email = self::getUserEmail((int) $ticket->created_by); - - if ($email) - { - $emails[] = $email; - } - } - break; - - case 'ticket_assigned': - // Notify newly assigned user - if (!empty($ticket->assigned_to)) - { - $email = self::getUserEmail((int) $ticket->assigned_to); - - if ($email) - { - $emails[] = $email; - } - } - break; - } - - return array_unique($emails); - } - - /** - * Build email subject line. - */ - private static function buildSubject(string $event, object $ticket): string - { - $siteName = Factory::getConfig()->get('sitename', 'Support'); - $prefix = '[' . $siteName . ' #' . $ticket->id . '] '; - - switch ($event) - { - case 'ticket_created': - return $prefix . 'New Ticket: ' . ($ticket->subject ?? ''); - - case 'ticket_replied': - return $prefix . 'Reply: ' . ($ticket->subject ?? ''); - - case 'status_changed': - return $prefix . 'Status Changed: ' . ($ticket->subject ?? ''); - - case 'ticket_assigned': - return $prefix . 'Assigned: ' . ($ticket->subject ?? ''); - - default: - return $prefix . ($ticket->subject ?? ''); - } - } - - /** - * Build email body. - */ - private static function buildBody(string $event, object $ticket, array $extra): string - { - $siteName = Factory::getConfig()->get('sitename', 'Support'); - $siteUrl = rtrim(Uri::root(), '/'); - $ticketUrl = $siteUrl . '/index.php?option=com_mokosuiteclient&view=ticket&id=' . $ticket->id; - - $lines = []; - $lines[] = $siteName . ' Support'; - $lines[] = str_repeat('-', 40); - $lines[] = ''; - - switch ($event) - { - case 'ticket_created': - $lines[] = 'A new support ticket has been created.'; - $lines[] = ''; - $lines[] = 'Subject: ' . ($ticket->subject ?? ''); - $lines[] = 'Priority: ' . ucfirst($ticket->priority ?? 'normal'); - $lines[] = 'Category: ' . ($ticket->category_title ?? 'General'); - $lines[] = ''; - - if (!empty($ticket->body)) - { - $lines[] = 'Description:'; - $lines[] = strip_tags($ticket->body); - $lines[] = ''; - } - break; - - case 'ticket_replied': - $lines[] = 'A new reply has been added to your ticket.'; - $lines[] = ''; - $lines[] = 'Subject: ' . ($ticket->subject ?? ''); - $lines[] = 'Status: ' . ucwords(str_replace('_', ' ', $ticket->status ?? '')); - $lines[] = ''; - - if (!empty($extra['reply_body'])) - { - $lines[] = 'Reply:'; - $lines[] = strip_tags($extra['reply_body']); - $lines[] = ''; - } - break; - - case 'status_changed': - $lines[] = 'Your ticket status has been updated.'; - $lines[] = ''; - $lines[] = 'Subject: ' . ($ticket->subject ?? ''); - $lines[] = 'New Status: ' . ucwords(str_replace('_', ' ', $ticket->status ?? '')); - - if (!empty($extra['old_status'])) - { - $lines[] = 'Old Status: ' . ucwords(str_replace('_', ' ', $extra['old_status'])); - } - - $lines[] = ''; - break; - - case 'ticket_assigned': - $lines[] = 'A ticket has been assigned to you.'; - $lines[] = ''; - $lines[] = 'Subject: ' . ($ticket->subject ?? ''); - $lines[] = 'Priority: ' . ucfirst($ticket->priority ?? 'normal'); - $lines[] = ''; - break; - } - - $lines[] = 'View ticket: ' . $ticketUrl; - $lines[] = ''; - $lines[] = '-- '; - $lines[] = $siteName . ' | Powered by MokoSuiteClient'; - - return implode("\n", $lines); - } - - /** - * Get email address for a Joomla user ID. - */ - private static function getUserEmail(int $userId): ?string - { - if ($userId <= 0) - { - return null; - } - - try - { - $db = Factory::getDbo(); - $db->setQuery( - $db->getQuery(true) - ->select($db->quoteName('email')) - ->from($db->quoteName('#__users')) - ->where($db->quoteName('id') . ' = ' . $userId) - ); - - return $db->loadResult() ?: null; - } - catch (\Throwable $e) - { - Log::add('Failed to look up email for user ID ' . $userId . ': ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); - return null; - } - } - - /** - * Get notification configuration from component params. - */ - private static function getNotificationConfig(): array - { - try - { - $db = Factory::getDbo(); - $db->setQuery( - $db->getQuery(true) - ->select($db->quoteName('params')) - ->from($db->quoteName('#__extensions')) - ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokosuiteclient')) - ->where($db->quoteName('type') . ' = ' . $db->quote('component')) - ); - - $params = json_decode($db->loadResult() ?? '{}', true); - - return $params['notifications'] ?? []; - } - 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) - // ================================================================== - - /** - * Send a security alert to admin emails. - */ - public static function securityAlert(string $event, string $subject, string $body): void - { - try - { - $config = self::getNotificationConfig(); - $enabled = $config['security_alerts'] ?? '1'; - - if (!$enabled) - { - return; - } - - $adminEmails = array_filter(array_map('trim', explode(',', $config['admin_emails'] ?? ''))); - $adminUserIds = array_filter(array_map('intval', explode(',', $config['admin_user_ids'] ?? ''))); - - $recipients = $adminEmails; - - foreach ($adminUserIds as $uid) - { - $email = self::getUserEmail($uid); - - if ($email) - { - $recipients[] = $email; - } - } - - $recipients = array_unique($recipients); - - if (empty($recipients)) - { - return; - } - - $siteName = Factory::getConfig()->get('sitename', 'Site'); - $fullSubject = '[' . $siteName . ' Security] ' . $subject; - - $lines = [ - $siteName . ' Security Alert', - str_repeat('-', 40), - '', - 'Event: ' . $event, - 'Time: ' . gmdate('Y-m-d H:i:s') . ' UTC', - '', - $body, - '', - '-- ', - $siteName . ' | MokoSuiteClient Security', - ]; - - $mailer = Factory::getMailer(); - $mailer->isHtml(false); - $mailer->setSubject($fullSubject); - $mailer->setBody(implode("\n", $lines)); - - foreach ($recipients as $email) - { - try - { - $mailer->clearAddresses(); - $mailer->addRecipient(trim($email)); - $mailer->Send(); - } - catch (\Throwable $e) - { - Log::add('Security alert send failed: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); - } - } - - // Also push via ntfy - self::pushNtfySecurity($event, $subject, $body); - } - catch (\Throwable $e) - { - Log::add('Security alert error: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); - } - } -} diff --git a/source/packages/com_mokosuiteclient/admin/src/View/Canned/HtmlView.php b/source/packages/com_mokosuiteclient/admin/src/View/Canned/HtmlView.php deleted file mode 100644 index 87d54425..00000000 --- a/source/packages/com_mokosuiteclient/admin/src/View/Canned/HtmlView.php +++ /dev/null @@ -1,33 +0,0 @@ -get('Joomla\Database\DatabaseInterface'); - - $db->setQuery('SELECT * FROM #__mokosuiteclient_ticket_canned ORDER BY ordering ASC'); - $this->responses = $db->loadObjectList() ?: []; - - $db->setQuery('SELECT id, title FROM #__mokosuiteclient_ticket_categories WHERE published = 1 ORDER BY ordering'); - $this->categories = $db->loadObjectList() ?: []; - - ToolbarHelper::title('Canned Responses', 'comment'); - ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuiteclient&view=tickets'); - - $wa = Factory::getApplication()->getDocument()->getWebAssetManager(); - $wa->registerAndUseStyle('com_mokosuiteclient.dashboard', 'com_mokosuiteclient/dashboard.css'); - - parent::display($tpl); - } -} diff --git a/source/packages/com_mokosuiteclient/admin/src/View/Dashboard/HtmlView.php b/source/packages/com_mokosuiteclient/admin/src/View/Dashboard/HtmlView.php index 322ba7b1..8eef3af2 100644 --- a/source/packages/com_mokosuiteclient/admin/src/View/Dashboard/HtmlView.php +++ b/source/packages/com_mokosuiteclient/admin/src/View/Dashboard/HtmlView.php @@ -27,6 +27,7 @@ class HtmlView extends BaseHtmlView protected $loginChartData = []; protected $mokoExtensions = []; public $supportPin = ''; + public $supportPinAvailable = false; public function display($tpl = null) { @@ -47,12 +48,21 @@ class HtmlView extends BaseHtmlView ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) ->where($db->quoteName('folder') . ' = ' . $db->quote('system')) ); - $token = (json_decode((string) $db->loadResult()))->health_api_token ?? ''; + $coreParams = json_decode((string) $db->loadResult()); + $healthToken = $coreParams->health_api_token ?? ''; + $this->supportPinAvailable = !empty($healthToken); - if (!empty($token)) + if (!empty($healthToken)) { - $hash = hash_hmac('sha256', gmdate('Y-m-d'), $token); - $this->supportPin = 'MOKO-' . strtoupper(substr($hash, 0, 4)) . '-' . strtoupper(substr($hash, 4, 4)); + $pinRequestedAt = $coreParams->support_pin_requested_at ?? ''; + $pinTtl = 72 * 3600; + + if (!empty($pinRequestedAt) && (time() - (int) $pinRequestedAt) < $pinTtl) + { + $window = floor((int) $pinRequestedAt / $pinTtl); + $hash = hash_hmac('sha256', (string) $window, $healthToken); + $this->supportPin = 'MOKO-' . strtoupper(substr($hash, 0, 4)) . '-' . strtoupper(substr($hash, 4, 4)); + } } } catch (\Throwable $e) {} diff --git a/source/packages/com_mokosuiteclient/admin/tmpl/canned/default.php b/source/packages/com_mokosuiteclient/admin/tmpl/canned/default.php deleted file mode 100644 index 14273e52..00000000 --- a/source/packages/com_mokosuiteclient/admin/tmpl/canned/default.php +++ /dev/null @@ -1,227 +0,0 @@ -responses; -$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; -} -?> - -
-
-
-

Canned Responses

- -
- -
- -
- -
-
-
-
-
- - title); ?> - category_id) && isset($catMap[$r->category_id])): ?> - category_id]); ?> - -
-

body), 0, 150)); ?>

-
- -
-
-
- - - -
No canned responses yet. Click "Add Response" to create one.
- -
-
- - - - - diff --git a/source/packages/com_mokosuiteclient/admin/tmpl/dashboard/default.php b/source/packages/com_mokosuiteclient/admin/tmpl/dashboard/default.php index c627f3a7..739f72dd 100644 --- a/source/packages/com_mokosuiteclient/admin/tmpl/dashboard/default.php +++ b/source/packages/com_mokosuiteclient/admin/tmpl/dashboard/default.php @@ -25,6 +25,9 @@ $atsAvail = $this->atsAvailable ?? null; $checkedOut = $this->checkedOutItems; $wafBlocks = $this->wafBlocks; $token = Session::getFormToken(); +$user = \Joomla\CMS\Factory::getApplication()->getIdentity(); +$canWafLog = $user->authorise('mokosuiteclient.security.waflog', 'com_mokosuiteclient') + || $user->authorise('core.admin', 'com_mokosuiteclient'); // Group plugins by category $grouped = []; @@ -54,13 +57,20 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action escape($siteInfo->sitename); ?> MokoSuite escape($siteInfo->mokosuiteclient_version); ?> supportPin)): ?> - escape($this->supportPin); ?> + escape($this->supportPin); ?> + supportPinAvailable)): ?> + Joomla escape($siteInfo->joomla_version); ?> PHP escape($siteInfo->php_version); ?> @@ -195,11 +205,7 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
protected): ?> - configure_only): ?> - - enabled ? Text::_('COM_MOKOSUITECLIENT_ENABLED') : Text::_('COM_MOKOSUITECLIENT_DISABLED'); ?> - - + extension_id): ?>
- type === 'plugin'): ?> + extension_id && $plugin->type === 'plugin'): ?> @@ -229,6 +235,7 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
+
@@ -238,6 +245,7 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
+
@@ -308,6 +316,7 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
+
@@ -335,6 +344,7 @@ $actionLogsEnabled = Joomla\CMS\Component\ComponentHelper::isEnabled('com_action
+
diff --git a/source/packages/com_mokosuiteclient/api/src/Controller/TicketsController.php b/source/packages/com_mokosuiteclient/api/src/Controller/TicketsController.php deleted file mode 100644 index 6250944d..00000000 --- a/source/packages/com_mokosuiteclient/api/src/Controller/TicketsController.php +++ /dev/null @@ -1,313 +0,0 @@ -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(); - } -} diff --git a/source/packages/com_mokosuiteclient/media/js/dashboard.js b/source/packages/com_mokosuiteclient/media/js/dashboard.js index 065fde22..625c218d 100644 --- a/source/packages/com_mokosuiteclient/media/js/dashboard.js +++ b/source/packages/com_mokosuiteclient/media/js/dashboard.js @@ -144,6 +144,43 @@ document.addEventListener('DOMContentLoaded', function () { }); } + // Request PIN button + var pinBtn = document.getElementById('mokosuiteclient-request-pin'); + if (pinBtn) { + pinBtn.addEventListener('click', function () { + var btn = this; + btn.disabled = true; + btn.textContent = '...'; + var fd = new FormData(); + fd.append(btn.dataset.token, '1'); + fetch(btn.dataset.url, {method: 'POST', body: fd, headers: {'X-Requested-With': 'XMLHttpRequest'}}) + .then(function (r) { return r.json(); }) + .then(function (d) { + if (d.success && d.pin) { + var badge = document.createElement('span'); + badge.className = 'badge bg-dark'; + badge.style.cssText = 'font-family:monospace;letter-spacing:0.08em;cursor:help;'; + badge.title = 'Support PIN — valid for 72 hours'; + badge.textContent = d.pin; + var icon = document.createElement('span'); + icon.className = 'icon-key small me-1'; + icon.setAttribute('aria-hidden', 'true'); + badge.prepend(icon); + btn.replaceWith(badge); + } else { + Joomla.renderMessages({error: [d.message || 'Failed to generate PIN']}); + btn.disabled = false; + btn.textContent = 'Request PIN'; + } + }) + .catch(function () { + Joomla.renderMessages({error: ['Network error']}); + btn.disabled = false; + btn.textContent = 'Request PIN'; + }); + }); + } + // Akeeba import buttons ['btn-import-admintools', 'btn-import-ats-dash'].forEach(function(id) { var btn = document.getElementById(id); diff --git a/source/packages/com_mokosuiteclient/mokosuiteclient.xml b/source/packages/com_mokosuiteclient/mokosuiteclient.xml index 30006a05..cd675411 100644 --- a/source/packages/com_mokosuiteclient/mokosuiteclient.xml +++ b/source/packages/com_mokosuiteclient/mokosuiteclient.xml @@ -20,7 +20,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.47.48 + 02.47.81 MokoSuiteClient admin dashboard and REST API. Provides a control panel for managing MokoSuiteClient feature plugins, site health monitoring, and remote management endpoints. Moko\Component\MokoSuiteClient @@ -42,7 +42,6 @@ COM_MOKOSUITECLIENT_MENU_DASHBOARD COM_MOKOSUITECLIENT_MENU_EXTENSIONS - COM_MOKOSUITECLIENT_MENU_TICKETS COM_MOKOSUITECLIENT_MENU_HTACCESS COM_MOKOSUITECLIENT_MENU_PRIVACY COM_MOKOSUITECLIENT_MENU_WAFLOG diff --git a/source/packages/com_mokosuiteclient/site/src/View/Ticket/HtmlView.php b/source/packages/com_mokosuiteclient/site/src/View/Ticket/HtmlView.php deleted file mode 100644 index b0240989..00000000 --- a/source/packages/com_mokosuiteclient/site/src/View/Ticket/HtmlView.php +++ /dev/null @@ -1,84 +0,0 @@ -get('Joomla\Database\DatabaseInterface'); - $user = Factory::getApplication()->getIdentity(); - $id = Factory::getApplication()->getInput()->getInt('id', 0); - - $this->isStaff = $user->authorise('core.admin') || $user->authorise('mokosuiteclient.tickets', 'com_mokosuiteclient'); - $this->canAssign = $user->authorise('core.admin') || $user->authorise('mokosuiteclient.tickets.assign', 'com_mokosuiteclient'); - - // Get ticket — staff see any, customers see only their own - $query = $db->getQuery(true) - ->select([ - $db->quoteName('t') . '.*', - $db->quoteName('c.title', 'category_title'), - $db->quoteName('u.name', 'created_by_name'), - $db->quoteName('u.email', 'created_by_email'), - $db->quoteName('a.name', 'assigned_to_name'), - ]) - ->from($db->quoteName('#__mokosuiteclient_tickets', 't')) - ->leftJoin($db->quoteName('#__mokosuiteclient_ticket_categories', 'c') . ' ON c.id = t.category_id') - ->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by') - ->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to') - ->where($db->quoteName('t.id') . ' = ' . $id); - - if (!$this->isStaff) - { - $query->where($db->quoteName('t.created_by') . ' = ' . (int) $user->id); - } - - $db->setQuery($query); - $this->ticket = $db->loadObject(); - - if (!$this->ticket) - { - Factory::getApplication()->enqueueMessage('Ticket not found.', 'error'); - Factory::getApplication()->redirect(Route::_('index.php?option=com_mokosuiteclient&view=tickets', false)); - - return; - } - - // Load replies — staff see internal notes, customers don't - $query = $db->getQuery(true) - ->select([ - $db->quoteName('r') . '.*', - $db->quoteName('u.name', 'user_name'), - ]) - ->from($db->quoteName('#__mokosuiteclient_ticket_replies', 'r')) - ->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id') - ->where($db->quoteName('r.ticket_id') . ' = ' . $id); - - if (!$this->isStaff) - { - $query->where($db->quoteName('r.is_internal') . ' = 0'); - } - - $query->order($db->quoteName('r.created') . ' ASC'); - $db->setQuery($query); - $this->ticket->replies = $db->loadObjectList() ?: []; - - parent::display($tpl); - } -} diff --git a/source/packages/com_mokosuiteclient/site/src/View/Tickets/HtmlView.php b/source/packages/com_mokosuiteclient/site/src/View/Tickets/HtmlView.php deleted file mode 100644 index cd97727b..00000000 --- a/source/packages/com_mokosuiteclient/site/src/View/Tickets/HtmlView.php +++ /dev/null @@ -1,75 +0,0 @@ -get('Joomla\Database\DatabaseInterface'); - $user = Factory::getApplication()->getIdentity(); - - $this->isStaff = $user->authorise('core.admin') - || $user->authorise('mokosuiteclient.tickets', 'com_mokosuiteclient'); - - // Staff see all tickets, customers see their own - $query = $db->getQuery(true) - ->select([ - $db->quoteName('t.id'), - $db->quoteName('t.subject'), - $db->quoteName('t.status'), - $db->quoteName('t.priority'), - $db->quoteName('t.created'), - $db->quoteName('t.assigned_to'), - $db->quoteName('c.title', 'category_title'), - $db->quoteName('u.name', 'created_by_name'), - $db->quoteName('a.name', 'assigned_to_name'), - ]) - ->from($db->quoteName('#__mokosuiteclient_tickets', 't')) - ->leftJoin($db->quoteName('#__mokosuiteclient_ticket_categories', 'c') . ' ON c.id = t.category_id') - ->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by') - ->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to'); - - if (!$this->isStaff) - { - $query->where($db->quoteName('t.created_by') . ' = ' . (int) $user->id); - } - - $filterStatus = Factory::getApplication()->getInput()->getString('filter_status', ''); - - if ($filterStatus) - { - $query->where($db->quoteName('t.status') . ' = ' . $db->quote($filterStatus)); - } - - $query->order($db->quoteName('t.created') . ' DESC')->setLimit(50); - $db->setQuery($query); - $this->tickets = $db->loadObjectList() ?: []; - - // Categories for new ticket form - $query = $db->getQuery(true) - ->select([$db->quoteName('id'), $db->quoteName('title')]) - ->from($db->quoteName('#__mokosuiteclient_ticket_categories')) - ->where($db->quoteName('published') . ' = 1') - ->order($db->quoteName('ordering') . ' ASC'); - $db->setQuery($query); - $this->categories = $db->loadObjectList() ?: []; - - parent::display($tpl); - } -} diff --git a/source/packages/com_mokosuiteclient/site/tmpl/ticket/default.php b/source/packages/com_mokosuiteclient/site/tmpl/ticket/default.php deleted file mode 100644 index b0971855..00000000 --- a/source/packages/com_mokosuiteclient/site/tmpl/ticket/default.php +++ /dev/null @@ -1,241 +0,0 @@ -ticket; -$isStaff = $this->isStaff; -$canAssign = $this->canAssign; -$token = Session::getFormToken(); -$userId = Factory::getApplication()->getIdentity()->id; - -$statusLabel = [ - 'open' => 'Open', 'in_progress' => 'In Progress', 'waiting' => 'Awaiting Response', - 'resolved' => 'Resolved', 'closed' => 'Closed', -]; -$statusClass = [ - 'open' => 'primary', 'in_progress' => 'info', 'waiting' => 'warning', - 'resolved' => 'success', 'closed' => 'secondary', -]; -?> - -
- - -
- -
- - -
-
-
-
-

#id; ?> — subject); ?>

- - category_title ?? 'General'); ?> - · created, 'M d, Y H:i'); ?> - · priority); ?> - - · By: created_by_name); ?> - - -
- - status] ?? $t->status; ?> - -
-
-
- - -
-
- created_by_name); ?> - created, 'M d, Y H:i'); ?> -
-
body)); ?>
-
- - - replies as $reply): ?> - user_id !== (int) $t->created_by); - $isInternal = (int) $reply->is_internal; - ?> -
-
-
- user_name ?? 'Support'); ?> - Staff - Internal Note - created, 'M d, Y H:i'); ?> -
-
-
body)); ?>
-
- - - - status, ['closed'])): ?> -
-
-
Reply
-
- - - -
- - - - -
-
-
-
- status === 'closed'): ?> -
- This ticket is closed. Open a new ticket if you need further help. -
- -
- - - -
- -
-
Details
-
-
-
Status
-
status] ?? $t->status; ?>
-
Priority
-
priority); ?>
-
Category
-
category_title ?? '—'); ?>
-
Submitted By
-
created_by_name); ?>
created_by_email ?? ''); ?>
-
Assigned To
-
assigned_to_name ?? 'Unassigned'); ?>
-
Created
-
created, 'M d H:i'); ?>
-
Replies
-
replies); ?>
-
-
-
- - -
-
Change Status
-
- 'Reopen', 'in_progress' => 'In Progress', 'waiting' => 'Waiting on Customer', 'resolved' => 'Resolve', 'closed' => 'Close'] as $s => $label): ?> - status): ?> - - - -
-
- - - -
-
Assign
-
- -
-
- -
- -
-
- - diff --git a/source/packages/com_mokosuiteclient/site/tmpl/tickets/default.php b/source/packages/com_mokosuiteclient/site/tmpl/tickets/default.php deleted file mode 100644 index 6537c29d..00000000 --- a/source/packages/com_mokosuiteclient/site/tmpl/tickets/default.php +++ /dev/null @@ -1,83 +0,0 @@ -tickets; -$categories = $this->categories; -$isStaff = $this->isStaff; -$token = Session::getFormToken(); - -$statusLabel = [ - 'open' => 'Open', 'in_progress' => 'In Progress', 'waiting' => 'Awaiting Response', - 'resolved' => 'Resolved', 'closed' => 'Closed', -]; -$statusClass = [ - 'open' => 'primary', 'in_progress' => 'info', 'waiting' => 'warning', - 'resolved' => 'success', 'closed' => 'secondary', -]; -?> - -
-
-

-
- - New Ticket - - -
- - - -
- -
-
- - -
- - -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
#SubjectStatusPriorityCategorySubmitted ByAssigned ToDate
id; ?>subject, 0, 60)); ?>status] ?? $t->status; ?>priority); ?>category_title ?? '—'); ?>created_by_name ?? ''); ?>assigned_to_name ?? 'Unassigned'); ?>created, 'M d, Y'); ?>
-
- -
diff --git a/source/packages/com_mokosuiteclient/site/tmpl/tickets/submit.php b/source/packages/com_mokosuiteclient/site/tmpl/tickets/submit.php deleted file mode 100644 index 762fce50..00000000 --- a/source/packages/com_mokosuiteclient/site/tmpl/tickets/submit.php +++ /dev/null @@ -1,204 +0,0 @@ -categories; -$token = Session::getFormToken(); -$searchUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.searchKb&format=json'); -$submitUrl = Route::_('index.php?option=com_mokosuiteclient&task=display.submitTicket&format=json'); -$ticketUrl = Route::_('index.php?option=com_mokosuiteclient&view=ticket&id='); -$ticketsUrl = Route::_('index.php?option=com_mokosuiteclient&view=tickets'); - -// Check if Smart Search has indexed content -$finderEnabled = false; -try { - $db = \Joomla\CMS\Factory::getContainer()->get('Joomla\Database\DatabaseInterface'); - $db->setQuery('SELECT COUNT(*) FROM #__finder_links WHERE published = 1'); - $finderEnabled = (int) $db->loadResult() > 0; -} catch (\Throwable $e) {} -?> - -
-

Submit a Support Request

- - - - - - - -
-
-
-
Ticket Details
-
-
- - -
-
-
- - -
-
- - -
-
-
- - -
- -
- - - My Tickets - -
-
-
-
-
-
- - diff --git a/source/packages/mod_mokosuiteclient_cache/mod_mokosuiteclient_cache.xml b/source/packages/mod_mokosuiteclient_cache/mod_mokosuiteclient_cache.xml index a25bb47f..f5d69cd4 100644 --- a/source/packages/mod_mokosuiteclient_cache/mod_mokosuiteclient_cache.xml +++ b/source/packages/mod_mokosuiteclient_cache/mod_mokosuiteclient_cache.xml @@ -7,7 +7,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.47.48 + 02.47.81 MOD_MOKOSUITECLIENT_CACHE_DESC Moko\Module\MokoSuiteClientCache diff --git a/source/packages/mod_mokosuiteclient_cache/tmpl/default.php b/source/packages/mod_mokosuiteclient_cache/tmpl/default.php index c1614b1e..6e7f3443 100644 --- a/source/packages/mod_mokosuiteclient_cache/tmpl/default.php +++ b/source/packages/mod_mokosuiteclient_cache/tmpl/default.php @@ -16,25 +16,20 @@ $tempUrl = 'index.php?option=com_mokosuiteclient&task=display.clearTemp&format= $domain = $domain ?? ''; ?> -
- -
-
-
-
- - - diff --git a/source/packages/mod_mokosuiteclient_menu/mod_mokosuiteclient_menu.xml b/source/packages/mod_mokosuiteclient_menu/mod_mokosuiteclient_menu.xml index 6d0d50c6..b785ced8 100644 --- a/source/packages/mod_mokosuiteclient_menu/mod_mokosuiteclient_menu.xml +++ b/source/packages/mod_mokosuiteclient_menu/mod_mokosuiteclient_menu.xml @@ -7,7 +7,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.47.48 + 02.47.81 MokoSuiteClient admin sidebar menu — renders a dedicated MokoSuiteClient section in the admin menu before Joomla's default menu. Moko\Module\MokoSuiteClientMenu diff --git a/source/packages/mod_mokosuiteclient_menu/tmpl/default.php b/source/packages/mod_mokosuiteclient_menu/tmpl/default.php index 35cb9322..26d0157a 100644 --- a/source/packages/mod_mokosuiteclient_menu/tmpl/default.php +++ b/source/packages/mod_mokosuiteclient_menu/tmpl/default.php @@ -17,17 +17,26 @@ $app = Factory::getApplication(); $currentOption = $app->getInput()->get('option', ''); $currentView = $app->getInput()->get('view', ''); -// ── Static views for com_mokosuiteclient ────────────────────────────────── -$mokosuiteclientStaticViews = [ - ['icon' => 'icon-cogs', 'title' => 'Dashboard', 'link' => 'index.php?option=com_mokosuiteclient'], - ['icon' => 'icon-puzzle-piece', 'title' => 'Extensions', 'link' => 'index.php?option=com_mokosuiteclient&view=extensions'], - ['icon' => 'fa-solid fa-file-code', 'title' => '.htaccess Maker', 'link' => 'index.php?option=com_mokosuiteclient&view=htaccess'], - ['icon' => 'icon-lock', 'title' => 'Privacy Guard', 'link' => 'index.php?option=com_mokosuiteclient&view=privacy'], - ['icon' => 'icon-shield-alt', 'title' => 'WAF Log', 'link' => 'index.php?option=com_mokosuiteclient&view=waflog'], - ['icon' => 'icon-database', 'title' => 'Database Tools', 'link' => 'index.php?option=com_mokosuiteclient&view=database'], - ['icon' => 'icon-trash', 'title' => 'Cache Cleanup', 'link' => 'index.php?option=com_mokosuiteclient&view=cleanup'], - ['icon' => 'icon-power-off', 'title' => 'Feature Plugins', 'link' => 'index.php?option=com_plugins&filter[folder]=system&filter[search]=mokosuiteclient'], +// ── Static views for com_mokosuiteclient (ACL-gated) ────────────────────── +$user = $app->getIdentity(); +$allViews = [ + ['icon' => 'icon-cogs', 'title' => 'Dashboard', 'link' => 'index.php?option=com_mokosuiteclient', 'acl' => 'mokosuiteclient.dashboard'], + ['icon' => 'icon-puzzle-piece', 'title' => 'Extensions', 'link' => 'index.php?option=com_mokosuiteclient&view=extensions', 'acl' => 'mokosuiteclient.extensions'], + ['icon' => 'fa-solid fa-file-code', 'title' => '.htaccess Maker', 'link' => 'index.php?option=com_mokosuiteclient&view=htaccess', 'acl' => 'mokosuiteclient.htaccess'], + ['icon' => 'icon-shield-alt', 'title' => 'WAF Log', 'link' => 'index.php?option=com_mokosuiteclient&view=waflog', 'acl' => 'mokosuiteclient.security.waflog'], + ['icon' => 'icon-lock', 'title' => 'Privacy Guard', 'link' => 'index.php?option=com_mokosuiteclient&view=privacy', 'acl' => 'core.admin'], + ['icon' => 'fa-solid fa-code', 'title' => 'Snippets', 'link' => 'index.php?option=com_mokosuiteclient&view=snippets', 'acl' => 'mokosuiteclient.snippets.manage'], + ['icon' => 'fa-solid fa-file-lines', 'title' => 'Templates', 'link' => 'index.php?option=com_mokosuiteclient&view=templates', 'acl' => 'mokosuiteclient.templates.manage'], + ['icon' => 'fa-solid fa-right-left', 'title' => 'Replacements', 'link' => 'index.php?option=com_mokosuiteclient&view=replacements','acl' => 'mokosuiteclient.replacements.manage'], + ['icon' => 'fa-solid fa-shuffle', 'title' => 'Conditions', 'link' => 'index.php?option=com_mokosuiteclient&view=conditions', 'acl' => 'mokosuiteclient.conditions.manage'], + ['icon' => 'icon-database', 'title' => 'Database Tools', 'link' => 'index.php?option=com_mokosuiteclient&view=database', 'acl' => 'core.admin'], + ['icon' => 'icon-trash', 'title' => 'Cache Cleanup', 'link' => 'index.php?option=com_mokosuiteclient&view=cleanup', 'acl' => 'mokosuiteclient.cache'], + ['icon' => 'icon-power-off', 'title' => 'Feature Plugins', 'link' => 'index.php?option=com_plugins&filter[folder]=system&filter[search]=mokosuiteclient', 'acl' => 'core.admin'], ]; +$isSuper = $user->authorise('core.admin', 'com_mokosuiteclient'); +$mokosuiteclientStaticViews = array_filter($allViews, function ($v) use ($user, $isSuper) { + return $isSuper || $user->authorise($v['acl'], 'com_mokosuiteclient'); +}); // ── Auto-discover all Moko components from #__menu ────────────────── $mokoComponents = []; diff --git a/source/packages/plg_system_mokosuiteclient/Extension/MokoSuiteClient.php b/source/packages/plg_system_mokosuiteclient/Extension/MokoSuiteClient.php index 8362280b..fb0c87e8 100644 --- a/source/packages/plg_system_mokosuiteclient/Extension/MokoSuiteClient.php +++ b/source/packages/plg_system_mokosuiteclient/Extension/MokoSuiteClient.php @@ -22,7 +22,7 @@ * DEFGROUP: Joomla.Plugin * INGROUP: MokoSuiteClient * REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - * VERSION: 02.47.48 + * VERSION: 02.47.81 * PATH: /src/Extension/MokoSuiteClient.php * NOTE: Core system plugin for MokoSuiteClient admin tools suite */ @@ -186,12 +186,15 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface */ public function onExtensionAfterSave($context, $table, $isNew) { + // Auto-clear cache on any extension save (#181) + $this->autoClearCache(); + if ($context !== 'com_plugins.plugin') { return; } - // Only act on our own plugin + // Only act on our own plugin for the rest of this handler if ($table->element !== 'mokosuiteclient' || $table->folder !== 'system') { return; @@ -2509,7 +2512,6 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface { 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'] ?? '', @@ -2522,9 +2524,11 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface */ public function onContentAfterSave($context, $article, $isNew): void { + // Auto-clear cache on content save (#181) + $this->autoClearCache(); + 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', @@ -2548,7 +2552,6 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface $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, @@ -2558,7 +2561,6 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface // 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" @@ -2583,7 +2585,6 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface 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" @@ -2863,4 +2864,1203 @@ class MokoSuiteClient extends CMSPlugin implements BootableExtensionInterface return null; } } + + // ------------------------------------------------------------------ + // Cache Auto-Clear (#181) + // ------------------------------------------------------------------ + + /** + * Clear all Joomla cache groups when the auto_clear_cache param is enabled. + * + * Called from onContentAfterSave and onExtensionAfterSave so that + * front-end visitors always see fresh content after an admin save. + * + * @return void + * + * @since 02.47.50 + */ + private function autoClearCache(): void + { + if (!$this->params->get('auto_clear_cache', 0)) + { + return; + } + + try + { + $cacheController = \Joomla\CMS\Cache\Cache::getInstance('', ['defaultgroup' => '']); + $cacheController->clean(''); + + Log::add('Cache auto-cleared on save.', Log::DEBUG, 'mokosuiteclient'); + } + catch (\Throwable $e) + { + // Silent — never break save operations + Log::add('Cache auto-clear failed: ' . $e->getMessage(), Log::WARNING, 'mokosuiteclient'); + } + } + + // ------------------------------------------------------------------ + // Advanced Module Manager — Conditions-based filtering (#160) + // ------------------------------------------------------------------ + + /** + * Filter the site module list based on ConditionsHelper rules. + * + * Modules that have no condition mappings pass through unchanged. + * Modules with condition sets are evaluated; only those whose + * conditions are satisfied for the current request are kept. + * + * @param array|null &$modules The list of module objects Joomla will render. + * + * @return void + * + * @since 02.47.52 + */ + public function onPrepareModuleList(?array &$modules): void + { + if ($modules === null || !$this->getApplication()->isClient('site')) + { + return; + } + + // Only filter if the conditions map table exists + try + { + $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); + $tables = $db->getTableList(); + $prefix = $db->getPrefix(); + + if (!\in_array($prefix . 'mokosuiteclient_conditions_map', $tables, true)) + { + return; + } + } + catch (\Throwable $e) + { + return; + } + + $filtered = []; + + foreach ($modules as $module) + { + $moduleId = (int) ($module->id ?? 0); + + if ($moduleId <= 0) + { + $filtered[] = $module; + continue; + } + + // ConditionsHelper::shouldDisplay returns true when no conditions + // are mapped (module shows everywhere) or when conditions pass. + if (\Moko\Component\MokoSuiteClient\Administrator\Helper\ConditionsHelper::shouldDisplay('com_modules', $moduleId)) + { + $filtered[] = $module; + } + } + + $modules = $filtered; + } + + // ------------------------------------------------------------------ + // Conditional Content Tags ({show}/{hide}) + // ------------------------------------------------------------------ + + /** + * Process conditional content tags in article/content body. + * + * @param string $context The context of the content being passed. + * @param object $article The article object. + * @param object $params The article params. + * @param int $page The page number. + * + * @return void + * + * @since 02.48.00 + */ + public function onContentPrepare($context, &$article, &$params, $page = 0): void + { + if ($context === 'com_finder.indexer') + { + return; + } + + $isSite = $this->getApplication()->isClient('site'); + $isAdmin = $this->getApplication()->isClient('administrator'); + $area = $isAdmin ? 'admin' : 'site'; + + $text = $article->text ?? ($article->introtext ?? ''); + + if ($text === '') + { + return; + } + + $modified = false; + + // Conditional tags (site only). + if ($isSite) + { + $hasConditional = (strpos($text, '{show') !== false || strpos($text, '{hide') !== false); + $hasSnippet = (stripos($text, '{snippet') !== false); + + if ($hasConditional) + { + $this->processConditionalTags($text); + $modified = true; + } + + if ($hasSnippet && (int) $this->params->get('snippets_enabled', 0) === 1 && $this->snippetsTableExists()) + { + $this->processSnippetTags($text); + $modified = true; + } + + if (stripos($text, '{template') !== false + && (int) $this->params->get('content_templates_enabled', 0) === 1 + && $this->contentTemplatesTableExists()) + { + $this->processTemplateTags($text); + $modified = true; + } + + if (stripos($text, '{article') !== false && (int) $this->params->get('articles_anywhere_enabled', 0) === 1) + { + $this->processArticleTags($text); + $modified = true; + } + + if (strpos($text, '{source') !== false) + { + $before = $text; + $this->processSourceTags($text); + + if ($text !== $before) + { + $modified = true; + } + } + + if (stripos($text, '{user') !== false) + { + $before = $text; + $this->processUserTags($text); + + if ($text !== $before) + { + $modified = true; + } + } + } + + // ReReplacer rules. + $before = $text; + $this->processReplacements($text, $area); + + if ($text !== $before) + { + $modified = true; + } + + if (!$modified) + { + return; + } + + // Write back to whichever property was populated. + if (isset($article->text)) + { + $article->text = $text; + } + else + { + $article->introtext = $text; + } + } + + /** + * Process conditional content tags in the final rendered HTML body. + * + * Catches tags in modules, template overrides, and other places that + * onContentPrepare does not reach. + * + * @return void + * + * @since 02.48.00 + */ + public function onAfterRender(): void + { + $isSite = $this->getApplication()->isClient('site'); + $isAdmin = $this->getApplication()->isClient('administrator'); + $area = $isAdmin ? 'admin' : 'site'; + + $body = $this->getApplication()->getBody(); + + if ($body === '') + { + return; + } + + $changed = false; + + // Conditional tags and snippets (site only). + if ($isSite) + { + if (strpos($body, '{show') !== false || strpos($body, '{hide') !== false) + { + $this->processConditionalTags($body); + $changed = true; + } + + if (stripos($body, '{snippet') !== false + && (int) $this->params->get('snippets_enabled', 0) === 1 + && $this->snippetsTableExists()) + { + $this->processSnippetTags($body); + $changed = true; + } + + if (stripos($body, '{template') !== false + && (int) $this->params->get('content_templates_enabled', 0) === 1 + && $this->contentTemplatesTableExists()) + { + $this->processTemplateTags($body); + $changed = true; + } + + if (stripos($body, '{article') !== false + && (int) $this->params->get('articles_anywhere_enabled', 0) === 1) + { + $this->processArticleTags($body); + $changed = true; + } + + if (stripos($body, '{user') !== false) + { + $before = $body; + $this->processUserTags($body); + + if ($body !== $before) + { + $changed = true; + } + } + } + + // ReReplacer rules. + $before = $body; + $this->processReplacements($body, $area); + + if ($body !== $before) + { + $changed = true; + } + + // Email protection (site only). + if ($isSite) + { + $this->protectEmails($body, $changed); + } + + if ($changed) + { + $this->getApplication()->setBody($body); + } + } + + /** + * Obfuscate email addresses in HTML output to prevent spam bot harvesting. + * + * Replaces mailto: links and plain-text email addresses with `` placeholders + * carrying base64-encoded data attributes. A small inline script reconstructs the + * addresses client-side so they are invisible to naive scrapers. + * + * @param string &$html The full response body (modified in place). + * @param bool &$changed Set to true when any replacement is made. + * + * @return void + * + * @since 02.48.00 + */ + private function protectEmails(string &$html, bool &$changed): void + { + if (!$this->params->get('protect_emails', 0)) + { + return; + } + + // Replace mailto: links first. + $html = preg_replace_callback( + '/]*?)href=["\']mailto:([^"\'?]+)([^"\']*)["\']([^>]*)>(.*?)<\/a>/si', + function ($m) use (&$changed) { + $changed = true; + $encoded = base64_encode($m[2]); + $encodedText = base64_encode($m[5]); + + return '[email protected]'; + }, + $html + ); + + // Replace plain email addresses not already inside data attributes or script tags. + $html = preg_replace_callback( + '/(?[email protected]'; + }, + $html + ); + + // Inject decloaking script before (once). + if (strpos($html, 'mokosuite-ep') !== false && strpos($html, 'mokosuite-ep-init') === false) + { + $script = ''; + + $html = str_replace('', $script . '', $html); + } + } + + /** + * Process {show} and {hide} conditional content tags. + * + * Supported syntax: + * {show condition="alias_or_id"}content{/show} + * {hide condition="alias_or_id"}content{/hide} + * {show condition="alias"}shown{else}hidden{/show} + * {show access_level="1,2"}content{/show} + * {show user_group="8"}content{/show} + * {show menu_item="101,102"}content{/show} + * {show home_page="1"}content{/show} + * + * @param string &$text The text to process (modified in place). + * + * @return void + * + * @since 02.48.00 + */ + private function processConditionalTags(string &$text): void + { + // Process innermost tags first (handles nesting by repeating). + $maxIterations = 10; + $iteration = 0; + + while ($iteration < $maxIterations + && (strpos($text, '{show') !== false || strpos($text, '{hide') !== false)) + { + $pattern = '#\{(show|hide)\s+([^}]*)\}(.*?)(?:\{else\}(.*?))?\{/\1\}#si'; + $self = $this; + + $newText = preg_replace_callback($pattern, function ($matches) use ($self) { + $tag = strtolower($matches[1]); // 'show' or 'hide' + $attributes = $matches[2]; + $content = $matches[3]; + $elseBlock = $matches[4] ?? ''; + + $passes = $self->evaluateTagCondition($attributes); + + // For {hide}, invert the logic. + if ($tag === 'hide') + { + $passes = !$passes; + } + + return $passes ? $content : $elseBlock; + }, $text); + + // If nothing changed, stop iterating. + if ($newText === $text) + { + break; + } + + $text = $newText; + $iteration++; + } + } + + /** + * Evaluate the condition specified by tag attributes. + * + * @param string $attributes The raw attribute string from inside the tag. + * + * @return bool True if the condition passes. + * + * @since 02.48.00 + */ + private function evaluateTagCondition(string $attributes): bool + { + $helper = \Moko\Component\MokoSuiteClient\Administrator\Helper\ConditionsHelper::class; + + // Parse all key="value" pairs from the attribute string. + $attrs = []; + + if (preg_match_all('/(\w+)\s*=\s*"([^"]*)"/', $attributes, $attrMatches, PREG_SET_ORDER)) + { + foreach ($attrMatches as $m) + { + $attrs[strtolower($m[1])] = $m[2]; + } + } + + // 1. Saved condition reference: condition="alias_or_id" + if (!empty($attrs['condition'])) + { + $ref = trim($attrs['condition']); + + return $helper::passByAlias($ref); + } + + // 2. Inline rules — map attribute names to rule types. + $inlineMap = [ + 'access_level' => 'visitor__access_level', + 'user_group' => 'visitor__user_group', + 'menu_item' => 'menu__menu_item', + 'home_page' => 'menu__home_page', + 'date' => 'date__date', + 'day' => 'date__day', + 'url' => 'other__url', + ]; + + // Evaluate all inline attributes; ALL must pass (AND logic). + $hasInline = false; + + foreach ($inlineMap as $attrName => $ruleType) + { + if (!isset($attrs[$attrName])) + { + continue; + } + + $hasInline = true; + $rawValue = $attrs[$attrName]; + + $params = $this->buildInlineRuleParams($ruleType, $rawValue); + + if (!$helper::evaluateInlineRule($ruleType, $params)) + { + return false; + } + } + + // If we processed at least one inline rule and none failed, pass. + if ($hasInline) + { + return true; + } + + // No recognised attributes — default to not showing. + return false; + } + + /** + * Parse key="value" attribute pairs from a tag attribute string. + * + * @param string $str The raw attribute string (e.g. 'alias="foo" color="red"'). + * + * @return array Associative array of attribute key => value pairs. + * + * @since 02.48.00 + */ + private function parseTagAttributes(string $str): array + { + $attrs = []; + + preg_match_all('/(\w+)\s*=\s*"([^"]*)"/', $str, $matches, PREG_SET_ORDER); + + foreach ($matches as $m) + { + $attrs[$m[1]] = $m[2]; + } + + return $attrs; + } + + /** + * Process {snippet alias="..."} content tags. + * + * Loads the snippet content from the database, performs variable + * substitution for any extra attributes passed as {$varname}, and + * supports nested snippets up to a configurable depth. + * + * @param string &$text The text to process (modified in place). + * @param int $depth Current recursion depth (prevents infinite loops). + * + * @return void + * + * @since 02.48.00 + */ + private function processSnippetTags(string &$text, int $depth = 0): void + { + if ($depth > 5) + { + return; // prevent infinite recursion + } + + // Match {snippet alias="my-snippet" var1="value1" var2="value2"} + $pattern = '#\{snippet\s+([^}]+)\}#i'; + + $text = preg_replace_callback($pattern, function ($match) use ($depth) { + $attrs = $this->parseTagAttributes($match[1]); + $alias = $attrs['alias'] ?? $attrs['id'] ?? ''; + + if (empty($alias)) + { + return $match[0]; + } + + // Load snippet from DB. + $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); + $query = $db->getQuery(true) + ->select($db->quoteName('content')) + ->from($db->quoteName('#__mokosuiteclient_snippets')) + ->where($db->quoteName('published') . ' = 1'); + + if (is_numeric($alias)) + { + $query->where($db->quoteName('id') . ' = ' . (int) $alias); + } + else + { + $query->where($db->quoteName('alias') . ' = ' . $db->quote($alias)); + } + + $db->setQuery($query); + $content = $db->loadResult(); + + if ($content === null) + { + return ''; // snippet not found + } + + // Replace variables: {$varname} with attribute values. + unset($attrs['alias'], $attrs['id']); + + foreach ($attrs as $key => $val) + { + $content = str_replace('{$' . $key . '}', $val, $content); + } + + // Process nested snippets. + $this->processSnippetTags($content, $depth + 1); + + return $content; + }, $text); + } + + /** + * Check whether the snippets DB table exists. + * + * @return bool + * + * @since 02.48.00 + */ + private function snippetsTableExists(): bool + { + static $exists = null; + + if ($exists !== null) + { + return $exists; + } + + try + { + $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); + $tables = $db->getTableList(); + $prefix = $db->getPrefix(); + $exists = \in_array($prefix . 'mokosuiteclient_snippets', $tables, true); + } + catch (\Exception $e) + { + $exists = false; + } + + return $exists; + } + + /** + * Process {article id="42"}template{/article} content tags. + * + * Loads a Joomla article by id, alias, or title and replaces data + * placeholders inside the template body. Supports date formatting + * via [created format="..."] and introtext truncation via + * [introtext limit="N"]. + * + * @param string &$text The text to process (modified in place). + * + * @return void + * + * @since 02.48.00 + */ + private function processArticleTags(string &$text): void + { + if (!$this->params->get('articles_anywhere_enabled', 0)) + { + return; + } + + // Match {article id="42" ...}template{/article} + $pattern = '#\{article\s+([^}]+)\}(.*?)\{/article\}#si'; + + $text = preg_replace_callback($pattern, function ($match) { + $attrs = $this->parseTagAttributes($match[1]); + $template = $match[2]; + + // Load article by id, alias, or title. + $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); + $query = $db->getQuery(true) + ->select('a.*, c.title AS category_title, u.name AS author_name, u.username AS author_username') + ->from($db->quoteName('#__content', 'a')) + ->leftJoin($db->quoteName('#__categories', 'c') . ' ON c.id = a.catid') + ->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = a.created_by') + ->where($db->quoteName('a.state') . ' = 1'); + + if (!empty($attrs['id'])) + { + $query->where($db->quoteName('a.id') . ' = ' . (int) $attrs['id']); + } + elseif (!empty($attrs['alias'])) + { + $query->where($db->quoteName('a.alias') . ' = ' . $db->quote($attrs['alias'])); + } + elseif (!empty($attrs['title'])) + { + $query->where($db->quoteName('a.title') . ' = ' . $db->quote($attrs['title'])); + } + else + { + return $match[0]; + } + + $db->setQuery($query); + $article = $db->loadObject(); + + if (!$article) + { + return ''; + } + + // Replace data tags. + $images = json_decode($article->images ?? '{}'); + $replacements = [ + '[title]' => $article->title ?? '', + '[introtext]' => $article->introtext ?? '', + '[fulltext]' => $article->fulltext ?? '', + '[text]' => ($article->introtext ?? '') . ($article->fulltext ?? ''), + '[author]' => $article->author_name ?? '', + '[author_username]' => $article->author_username ?? '', + '[category]' => $article->category_title ?? '', + '[created]' => $article->created ?? '', + '[modified]' => $article->modified ?? '', + '[publish_up]' => $article->publish_up ?? '', + '[id]' => $article->id ?? '', + '[alias]' => $article->alias ?? '', + '[catid]' => $article->catid ?? '', + '[hits]' => $article->hits ?? 0, + '[image-intro]' => $images->image_intro ?? '', + '[image-fulltext]' => $images->image_fulltext ?? '', + '[metadesc]' => $article->metadesc ?? '', + '[metakey]' => $article->metakey ?? '', + ]; + + $output = $template; + + foreach ($replacements as $tag => $value) + { + $output = str_replace($tag, $value, $output); + } + + // Handle [introtext limit="N"] truncation. + $output = preg_replace_callback('/\[introtext\s+limit="(\d+)"\]/', function ($m) use ($article) { + return \Joomla\CMS\HTML\HTMLHelper::_('string.truncate', strip_tags($article->introtext ?? ''), (int) $m[1]); + }, $output); + + // Handle [created format="d M Y"] date formatting. + $output = preg_replace_callback('/\[created\s+format="([^"]+)"\]/', function ($m) use ($article) { + return date($m[1], strtotime($article->created ?? 'now')); + }, $output); + + // Handle [modified format="d M Y"] date formatting. + $output = preg_replace_callback('/\[modified\s+format="([^"]+)"\]/', function ($m) use ($article) { + return date($m[1], strtotime($article->modified ?? 'now')); + }, $output); + + return $output; + }, $text); + } + + /** + * Build a params object for an inline rule from the raw attribute value. + * + * @param string $ruleType The rule type identifier. + * @param string $rawValue The raw comma-separated value from the tag attribute. + * + * @return object A params object suitable for ConditionsHelper::evaluateInlineRule(). + * + * @since 02.48.00 + */ + private function buildInlineRuleParams(string $ruleType, string $rawValue): object + { + $params = new \stdClass(); + + switch ($ruleType) + { + case 'visitor__access_level': + case 'visitor__user_group': + case 'menu__menu_item': + case 'date__day': + // Comma-separated list of IDs. + $params->selection = array_map('trim', explode(',', $rawValue)); + $params->comparison = 'any'; + break; + + case 'menu__home_page': + // Single boolean-like value: "1" or "0". + $params->selection = [trim($rawValue)]; + break; + + case 'date__date': + // Supports "after:2026-01-01", "before:2026-12-31", + // "between:2026-01-01,2026-12-31", or plain date (defaults to 'after'). + $parts = explode(':', $rawValue, 2); + + if (\count($parts) === 2 && \in_array($parts[0], ['before', 'after', 'between'], true)) + { + $params->comparison = $parts[0]; + $params->selection = array_map('trim', explode(',', $parts[1])); + } + else + { + $params->comparison = 'after'; + $params->selection = array_map('trim', explode(',', $rawValue)); + } + + break; + + case 'other__url': + // Comma-separated regex patterns. + $params->selection = array_map('trim', explode(',', $rawValue)); + break; + + default: + $params->selection = array_map('trim', explode(',', $rawValue)); + break; + } + + return $params; + } + + /** + * Apply backend-managed string/regex replacement rules to content. + * + * Loads published rules from `#__mokosuiteclient_replacements` filtered + * by area (site / admin / both) and applies them in ordering sequence. + * Content wrapped in `{noreplace}…{/noreplace}` is shielded from changes. + * + * @param string &$text The text to process (modified in place). + * @param string $area Current application area: 'site' or 'admin'. + * + * @return void + * + * @since 02.48.00 + */ + private function processReplacements(string &$text, string $area = 'site'): void + { + if (!$this->params->get('replacements_enabled', 0)) + { + return; + } + + try + { + $db = \Joomla\CMS\Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); + + // Check table exists. + $tables = $db->getTableList(); + + if (!in_array($db->getPrefix() . 'mokosuiteclient_replacements', $tables)) + { + return; + } + + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuiteclient_replacements')) + ->where($db->quoteName('published') . ' = 1') + ->where('(' . $db->quoteName('area') . ' = ' . $db->quote('both') + . ' OR ' . $db->quoteName('area') . ' = ' . $db->quote($area) . ')') + ->order($db->quoteName('ordering') . ' ASC'); + $db->setQuery($query); + $rules = $db->loadObjectList() ?: []; + + foreach ($rules as $rule) + { + if (empty($rule->search)) + { + continue; + } + + // Skip {noreplace}...{/noreplace} blocks. + $protected = []; + $text = preg_replace_callback( + '#\{noreplace\}(.*?)\{/noreplace\}#si', + function ($m) use (&$protected) { + $key = ''; + $protected[$key] = $m[1]; + + return $key; + }, + $text + ); + + if ($rule->regex) + { + $flags = $rule->casesensitive ? '' : 'i'; + $safeSearch = str_replace('/', '\\/', $rule->search); + $text = @preg_replace('/' . $safeSearch . '/' . $flags . 's', $rule->replace_value, $text); + } + else + { + if ($rule->casesensitive) + { + $text = str_replace($rule->search, $rule->replace_value, $text); + } + else + { + $text = str_ireplace($rule->search, $rule->replace_value, $text); + } + } + + // Restore protected blocks. + if (!empty($protected)) + { + $text = str_replace(array_keys($protected), array_values($protected), $text); + } + } + } + catch (\Throwable $e) + { + // Silently fail — replacement processing must never break rendering. + } + } + + /** + * Process {source}...{/source} tags to embed PHP, JS, and CSS in content. + * + * PHP blocks are executed via eval() with a configurable forbidden-function + * blacklist. Remaining HTML/JS/CSS passes through verbatim. + * + * @param string &$text The content text (modified in-place). + * + * @return void + * + * @since 02.48.00 + */ + private function processSourceTags(string &$text): void + { + if (!$this->params->get('sourcerer_enabled', 0)) + { + return; + } + + // Match {source}code{/source} + $pattern = '#\{source\}(.*?)\{/source\}#si'; + + $text = preg_replace_callback($pattern, function ($match) { + $code = $match[1]; + $output = ''; + + // Extract and process PHP blocks. + if (preg_match_all('/<\?php(.*?)\?>/si', $code, $phpMatches)) + { + // Security: check forbidden functions. + $forbidden = array_map( + 'trim', + explode(',', $this->params->get('sourcerer_forbidden_functions', 'exec,system,passthru,shell_exec,popen,proc_open,dl,eval')) + ); + + foreach ($phpMatches[1] as $phpCode) + { + // Check for forbidden functions and dangerous patterns. + $blocked = false; + + // Block backtick operator (shell execution) + if (strpos($phpCode, '`') !== false) + { + $blocked = true; + } + + // Block variable functions: $var(...) pattern + if (!$blocked && preg_match('/\$\w+\s*\(/', $phpCode)) + { + $blocked = true; + } + + // Block string concat function calls: ('sys'.'tem')(...) + if (!$blocked && preg_match('/[\'"][a-z]+[\'"]\s*\.\s*[\'"][a-z]+[\'"]\s*\)\s*\(/i', $phpCode)) + { + $blocked = true; + } + + if (!$blocked) + { + foreach ($forbidden as $func) + { + if (!empty($func) && preg_match('/\b' . preg_quote($func, '/') . '\s*\(/i', $phpCode)) + { + $blocked = true; + break; + } + } + } + + if (!$blocked) + { + ob_start(); + + try + { + eval($phpCode); + } + catch (\Throwable $e) + { + // Silent — source tag execution must never break rendering. + } + + $output .= ob_get_clean(); + } + } + + // Remove PHP blocks from remaining code. + $code = preg_replace('/<\?php.*?\?>/si', '', $code); + } + + // Remaining code (HTML/JS/CSS) passes through. + $output .= $code; + + return $output; + }, $text); + } + + /** + * Process {user} content tags to render user data. + * + * Supports two patterns: + * 1. {user id="42"}[name] - [email]{/user} — specific user with template + * 2. {user name} — current logged-in user field + * + * Template placeholders: [name], [username], [email], [id], + * [registerDate], [lastvisitDate], [block] + * + * @param string &$text The text to process (modified in place). + * + * @return void + * + * @since 02.48.00 + */ + private function processUserTags(string &$text): void + { + if (!$this->params->get('users_anywhere_enabled', 0)) + { + return; + } + + // Pattern 1: {user id="X"}template{/user} — specific user with template. + $pattern1 = '#\{user\s+([^}]*(?:id|username|email)=[^}]+)\}(.*?)\{/user\}#si'; + + $text = preg_replace_callback($pattern1, function ($match) { + $attrs = $this->parseTagAttributes($match[1]); + $template = $match[2]; + + $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); + $query = $db->getQuery(true) + ->select('u.*') + ->from($db->quoteName('#__users', 'u')); + + if (!empty($attrs['id'])) + { + $query->where($db->quoteName('u.id') . ' = ' . (int) $attrs['id']); + } + elseif (!empty($attrs['username'])) + { + $query->where($db->quoteName('u.username') . ' = ' . $db->quote($attrs['username'])); + } + elseif (!empty($attrs['email'])) + { + $query->where($db->quoteName('u.email') . ' = ' . $db->quote($attrs['email'])); + } + else + { + return $match[0]; + } + + $db->setQuery($query); + $user = $db->loadObject(); + + if (!$user) + { + return ''; + } + + // Security: optionally hide email/username. + $allowEmail = $this->params->get('users_allow_email', 0); + $allowUsername = $this->params->get('users_allow_username', 1); + + $output = str_replace( + ['[name]', '[username]', '[email]', '[id]', '[registerDate]', '[lastvisitDate]', '[block]'], + [ + $user->name ?? '', + $allowUsername ? ($user->username ?? '') : '***', + $allowEmail ? ($user->email ?? '') : '***', + $user->id ?? '', + $user->registerDate ?? '', + $user->lastvisitDate ?? '', + $user->block ?? 0, + ], + $template + ); + + return $output; + }, $text); + + // Pattern 2: {user name}, {user email} etc. for CURRENT logged-in user. + $pattern2 = '#\{user\s+(name|username|email|id|registerDate|lastvisitDate)\}#i'; + + $text = preg_replace_callback($pattern2, function ($match) { + $field = strtolower($match[1]); + $user = Factory::getApplication()->getIdentity(); + + if (!$user || $user->guest) + { + return ''; + } + + $allowEmail = $this->params->get('users_allow_email', 0); + $allowUsername = $this->params->get('users_allow_username', 1); + + return match ($field) { + 'name' => $user->name, + 'username' => $allowUsername ? $user->username : '***', + 'email' => $allowEmail ? $user->email : '***', + 'id' => (string) $user->id, + 'registerdate' => $user->registerDate, + 'lastvisitdate' => $user->lastvisitDate, + default => '', + }; + }, $text); + } + + /** + * Process {template alias="..."} content tags. + * + * Loads a content template from the database, decodes the JSON + * `template_data` column, and returns the concatenation of its + * `introtext` and `fulltext` fields. + * + * @param string &$text The text to process (modified in place). + * + * @return void + * + * @since 02.48.00 + */ + private function processTemplateTags(string &$text): void + { + if (!$this->params->get('content_templates_enabled', 0)) + { + return; + } + + $pattern = '#\{template\s+([^}]+)\}#i'; + + $text = preg_replace_callback($pattern, function ($match) { + $attrs = $this->parseTagAttributes($match[1]); + $alias = $attrs['alias'] ?? $attrs['id'] ?? ''; + + if (empty($alias)) + { + return $match[0]; + } + + try + { + $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); + $query = $db->getQuery(true) + ->select($db->quoteName('template_data')) + ->from($db->quoteName('#__mokosuiteclient_content_templates')) + ->where($db->quoteName('published') . ' = 1'); + + if (is_numeric($alias)) + { + $query->where($db->quoteName('id') . ' = ' . (int) $alias); + } + else + { + $query->where($db->quoteName('alias') . ' = ' . $db->quote($alias)); + } + + $db->setQuery($query); + $data = json_decode($db->loadResult() ?? '{}'); + + return ($data->introtext ?? '') . ($data->fulltext ?? ''); + } + catch (\Throwable $e) + { + return ''; + } + }, $text); + } + + /** + * Check whether the content_templates DB table exists. + * + * @return bool + * + * @since 02.48.00 + */ + private function contentTemplatesTableExists(): bool + { + static $exists = null; + + if ($exists !== null) + { + return $exists; + } + + try + { + $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); + $tables = $db->getTableList(); + $prefix = $db->getPrefix(); + $exists = \in_array($prefix . 'mokosuiteclient_content_templates', $tables, true); + } + catch (\Exception $e) + { + $exists = false; + } + + return $exists; + } } diff --git a/source/packages/plg_system_mokosuiteclient/Field/ArticlesField.php b/source/packages/plg_system_mokosuiteclient/Field/ArticlesField.php new file mode 100644 index 00000000..f9c9284b --- /dev/null +++ b/source/packages/plg_system_mokosuiteclient/Field/ArticlesField.php @@ -0,0 +1,67 @@ + + * + * @since 02.47.62 + */ +class ArticlesField extends ListField +{ + protected $type = 'Articles'; + + protected function getOptions(): array + { + $options = parent::getOptions(); + + $db = Factory::getContainer()->get(DatabaseInterface::class); + $query = $db->getQuery(true) + ->select([ + $db->quoteName('a.id', 'value'), + $db->quoteName('a.title', 'text'), + $db->quoteName('c.title', 'category'), + ]) + ->from($db->quoteName('#__content', 'a')) + ->leftJoin($db->quoteName('#__categories', 'c') . ' ON c.id = a.catid') + ->where($db->quoteName('a.state') . ' = 1') + ->order($db->quoteName('a.title') . ' ASC'); + + $db->setQuery($query); + $articles = $db->loadObjectList() ?: []; + + foreach ($articles as $article) { + $label = $article->text; + if (!empty($article->category)) { + $label .= ' [' . $article->category . ']'; + } + $options[] = HTMLHelper::_('select.option', $article->value, $label); + } + + return $options; + } +} diff --git a/source/packages/plg_system_mokosuiteclient/Field/CopyableTokenField.php b/source/packages/plg_system_mokosuiteclient/Field/CopyableTokenField.php index f73a88b9..b5abeb77 100644 --- a/source/packages/plg_system_mokosuiteclient/Field/CopyableTokenField.php +++ b/source/packages/plg_system_mokosuiteclient/Field/CopyableTokenField.php @@ -8,7 +8,7 @@ * FILE INFORMATION * DEFGROUP: Joomla.Plugin * INGROUP: MokoSuiteClient - * VERSION: 02.47.48 + * VERSION: 02.47.81 * PATH: /src/Field/CopyableTokenField.php * BRIEF: Read-only token field with a copy-to-clipboard button */ diff --git a/source/packages/plg_system_mokosuiteclient/mokosuiteclient.xml b/source/packages/plg_system_mokosuiteclient/mokosuiteclient.xml index 0205bc8e..1ea83c45 100644 --- a/source/packages/plg_system_mokosuiteclient/mokosuiteclient.xml +++ b/source/packages/plg_system_mokosuiteclient/mokosuiteclient.xml @@ -30,7 +30,7 @@ GNU General Public License version 3 or later; see LICENSE.md hello@mokoconsulting.tech https://mokoconsulting.tech - 02.47.48 + 02.47.81 MokoSuiteClient core system plugin — coordinates feature plugins, heartbeat, health checks, and admin customizations. Moko\Plugin\System\MokoSuiteClient script.php @@ -99,7 +99,94 @@ description="PLG_SYSTEM_MOKOSUITECLIENT_MONITOR_BASE_URL_DESC" filter="url" /> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/source/packages/plg_system_mokosuiteclient/script.php b/source/packages/plg_system_mokosuiteclient/script.php index f77b21fb..18774eb8 100644 --- a/source/packages/plg_system_mokosuiteclient/script.php +++ b/source/packages/plg_system_mokosuiteclient/script.php @@ -22,7 +22,7 @@ * DEFGROUP: Joomla.Plugin * INGROUP: MokoSuiteClient * REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - * VERSION: 02.47.48 + * VERSION: 02.47.81 * PATH: /src/script.php * BRIEF: Installation script for MokoSuiteClient plugin * NOTE: Handles installation, update, and uninstallation tasks including language override deployment diff --git a/source/packages/plg_system_mokosuiteclient/services/provider.php b/source/packages/plg_system_mokosuiteclient/services/provider.php index 8fb66051..af652691 100644 --- a/source/packages/plg_system_mokosuiteclient/services/provider.php +++ b/source/packages/plg_system_mokosuiteclient/services/provider.php @@ -22,7 +22,7 @@ * DEFGROUP: Joomla.Plugin * INGROUP: MokoSuiteClient * REPO: https://github.com/mokoconsulting-tech/mokosuiteclient - * VERSION: 02.47.48 + * VERSION: 02.47.81 * PATH: /src/services/provider.php * BRIEF: Service provider for dependency injection in Joomla 5.x * NOTE: Registers the plugin with Joomla's DI container diff --git a/source/packages/plg_system_mokosuiteclient_backup/mokosuiteclient_backup.xml b/source/packages/plg_system_mokosuiteclient_backup/mokosuiteclient_backup.xml index 43ada9a1..04a08409 100644 --- a/source/packages/plg_system_mokosuiteclient_backup/mokosuiteclient_backup.xml +++ b/source/packages/plg_system_mokosuiteclient_backup/mokosuiteclient_backup.xml @@ -8,7 +8,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.47.48 + 02.47.81 PLG_SYSTEM_MOKOSUITECLIENT_BACKUP_DESC Moko\Plugin\System\MokoSuiteClientBackup diff --git a/source/packages/plg_system_mokosuiteclient_dbip/mokosuiteclient_dbip.xml b/source/packages/plg_system_mokosuiteclient_dbip/mokosuiteclient_dbip.xml index 8fe8e26f..c614e2d2 100644 --- a/source/packages/plg_system_mokosuiteclient_dbip/mokosuiteclient_dbip.xml +++ b/source/packages/plg_system_mokosuiteclient_dbip/mokosuiteclient_dbip.xml @@ -8,7 +8,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.47.48 + 02.47.81 PLG_SYSTEM_MOKOSUITECLIENT_DBIP_DESC Moko\Plugin\System\MokoSuiteClientDBIP diff --git a/source/packages/plg_system_mokosuiteclient_devtools/mokosuiteclient_devtools.xml b/source/packages/plg_system_mokosuiteclient_devtools/mokosuiteclient_devtools.xml index b879f72c..345de540 100644 --- a/source/packages/plg_system_mokosuiteclient_devtools/mokosuiteclient_devtools.xml +++ b/source/packages/plg_system_mokosuiteclient_devtools/mokosuiteclient_devtools.xml @@ -8,7 +8,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.47.48 + 02.47.81 PLG_SYSTEM_MOKOSUITECLIENT_DEVTOOLS_DESC Moko\Plugin\System\MokoSuiteClientDevTools diff --git a/source/packages/plg_system_mokosuiteclient_firewall/mokosuiteclient_firewall.xml b/source/packages/plg_system_mokosuiteclient_firewall/mokosuiteclient_firewall.xml index de3d08c0..b2a486a7 100644 --- a/source/packages/plg_system_mokosuiteclient_firewall/mokosuiteclient_firewall.xml +++ b/source/packages/plg_system_mokosuiteclient_firewall/mokosuiteclient_firewall.xml @@ -8,7 +8,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.47.48 + 02.47.81 PLG_SYSTEM_MOKOSUITECLIENT_FIREWALL_DESC Moko\Plugin\System\MokoSuiteClientFirewall diff --git a/source/packages/plg_system_mokosuiteclient_license/mokosuiteclient_license.xml b/source/packages/plg_system_mokosuiteclient_license/mokosuiteclient_license.xml index 84c28249..e51edb10 100644 --- a/source/packages/plg_system_mokosuiteclient_license/mokosuiteclient_license.xml +++ b/source/packages/plg_system_mokosuiteclient_license/mokosuiteclient_license.xml @@ -8,7 +8,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.47.48 + 02.47.81 PLG_SYSTEM_MOKOSUITECLIENT_LICENSE_DESC Moko\Plugin\System\MokoSuiteClientLicense srcserviceslanguage diff --git a/source/packages/plg_system_mokosuiteclient_offline/mokosuiteclient_offline.xml b/source/packages/plg_system_mokosuiteclient_offline/mokosuiteclient_offline.xml index f311cb6f..cc0d3252 100644 --- a/source/packages/plg_system_mokosuiteclient_offline/mokosuiteclient_offline.xml +++ b/source/packages/plg_system_mokosuiteclient_offline/mokosuiteclient_offline.xml @@ -8,7 +8,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.47.48 + 02.47.81 PLG_SYSTEM_MOKOSUITECLIENT_OFFLINE_DESC Moko\Plugin\System\MokoSuiteClientOffline diff --git a/source/packages/plg_system_mokosuiteclient_tenant/mokosuiteclient_tenant.xml b/source/packages/plg_system_mokosuiteclient_tenant/mokosuiteclient_tenant.xml index 80ffe40f..3f8cd376 100644 --- a/source/packages/plg_system_mokosuiteclient_tenant/mokosuiteclient_tenant.xml +++ b/source/packages/plg_system_mokosuiteclient_tenant/mokosuiteclient_tenant.xml @@ -8,7 +8,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.47.48 + 02.47.81 PLG_SYSTEM_MOKOSUITECLIENT_TENANT_DESC Moko\Plugin\System\MokoSuiteClientTenant diff --git a/source/packages/plg_task_mokosuiteclientdemo/mokosuiteclientdemo.xml b/source/packages/plg_task_mokosuiteclientdemo/mokosuiteclientdemo.xml index 90eacc99..3b2f5d6e 100644 --- a/source/packages/plg_task_mokosuiteclientdemo/mokosuiteclientdemo.xml +++ b/source/packages/plg_task_mokosuiteclientdemo/mokosuiteclientdemo.xml @@ -12,7 +12,7 @@ GNU General Public License version 3 or later; see LICENSE hello@mokoconsulting.tech https://mokoconsulting.tech - 02.47.48 + 02.47.81 PLG_TASK_MOKOSUITECLIENTDEMO_DESC Moko\Plugin\Task\MokoSuiteClientDemo diff --git a/source/packages/plg_task_mokosuiteclientdemo/src/Service/DemoResetService.php b/source/packages/plg_task_mokosuiteclientdemo/src/Service/DemoResetService.php index 4beb1702..28696cb2 100644 --- a/source/packages/plg_task_mokosuiteclientdemo/src/Service/DemoResetService.php +++ b/source/packages/plg_task_mokosuiteclientdemo/src/Service/DemoResetService.php @@ -10,7 +10,7 @@ * INGROUP: MokoSuiteClient * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient * PATH: /src/packages/plg_system_mokosuiteclient/Service/DemoResetService.php - * VERSION: 02.47.48 + * VERSION: 02.47.81 * BRIEF: Content-only snapshot/restore for demo site reset */ diff --git a/source/packages/plg_task_mokosuiteclientsync/mokosuiteclientsync.xml b/source/packages/plg_task_mokosuiteclientsync/mokosuiteclientsync.xml index 83a63190..b41dee62 100644 --- a/source/packages/plg_task_mokosuiteclientsync/mokosuiteclientsync.xml +++ b/source/packages/plg_task_mokosuiteclientsync/mokosuiteclientsync.xml @@ -12,7 +12,7 @@ GNU General Public License version 3 or later; see LICENSE hello@mokoconsulting.tech https://mokoconsulting.tech - 02.47.48 + 02.47.81 PLG_TASK_MOKOSUITECLIENTSYNC_DESC Moko\Plugin\Task\MokoSuiteClientSync diff --git a/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncReceiver.php b/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncReceiver.php index 29c31482..27d88694 100644 --- a/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncReceiver.php +++ b/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncReceiver.php @@ -10,7 +10,7 @@ * INGROUP: MokoSuiteClient * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient * PATH: /src/packages/plg_system_mokosuiteclient/Service/ContentSyncReceiver.php - * VERSION: 02.47.48 + * VERSION: 02.47.81 * BRIEF: Receiver-side content sync — applies incoming payload to local DB */ diff --git a/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncService.php b/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncService.php index 62b64f55..6ab5f90d 100644 --- a/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncService.php +++ b/source/packages/plg_task_mokosuiteclientsync/src/Service/ContentSyncService.php @@ -10,7 +10,7 @@ * INGROUP: MokoSuiteClient * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient * PATH: /src/packages/plg_system_mokosuiteclient/Service/ContentSyncService.php - * VERSION: 02.47.48 + * VERSION: 02.47.81 * BRIEF: Sender-side content sync — builds payload and pushes to remote sites */ diff --git a/source/packages/plg_webservices_mokosuiteclient/mokosuiteclient.xml b/source/packages/plg_webservices_mokosuiteclient/mokosuiteclient.xml index 380fd970..d4d53b7b 100644 --- a/source/packages/plg_webservices_mokosuiteclient/mokosuiteclient.xml +++ b/source/packages/plg_webservices_mokosuiteclient/mokosuiteclient.xml @@ -7,7 +7,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.47.48 + 02.47.81 Joomla Web Services API routes for MokoSuiteClient site management — health checks, cache, updates, backups, and site info. Moko\Plugin\WebServices\MokoSuiteClient diff --git a/source/packages/plg_webservices_mokosuiteclient/src/Extension/MokoSuiteClientApi.php b/source/packages/plg_webservices_mokosuiteclient/src/Extension/MokoSuiteClientApi.php index 7dd72165..faf2cad0 100644 --- a/source/packages/plg_webservices_mokosuiteclient/src/Extension/MokoSuiteClientApi.php +++ b/source/packages/plg_webservices_mokosuiteclient/src/Extension/MokoSuiteClientApi.php @@ -155,22 +155,5 @@ final class MokoSuiteClientApi extends CMSPlugin implements SubscriberInterface ) ); - // Helpdesk Tickets API (#142) - $router->createCRUDRoutes( - 'v1/mokosuiteclient/tickets', - 'tickets', - ['component' => 'com_mokosuiteclient'] - ); - - // Ticket reply (custom route — POST only) - $router->addRoute( - new \Joomla\Router\Route( - ['POST'], - 'v1/mokosuiteclient/tickets/:id/reply', - 'tickets.reply', - ['id' => '(\d+)'], - ['component' => 'com_mokosuiteclient'] - ) - ); } } diff --git a/source/pkg_mokosuiteclient.xml b/source/pkg_mokosuiteclient.xml index cfef48c6..4d18df83 100644 --- a/source/pkg_mokosuiteclient.xml +++ b/source/pkg_mokosuiteclient.xml @@ -2,7 +2,7 @@ Package - MokoSuiteClient mokosuiteclient - 02.47.48 + 02.47.81 2026-06-02 Moko Consulting hello@mokoconsulting.tech