diff --git a/.gitignore b/.gitignore index ee502562..e22eb9cb 100644 --- a/.gitignore +++ b/.gitignore @@ -122,6 +122,7 @@ build/ dist/ out/ site/ +!src/packages/*/site/ *.map *.css.map *.js.map diff --git a/.mokogitea/manifest.xml b/.mokogitea/manifest.xml index 30237d02..5c62c67e 100644 --- a/.mokogitea/manifest.xml +++ b/.mokogitea/manifest.xml @@ -9,7 +9,7 @@ Package - MokoWaaS MokoConsulting White-label identity, security hardening, and tenant restriction layer for WaaS-managed Joomla environments - 02.33.00 + 02.32.52 GNU General Public License v3 diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index d2e729db..ff7f0ce6 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.33.00 +# VERSION: 02.32.52 # BRIEF: Auto-create feature branch when an issue is opened name: "Universal: Issue Branch" diff --git a/.mokogitea/workflows/update-server.yml b/.mokogitea/workflows/update-server.yml deleted file mode 100644 index ac5c9a52..00000000 --- a/.mokogitea/workflows/update-server.yml +++ /dev/null @@ -1,302 +0,0 @@ -# Copyright (C) 2026 Moko Consulting -# -# SPDX-License-Identifier: GPL-3.0-or-later -# -# FILE INFORMATION -# DEFGROUP: Gitea.Workflow -# INGROUP: moko-platform.Universal -# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform -# PATH: /templates/workflows/update-server.yml -# VERSION: 09.23.00 -# BRIEF: Pre-release build + update server XML for dev/alpha/beta/rc branches -# -# Thin wrapper around moko-platform CLI tools. -# Builds packages, updates updates.xml, and optionally deploys via SFTP. -# -# Joomla filters update entries by the user's "Minimum Stability" setting. - -name: "Update Server" - -on: - push: - branches: - - 'dev' - - 'dev/**' - - 'alpha/**' - - 'beta/**' - - 'rc/**' - paths: - - 'src/**' - - 'htdocs/**' - pull_request: - types: [closed] - branches: - - 'dev' - - 'dev/**' - - 'alpha/**' - - 'beta/**' - - 'rc/**' - paths: - - 'src/**' - - 'htdocs/**' - workflow_dispatch: - inputs: - stability: - description: 'Stability tag' - required: true - default: 'development' - type: choice - options: - - development - - alpha - - beta - - rc - - stable - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} - GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }} - GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }} - -permissions: - contents: write - -jobs: - update-xml: - name: Update Server - runs-on: release - if: >- - github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' || github.event_name == 'push' - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - token: ${{ secrets.MOKOGITEA_TOKEN }} - fetch-depth: 0 - - - name: Setup moko-platform tools - env: - MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} - MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting - COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.MOKOGITEA_TOKEN }}"}}}' - run: | - if ! command -v composer &> /dev/null; then - sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1 - fi - # Always fetch latest CLI tools — never use stale cache from previous runs - rm -rf /tmp/moko-platform - git clone --depth 1 --branch main --quiet \ - "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \ - /tmp/moko-platform 2>/dev/null || true - if [ -d "/tmp/moko-platform" ] && [ -f "/tmp/moko-platform/composer.json" ]; then - cd /tmp/moko-platform && composer install --no-dev --no-interaction --quiet 2>/dev/null || true - fi - echo "MOKO_CLI=/tmp/moko-platform/cli" >> "$GITHUB_ENV" - - - name: Detect platform - id: platform - run: php ${MOKO_CLI}/manifest_read.php --path . --github-output - - - name: Resolve stability and bump version - id: meta - run: | - BRANCH="${{ github.ref_name }}" - - # Configure git for bot pushes - git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" - git config --local user.name "gitea-actions[bot]" - git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" - - # Determine stability from branch or manual input - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - STABILITY="${{ inputs.stability }}" - elif [[ "$BRANCH" == rc/* ]]; then - STABILITY="rc" - elif [[ "$BRANCH" == beta/* ]]; then - STABILITY="beta" - elif [[ "$BRANCH" == alpha/* ]]; then - STABILITY="alpha" - else - STABILITY="development" - fi - - # Gitea release tag per stability - case "$STABILITY" in - development) TAG="development" ;; - alpha) TAG="alpha" ;; - beta) TAG="beta" ;; - rc) TAG="release-candidate" ;; - *) TAG="stable" ;; - esac - - # Bump patch, set platform suffix, fix consistency — version_bump preserves suffix - php ${MOKO_CLI}/version_set_platform.php \ - --path . --version "$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo '00.00.01')" \ - --branch "$BRANCH" --stability "$STABILITY" 2>/dev/null || true - php ${MOKO_CLI}/version_bump.php --path . 2>/dev/null || true - php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true - - # Read final version (includes suffix, e.g. 01.02.15-dev) - VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01") - - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT" - echo "tag=${TAG}" >> "$GITHUB_OUTPUT" - - # Commit version bump if changed - git add -A - git diff --cached --quiet || { - git commit -m "chore(version): auto-bump ${VERSION} [skip ci]" \ - --author="gitea-actions[bot] " - git push - } - - - name: Create release and upload package - id: package - run: | - VERSION="${{ steps.meta.outputs.version }}" - TAG="${{ steps.meta.outputs.tag }}" - API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - - # Create or update Gitea release - php ${MOKO_CLI}/release_create.php \ - --path . --version "$VERSION" --tag "$TAG" \ - --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \ - --repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease - - # Build package and upload - php ${MOKO_CLI}/release_package.php \ - --path . --version "$VERSION" --tag "$TAG" \ - --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \ - --repo "${GITEA_REPO}" --output /tmp || true - - - name: Update updates.xml - if: steps.platform.outputs.platform == 'joomla' - run: | - VERSION="${{ steps.meta.outputs.version }}" - STABILITY="${{ steps.meta.outputs.stability }}" - SHA256="${{ steps.package.outputs.sha256_zip }}" - - if [ ! -f "updates.xml" ]; then - echo "No updates.xml — skipping" - exit 0 - fi - - SHA_FLAG="" - [ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}" - - php ${MOKO_CLI}/updates_xml_build.php \ - --path . --version "${VERSION}" --stability "${STABILITY}" \ - --gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \ - ${SHA_FLAG} - - # Commit and push updates.xml - git add updates.xml - git diff --cached --quiet || { - git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]" - git push - } - - - name: Sync updates.xml to main - if: github.ref_name != 'main' && steps.platform.outputs.platform == 'joomla' - run: | - API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - GITEA_TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" - - FILE_SHA=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" \ - "${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true) - - if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then - python3 -c " - import base64, json, urllib.request, sys - with open('updates.xml', 'rb') as f: - content = base64.b64encode(f.read()).decode() - payload = json.dumps({ - 'content': content, - 'sha': '${FILE_SHA}', - 'message': 'chore: sync updates.xml from ${{ steps.meta.outputs.stability }} [skip ci]', - 'branch': 'main' - }).encode() - req = urllib.request.Request( - '${API_BASE}/contents/updates.xml', - data=payload, method='PUT', - headers={ - 'Authorization': 'token ${GITEA_TOKEN}', - 'Content-Type': 'application/json' - }) - try: - urllib.request.urlopen(req) - print('updates.xml synced to main') - except Exception as e: - print(f'WARNING: sync to main failed: {e}', file=sys.stderr) - " - fi - - - name: SFTP deploy to dev server - if: contains(github.ref, 'dev/') || github.ref == 'refs/heads/dev' - env: - DEV_HOST: ${{ vars.DEV_FTP_HOST }} - DEV_PATH: ${{ vars.DEV_FTP_PATH }} - DEV_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }} - DEV_USER: ${{ vars.DEV_FTP_USERNAME }} - DEV_PORT: ${{ vars.DEV_FTP_PORT }} - DEV_KEY: ${{ secrets.DEV_FTP_KEY }} - DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }} - run: | - # Permission check: admin or maintain role required - ACTOR="${{ github.actor }}" - API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - - PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \ - "${API_BASE}/collaborators/${ACTOR}/permission" 2>/dev/null | \ - python3 -c "import sys,json; print(json.load(sys.stdin).get('permission','read'))" 2>/dev/null || echo "read") - case "$PERMISSION" in - admin|maintain|write) ;; - *) - echo "Deploy denied: ${ACTOR} has '${PERMISSION}' — requires admin, maintain, or write" - exit 0 - ;; - esac - - [ -z "$DEV_HOST" ] || [ -z "$DEV_PATH" ] && { echo "DEV FTP not configured — skipping SFTP"; exit 0; } - - SOURCE_DIR="src" - [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs" - [ ! -d "$SOURCE_DIR" ] && exit 0 - - PORT="${DEV_PORT:-22}" - REMOTE="${DEV_PATH%/}" - [ -n "$DEV_SUFFIX" ] && REMOTE="${REMOTE}/${DEV_SUFFIX#/}" - - printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \ - "$DEV_HOST" "$PORT" "$DEV_USER" "$REMOTE" > /tmp/sftp-config.json - if [ -n "$DEV_KEY" ]; then - echo "$DEV_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key - printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json - else - printf ',"password":"%s"}' "$DEV_PASS" >> /tmp/sftp-config.json - fi - - PLATFORM=$(php ${MOKO_CLI}/platform_detect.php --path . 2>/dev/null || true) - if [ "$PLATFORM" = "waas-component" ] && [ -f "${MOKO_CLI}/../deploy/deploy-joomla.php" ]; then - php ${MOKO_CLI}/../deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json - elif [ -f "${MOKO_CLI}/../deploy/deploy-sftp.php" ]; then - php ${MOKO_CLI}/../deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json - fi - rm -f /tmp/deploy_key /tmp/sftp-config.json - echo "SFTP deploy to dev complete" >> $GITHUB_STEP_SUMMARY - - - name: Summary - if: always() - run: | - VERSION="${{ steps.meta.outputs.version }}" - STABILITY="${{ steps.meta.outputs.stability }}" - DISPLAY="${VERSION}" - echo "## Update Server" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY - echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY - echo "| Stability | \`${STABILITY}\` |" >> $GITHUB_STEP_SUMMARY - echo "| Version | \`${DISPLAY}\` |" >> $GITHUB_STEP_SUMMARY diff --git a/CHANGELOG.md b/CHANGELOG.md index 1118c62f..d1967707 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,11 +14,43 @@ INGROUP: MokoWaaS.Documentation REPO: https://github.com/mokoconsulting-tech/mokowaas PATH: ./CHANGELOG.md - VERSION: 02.33.00 + VERSION: 02.32.52 BRIEF: Version history using `Keep a Changelog` --> -# Changelog## [02.32.00] - 2026-06-02 +# Changelog +## [02.33.00] - 2026-06-04 +### Added +- Database Tools view — table status, optimize, repair, session purge (#127) +- Cache Cleanup view — directory size reporting and one-click cleanup (#128) +- mod_mokowaas_cache — one-click cache cleaner button in admin status bar (replaces Regular Labs Cache Cleaner) +- mod_mokowaas_menu — collapsible admin sidebar menu using native MetisMenu classes (like Community Builder) +- SSL certificate expiry monitoring in cpanel module (#148) +- MokoWaaS-specific update badge (blue) separate from other updates in cpanel module +- migrateUpdateServerUrls() — rewrites all Moko extension update server URLs from old /raw/branch/main/ to clean /updates.xml on install/update +- fixMenuIcons() — sets menu_icon params on submenu items (Joomla only renders img on level 1) +- setupCacheModule() — registers cache cleaner module in status bar position on install +- Component config.xml for Joomla Options modal (#149) + +### Changed +- Admin menu module uses native Joomla MetisMenu CSS classes (item, has-arrow, mm-collapse, mm-active, sidebar-item-title) +- Helpdesk icon changed to fa-handshake-angle, .htaccess to fa-solid fa-file-code (unmapped in joomla-fontawesome.css) +- clearCache now purges all cache files recursively (not just expired), matching Regular Labs behavior +- License key warning moved from every-page onAfterRoute to package postflight only +- Update server URL changed to https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/updates.xml (dynamic MokoGitea feed) +- Component manifest adds `` element so sys.ini deploys to global language dir for menu translation +- Privacy and WAF Log added to component manifest submenu + +### Removed +- Static updates.xml — MokoGitea generates the update feed dynamically from releases +- update-server.yml workflow — no longer needed with dynamic MokoGitea endpoint + +### Fixed +- Tickets list showing raw `Unassigned` HTML instead of italic text +- Cache cleaner CSRF failure — token now sent as POST FormData instead of URL query param +- Admin menu icons missing for Helpdesk and .htaccess Maker (icon classes not in joomla-fontawesome.css) + +## [02.32.00] - 2026-06-02 ### Added - Admin control panel dashboard in com_mokowaas with site info bar, feature plugin grid, and quick actions - Feature plugin architecture — MokoWaaS features split into toggleable plugins managed from the dashboard @@ -107,3 +139,13 @@ ## [02.20.00] --- 2026-05-28 ## [02.20.00] --- 2026-05-28 + +## [02.19.00] --- 2026-05-28 + +## [02.18.00] --- 2026-05-28 + + +All notable changes to the MokoWaaS plugin will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 3a0a46aa..48a8ff3c 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -14,7 +14,7 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS.Documentation REPO: https://github.com/mokoconsulting-tech/mokowaas - VERSION: 02.33.00 + VERSION: 02.32.52 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 8ea124af..803cad69 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -19,7 +19,7 @@ DEFGROUP: mokoconsulting-tech.MokoWaaSBrand INGROUP: MokoStandards.Governance REPO: https://github.com/mokoconsulting-tech/MokoWaaSBrand - VERSION: 02.33.00 + VERSION: 02.32.52 PATH: /GOVERNANCE.md BRIEF: Project governance rules, roles, and decision process for MokoWaaSBrand --> diff --git a/LICENSE.md b/LICENSE.md index 1197c9f5..8970a931 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -15,7 +15,7 @@ INGROUP: MokoWaaS.Documentation REPO: https://github.com/mokoconsulting-tech/mokowaas PATH: ./LICENSE.md - VERSION: 02.33.00 + VERSION: 02.32.52 BRIEF: Project license (GPL-3.0-or-later) --> GNU GENERAL PUBLIC LICENSE diff --git a/README.md b/README.md index 0eba6c43..adee1903 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS - VERSION: 02.33.00 + VERSION: 02.32.52 PATH: /README.md BRIEF: MokoWaaS platform plugin for Joomla --> diff --git a/SECURITY.md b/SECURITY.md index 6ba5df75..c24d1fef 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.33.00 +VERSION: 02.32.52 BRIEF: Security vulnerability reporting and handling policy --> diff --git a/docs/guides/build-guide.md b/docs/guides/build-guide.md index 97ab483e..cac745ee 100644 --- a/docs/guides/build-guide.md +++ b/docs/guides/build-guide.md @@ -11,13 +11,13 @@ INGROUP: MokoWaaS.Build REPO: https://github.com/mokoconsulting-tech/mokowaas FILE: build-guide.md - VERSION: 02.33.00 + VERSION: 02.32.52 PATH: /docs/guides/ BRIEF: Build and packaging guide for the MokoWaaS system plugin NOTE: Defines environment setup, repository layout, packaging rules, and release preparation --> -# MokoWaaS Build Guide (VERSION: 02.33.00) +# MokoWaaS Build Guide (VERSION: 02.32.52) ## 1. Purpose diff --git a/docs/guides/configuration-guide.md b/docs/guides/configuration-guide.md index b751daaa..24f7208b 100644 --- a/docs/guides/configuration-guide.md +++ b/docs/guides/configuration-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS.Guides REPO: https://github.com/mokoconsulting-tech/mokowaas - VERSION: 02.33.00 + VERSION: 02.32.52 PATH: /docs/guides/configuration-guide.md BRIEF: Configuration guide for the MokoWaaS system plugin NOTE: Defines plugin parameters, expected behaviors, and recommended defaults --> -# MokoWaaS Configuration Guide (VERSION: 02.33.00) +# MokoWaaS Configuration Guide (VERSION: 02.32.52) ## 1. Objective diff --git a/docs/guides/installation-guide.md b/docs/guides/installation-guide.md index 092389ef..6d7bcf46 100644 --- a/docs/guides/installation-guide.md +++ b/docs/guides/installation-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS.Guides REPO: https://github.com/mokoconsulting-tech/mokowaas - VERSION: 02.33.00 + VERSION: 02.32.52 PATH: /docs/guides/installation-guide.md BRIEF: Installation guide for the MokoWaaS system plugin NOTE: First document in the guide set --> -# MokoWaaS Installation Guide (VERSION: 02.33.00) +# MokoWaaS Installation Guide (VERSION: 02.32.52) ## Introduction diff --git a/docs/guides/operations-guide.md b/docs/guides/operations-guide.md index 1b6b1182..842cba4d 100644 --- a/docs/guides/operations-guide.md +++ b/docs/guides/operations-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS.Guides REPO: https://github.com/mokoconsulting-tech/mokowaas - VERSION: 02.33.00 + VERSION: 02.32.52 PATH: /docs/guides/operations-guide.md BRIEF: Operational guide for administering and managing the MokoWaaS system plugin NOTE: Defines lifecycle, responsibilities, and operational behaviors --> -# MokoWaaS Operations Guide (VERSION: 02.33.00) +# MokoWaaS Operations Guide (VERSION: 02.32.52) ## Introduction diff --git a/docs/guides/rollback-and-recovery-guide.md b/docs/guides/rollback-and-recovery-guide.md index 3d00b8c3..b8c85464 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: MokoWaaS.Guides REPO: https://github.com/mokoconsulting-tech/mokowaas - VERSION: 02.33.00 + VERSION: 02.32.52 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 WaaS plugin governance --> -# MokoWaaS Rollback and Recovery Guide (VERSION: 02.33.00) +# MokoWaaS Rollback and Recovery Guide (VERSION: 02.32.52) ## Introduction diff --git a/docs/guides/testing-guide.md b/docs/guides/testing-guide.md index 64947ff4..35b4ccda 100644 --- a/docs/guides/testing-guide.md +++ b/docs/guides/testing-guide.md @@ -7,13 +7,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS.Guides REPO: https://github.com/mokoconsulting-tech/mokowaas - VERSION: 02.33.00 + VERSION: 02.32.52 PATH: /docs/guides/testing-guide.md BRIEF: Testing guide for MokoWaaS v02.01.08 NOTE: Covers manual test procedures for language overrides, install/uninstall, and configuration --> -# MokoWaaS Testing Guide (VERSION: 02.33.00) +# MokoWaaS Testing Guide (VERSION: 02.32.52) ## 1. Prerequisites diff --git a/docs/guides/troubleshooting-guide.md b/docs/guides/troubleshooting-guide.md index d073b8a9..3256942d 100644 --- a/docs/guides/troubleshooting-guide.md +++ b/docs/guides/troubleshooting-guide.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS.Guides REPO: https://github.com/mokoconsulting-tech/mokowaas - VERSION: 02.33.00 + VERSION: 02.32.52 PATH: /docs/guides/troubleshooting-guide.md BRIEF: Troubleshooting guide for diagnosing and resolving issues related to the MokoWaaS plugin NOTE: Designed for administrators and WaaS operations teams --> -# MokoWaaS Troubleshooting Guide (VERSION: 02.33.00) +# MokoWaaS Troubleshooting Guide (VERSION: 02.32.52) ## Introduction diff --git a/docs/guides/upgrade-and-versioning-guide.md b/docs/guides/upgrade-and-versioning-guide.md index 9dc524ca..907c2241 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: MokoWaaS.Guides REPO: https://github.com/mokoconsulting-tech/mokowaas - VERSION: 02.33.00 + VERSION: 02.32.52 PATH: /docs/guides/upgrade-and-versioning-guide.md BRIEF: Guide for updating, versioning, and maintaining the MokoWaaS plugin NOTE: Defines release flow, version rules, and upgrade validation --> -# MokoWaaS Upgrade and Versioning Guide (VERSION: 02.33.00) +# MokoWaaS Upgrade and Versioning Guide (VERSION: 02.32.52) ## Introduction diff --git a/docs/index.md b/docs/index.md index 835baaaf..c2385453 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,13 +10,13 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS.Documentation REPO: https://github.com/mokoconsulting-tech/mokowaas - VERSION: 02.33.00 + VERSION: 02.32.52 PATH: /docs/index.md BRIEF: Master index of all documentation for the MokoWaaS plugin NOTE: Automatically maintained index for all guide canvases --> -# MokoWaaS Documentation Index (VERSION: 02.33.00) +# MokoWaaS Documentation Index (VERSION: 02.32.52) ## Introduction diff --git a/docs/plugin-basic.md b/docs/plugin-basic.md index b22774bc..1fef8ccb 100644 --- a/docs/plugin-basic.md +++ b/docs/plugin-basic.md @@ -11,12 +11,12 @@ INGROUP: MokoWaaS REPO: https://github.com/mokoconsulting-tech/mokowaas PATH: /docs/plugin-basic.md - VERSION: 02.33.00 + VERSION: 02.32.52 BRIEF: Baseline documentation for the MokoWaaS system plugin NOTE: Foundational reference for internal and external stakeholders --> -# MokoWaaS Plugin Overview (VERSION: 02.33.00) +# MokoWaaS Plugin Overview (VERSION: 02.32.52) ## Introduction diff --git a/docs/update-server.md b/docs/update-server.md index 0231960b..9297a863 100644 --- a/docs/update-server.md +++ b/docs/update-server.md @@ -10,7 +10,7 @@ DEFGROUP: MokoWaaS.Documentation INGROUP: MokoStandards.Templates REPO: https://github.com/mokoconsulting-tech/MokoWaaS PATH: /docs/update-server.md -VERSION: 02.33.00 +VERSION: 02.32.52 BRIEF: How this extension's Joomla update server file (update.xml) is managed --> diff --git a/src/packages/com_mokowaas/admin/access.xml b/src/packages/com_mokowaas/admin/access.xml new file mode 100644 index 00000000..753c1ee1 --- /dev/null +++ b/src/packages/com_mokowaas/admin/access.xml @@ -0,0 +1,15 @@ + + +
+ + + + + + + + + + +
+
diff --git a/src/packages/com_mokowaas/admin/config.xml b/src/packages/com_mokowaas/admin/config.xml new file mode 100644 index 00000000..34e4e4e0 --- /dev/null +++ b/src/packages/com_mokowaas/admin/config.xml @@ -0,0 +1,47 @@ + + +
+ + + + + + +
+ +
+ + + + + + +
+ +
+ +
+
diff --git a/src/packages/com_mokowaas/admin/language/en-GB/com_mokowaas.ini b/src/packages/com_mokowaas/admin/language/en-GB/com_mokowaas.ini index 2d7a1c7e..f8a00220 100644 --- a/src/packages/com_mokowaas/admin/language/en-GB/com_mokowaas.ini +++ b/src/packages/com_mokowaas/admin/language/en-GB/com_mokowaas.ini @@ -19,3 +19,23 @@ COM_MOKOWAAS_CACHE_CLEARED="Cache cleared successfully." COM_MOKOWAAS_EXTENSIONS_TITLE="Moko Extensions" COM_MOKOWAAS_EXTENSIONS_INFO="Install Moko Consulting Joomla packages from the official release server. Updates are handled through Joomla's native System > Update mechanism — each package registers its own update server." COM_MOKOWAAS_EXTENSIONS_LINK="Moko Extensions" +COM_MOKOWAAS_HTACCESS_TITLE=".htaccess Maker" +COM_MOKOWAAS_TICKETS_TITLE="Helpdesk" + +; ACL +COM_MOKOWAAS_ACL_DASHBOARD="View Dashboard" +COM_MOKOWAAS_ACL_DASHBOARD_DESC="Allow viewing the MokoWaaS control panel dashboard." +COM_MOKOWAAS_ACL_EXTENSIONS="Manage Extensions" +COM_MOKOWAAS_ACL_EXTENSIONS_DESC="Allow installing and uninstalling Moko extensions." +COM_MOKOWAAS_ACL_HTACCESS="Manage .htaccess" +COM_MOKOWAAS_ACL_HTACCESS_DESC="Allow editing and saving the .htaccess configuration." +COM_MOKOWAAS_ACL_TICKETS="View Tickets" +COM_MOKOWAAS_ACL_TICKETS_DESC="Allow viewing helpdesk tickets." +COM_MOKOWAAS_ACL_TICKETS_CREATE="Create Tickets" +COM_MOKOWAAS_ACL_TICKETS_CREATE_DESC="Allow creating new helpdesk tickets." +COM_MOKOWAAS_ACL_TICKETS_ASSIGN="Assign Tickets" +COM_MOKOWAAS_ACL_TICKETS_ASSIGN_DESC="Allow assigning tickets to other users." +COM_MOKOWAAS_ACL_PLUGINS_TOGGLE="Toggle Plugins" +COM_MOKOWAAS_ACL_PLUGINS_TOGGLE_DESC="Allow enabling and disabling MokoWaaS feature plugins." +COM_MOKOWAAS_ACL_CACHE="Clear Cache" +COM_MOKOWAAS_ACL_CACHE_DESC="Allow clearing the Joomla cache from the dashboard." diff --git a/src/packages/com_mokowaas/admin/language/en-GB/com_mokowaas.sys.ini b/src/packages/com_mokowaas/admin/language/en-GB/com_mokowaas.sys.ini index ac058b55..3c71dbd1 100644 --- a/src/packages/com_mokowaas/admin/language/en-GB/com_mokowaas.sys.ini +++ b/src/packages/com_mokowaas/admin/language/en-GB/com_mokowaas.sys.ini @@ -5,3 +5,15 @@ COM_MOKOWAAS="MokoWaaS" COM_MOKOWAAS_DESCRIPTION="MokoWaaS admin dashboard and REST API. Control panel for managing site features, health monitoring, and remote management." COM_MOKOWAAS_DASHBOARD_TITLE="MokoWaaS Control Panel" +COM_MOKOWAAS_MENU_DASHBOARD="Dashboard" +COM_MOKOWAAS_MENU_EXTENSIONS="Moko Extensions" +COM_MOKOWAAS_MENU_PLUGINS="Feature Plugins" +COM_MOKOWAAS_MENU_UPDATES="Joomla Updates" +COM_MOKOWAAS_MENU_CHECKIN="Global Check-in" +COM_MOKOWAAS_MENU_TICKETS="Helpdesk" +COM_MOKOWAAS_MENU_HTACCESS=".htaccess Maker" +COM_MOKOWAAS_MENU_PRIVACY="Privacy Guard" +COM_MOKOWAAS_MENU_WAFLOG="WAF Log" +COM_MOKOWAAS_MENU_DATABASE="Database Tools" +COM_MOKOWAAS_MENU_CLEANUP="Cache Cleanup" +COM_MOKOWAAS_MENU_CACHE="Cache Management" diff --git a/src/packages/com_mokowaas/admin/sql/install.mysql.sql b/src/packages/com_mokowaas/admin/sql/install.mysql.sql new file mode 100644 index 00000000..0bd447a0 --- /dev/null +++ b/src/packages/com_mokowaas/admin/sql/install.mysql.sql @@ -0,0 +1,135 @@ +-- +-- MokoWaaS Helpdesk Tables +-- + +CREATE TABLE IF NOT EXISTS `#__mokowaas_ticket_categories` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `title` VARCHAR(255) NOT NULL, + `alias` VARCHAR(255) NOT NULL DEFAULT '', + `description` TEXT, + `auto_assign_user` INT DEFAULT NULL, + `sla_response_minutes` INT UNSIGNED NOT NULL DEFAULT 480, + `sla_resolution_minutes` INT UNSIGNED NOT NULL DEFAULT 2880, + `ordering` INT NOT NULL DEFAULT 0, + `published` TINYINT NOT NULL DEFAULT 1, + PRIMARY KEY (`id`), + KEY `idx_alias` (`alias`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokowaas_tickets` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `subject` VARCHAR(512) NOT NULL, + `body` TEXT NOT NULL, + `status` ENUM('open','in_progress','waiting','resolved','closed') NOT NULL DEFAULT 'open', + `priority` ENUM('low','normal','high','urgent') NOT NULL DEFAULT 'normal', + `category_id` INT UNSIGNED DEFAULT NULL, + `created_by` INT NOT NULL DEFAULT 0, + `assigned_to` INT DEFAULT NULL, + `created` DATETIME NOT NULL, + `modified` DATETIME DEFAULT NULL, + `resolved` DATETIME DEFAULT NULL, + `closed` DATETIME DEFAULT NULL, + `sla_response_due` DATETIME DEFAULT NULL, + `sla_resolution_due` DATETIME DEFAULT NULL, + `sla_responded` TINYINT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_status` (`status`), + KEY `idx_priority` (`priority`), + KEY `idx_assigned` (`assigned_to`), + KEY `idx_category` (`category_id`), + KEY `idx_created` (`created`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokowaas_ticket_replies` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `ticket_id` INT UNSIGNED NOT NULL, + `user_id` INT NOT NULL DEFAULT 0, + `body` TEXT NOT NULL, + `is_internal` TINYINT NOT NULL DEFAULT 0, + `created` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_ticket` (`ticket_id`), + KEY `idx_created` (`created`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokowaas_ticket_canned` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `title` VARCHAR(255) NOT NULL, + `body` TEXT NOT NULL, + `category_id` INT UNSIGNED DEFAULT NULL, + `ordering` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokowaas_ticket_automation` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `title` VARCHAR(255) NOT NULL, + `trigger_event` VARCHAR(50) NOT NULL DEFAULT 'ticket_created', + `conditions` TEXT NOT NULL DEFAULT '[]', + `actions` TEXT NOT NULL DEFAULT '[]', + `enabled` TINYINT NOT NULL DEFAULT 1, + `ordering` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Default automation rules +INSERT IGNORE INTO `#__mokowaas_ticket_automation` (`id`, `title`, `trigger_event`, `conditions`, `actions`, `enabled`, `ordering`) VALUES +(1, 'Auto-close resolved tickets after 7 days', 'scheduled', '[{"field":"status","op":"eq","value":"resolved"},{"field":"age_hours","op":"gt","value":"168"}]', '[{"type":"set_status","value":"closed"},{"type":"add_note","value":"Auto-closed after 7 days with no response."}]', 1, 1), +(2, 'Escalate urgent tickets with no response in 1 hour', 'scheduled', '[{"field":"priority","op":"eq","value":"urgent"},{"field":"sla_responded","op":"eq","value":"0"},{"field":"age_hours","op":"gt","value":"1"}]', '[{"type":"add_note","value":"SLA BREACH: Urgent ticket has no staff response after 1 hour."}]', 1, 2), +(3, 'Notify on high priority ticket creation', 'ticket_created', '[{"field":"priority","op":"in","value":"high,urgent"}]', '[{"type":"add_note","value":"High/urgent ticket created — requires immediate attention."}]', 1, 3); + +-- Default categories +INSERT IGNORE INTO `#__mokowaas_ticket_categories` (`id`, `title`, `alias`, `description`, `sla_response_minutes`, `sla_resolution_minutes`, `ordering`) VALUES +(1, 'General Support', 'general-support', 'General questions and assistance', 480, 2880, 1), +(2, 'Bug Report', 'bug-report', 'Report a software bug or issue', 240, 1440, 2), +(3, 'Feature Request', 'feature-request', 'Request a new feature or enhancement', 1440, 10080, 3), +(4, 'Billing', 'billing', 'Billing, invoicing, and payment questions', 240, 1440, 4), +(5, 'Urgent / Outage', 'urgent-outage', 'Site down or critical issue', 60, 240, 5); + +-- +-- Privacy Guard Tables +-- + +CREATE TABLE IF NOT EXISTS `#__mokowaas_consent_log` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` INT NOT NULL, + `category` VARCHAR(50) NOT NULL, + `action` ENUM('granted','revoked') NOT NULL, + `ip_address` VARCHAR(45) NOT NULL DEFAULT '', + `created` DATETIME NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_user` (`user_id`), + KEY `idx_category` (`category`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokowaas_data_requests` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` INT NOT NULL, + `type` ENUM('export','delete','anonymize') NOT NULL, + `status` ENUM('pending','processing','completed','denied') NOT NULL DEFAULT 'pending', + `notes` TEXT, + `processed_by` INT DEFAULT NULL, + `created` DATETIME NOT NULL, + `processed` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_user` (`user_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `#__mokowaas_retention_policies` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `content_type` VARCHAR(100) NOT NULL, + `retention_days` INT UNSIGNED NOT NULL DEFAULT 365, + `action` ENUM('anonymize','delete','archive') NOT NULL DEFAULT 'anonymize', + `enabled` TINYINT NOT NULL DEFAULT 1, + `description` VARCHAR(255) NOT NULL DEFAULT '', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Default retention policies +INSERT IGNORE INTO `#__mokowaas_retention_policies` (`id`, `content_type`, `retention_days`, `action`, `enabled`, `description`) VALUES +(1, 'action_logs', 90, 'delete', 1, 'Delete action log entries older than 90 days'), +(2, 'waf_logs', 30, 'delete', 1, 'Delete WAF block logs older than 30 days'), +(3, 'sessions', 7, 'delete', 1, 'Purge expired sessions older than 7 days'), +(4, 'inactive_users', 730, 'anonymize', 0, 'Anonymize users inactive for 2 years (disabled by default)'), +(5, 'closed_tickets', 365, 'anonymize', 0, 'Anonymize closed tickets older than 1 year (disabled by default)'); diff --git a/src/packages/com_mokowaas/admin/src/Controller/DisplayController.php b/src/packages/com_mokowaas/admin/src/Controller/DisplayController.php index b6bdac57..dd77f61f 100644 --- a/src/packages/com_mokowaas/admin/src/Controller/DisplayController.php +++ b/src/packages/com_mokowaas/admin/src/Controller/DisplayController.php @@ -20,106 +20,659 @@ class DisplayController extends BaseController { protected $default_view = 'dashboard'; + /** + * ACL map: view name => required permission. + */ + private const VIEW_ACL = [ + 'dashboard' => 'mokowaas.dashboard', + 'extensions' => 'mokowaas.extensions', + 'htaccess' => 'mokowaas.htaccess', + 'tickets' => 'mokowaas.tickets', + 'ticket' => 'mokowaas.tickets', + 'privacy' => 'core.admin', + 'waflog' => 'core.admin', + 'categories' => 'mokowaas.tickets', + 'canned' => 'mokowaas.tickets', + 'automation' => 'core.admin', + 'database' => 'core.admin', + 'cleanup' => 'mokowaas.cache', + ]; + public function display($cachable = false, $urlparams = []) { + $view = $this->input->get('view', $this->default_view); + $acl = self::VIEW_ACL[$view] ?? 'core.manage'; + + if (!$this->checkAcl($acl)) + { + Factory::getApplication()->enqueueMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error'); + Factory::getApplication()->redirect(Route::_('index.php', false)); + + return; + } + return parent::display($cachable, $urlparams); } - /** - * Toggle a MokoWaaS feature plugin on or off. - * - * Expects POST with extension_id and enabled (0 or 1). - * Returns JSON response for AJAX calls. - */ + // ================================================================== + // Plugin toggle + // ================================================================== + public function togglePlugin() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + if (!$this->checkAcl('mokowaas.plugins.toggle')) + { + $this->jsonForbidden(); + return; + } + $app = Factory::getApplication(); - $input = $app->getInput(); + $model = $this->getModel('Dashboard'); - $user = $app->getIdentity(); - if (!$user->authorise('core.manage', 'com_plugins')) - { - $app->setHeader('Content-Type', 'application/json'); - echo json_encode(['success' => false, 'message' => Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN')]); - $app->close(); - } + $result = $model->togglePlugin( + $app->getInput()->getInt('extension_id', 0), + $app->getInput()->getInt('enabled', 0) + ); - $extensionId = $input->getInt('extension_id', 0); - $enabled = $input->getInt('enabled', 0); - - if (!$extensionId) - { - $app->setHeader('Content-Type', 'application/json'); - echo json_encode(['success' => false, 'message' => 'Missing extension_id']); - $app->close(); - } - - $model = $this->getModel('Dashboard'); - $result = $model->togglePlugin($extensionId, $enabled); - - $app->setHeader('Content-Type', 'application/json'); - echo json_encode($result); - $app->close(); + $this->jsonResponse($result); } - /** - * Clear the Joomla cache. - */ + // ================================================================== + // Cache + // ================================================================== + public function clearCache() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); - $app = Factory::getApplication(); - $user = $app->getIdentity(); - - if (!$user->authorise('core.admin')) + if (!$this->checkAcl('mokowaas.cache')) { - $app->setHeader('Content-Type', 'application/json'); - echo json_encode(['success' => false, 'message' => Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN')]); - $app->close(); + $this->jsonForbidden(); + return; } - $model = $this->getModel('Dashboard'); - $result = $model->clearCache(); - - $app->setHeader('Content-Type', 'application/json'); - echo json_encode($result); - $app->close(); + $this->jsonResponse($this->getModel('Dashboard')->clearCache()); } - /** - * Install a Moko extension from a download URL. - */ + // ================================================================== + // Extensions + // ================================================================== + public function installExtension() { Session::checkToken() or die(Text::_('JINVALID_TOKEN')); - $app = Factory::getApplication(); - $user = $app->getIdentity(); - - if (!$user->authorise('core.admin')) + if (!$this->checkAcl('mokowaas.extensions')) { - $app->setHeader('Content-Type', 'application/json'); - echo json_encode(['success' => false, 'message' => Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN')]); - $app->close(); + $this->jsonForbidden(); + return; } - $downloadUrl = $app->getInput()->getString('download_url', ''); + $downloadUrl = Factory::getApplication()->getInput()->getString('download_url', ''); if (empty($downloadUrl)) { - $app->setHeader('Content-Type', 'application/json'); - echo json_encode(['success' => false, 'message' => 'Missing download URL.']); - $app->close(); + $this->jsonResponse(['success' => false, 'message' => 'Missing download URL.']); + return; } - $model = $this->getModel('Extensions'); - $result = $model->installFromUrl($downloadUrl); + $this->jsonResponse($this->getModel('Extensions')->installFromUrl($downloadUrl)); + } + // ================================================================== + // .htaccess + // ================================================================== + + public function saveHtaccess() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('mokowaas.htaccess')) + { + $this->jsonForbidden(); + return; + } + + $app = Factory::getApplication(); + $input = $app->getInput(); + $model = $this->getModel('Htaccess'); + + $options = []; + + foreach ($input->getArray() as $key => $value) + { + if (str_starts_with($key, 'opt_')) + { + $options[substr($key, 4)] = $value; + } + } + + if (!empty($options)) + { + $model->saveOptions($options); + } + + $this->jsonResponse($model->saveHtaccess($input->getRaw('content', ''))); + } + + public function generateHtaccess() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('mokowaas.htaccess')) + { + $this->jsonForbidden(); + return; + } + + $model = $this->getModel('Htaccess'); + $options = Factory::getApplication()->getInput()->getArray(); + + $model->saveOptions($options); + + $app = Factory::getApplication(); $app->setHeader('Content-Type', 'application/json'); - echo json_encode($result); + echo json_encode([ + 'htaccess' => $model->generateHtaccess($options), + 'nginx' => $model->generateNginx($options), + ]); $app->close(); } + + // ================================================================== + // Tickets + // ================================================================== + + public function createTicket() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('mokowaas.tickets.create')) + { + $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), + ])); + } + + public function addTicketReply() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('mokowaas.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('mokowaas.tickets')) + { + $this->jsonForbidden(); + return; + } + + $input = Factory::getApplication()->getInput(); + + $this->jsonResponse($this->getModel('Tickets')->updateStatus( + $input->getInt('ticket_id', 0), + $input->getString('status', '') + )); + } + + // ================================================================== + // KB Search + // ================================================================== + + public function searchKb() + { + $query = Factory::getApplication()->getInput()->getString('q', ''); + + if (strlen($query) < 3) + { + $this->jsonResponse(['results' => []]); + } + + try + { + $db = Factory::getDbo(); + $escaped = $db->quote('%' . $db->escape($query, true) . '%'); + + $results = $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() ?: []; + + foreach ($results as $r) + { + $r->description = mb_substr(strip_tags($r->description ?? ''), 0, 150); + } + + $this->jsonResponse(['results' => $results]); + } + catch (\Throwable $e) + { + $this->jsonResponse(['results' => []]); + } + } + + // ================================================================== + // Maintenance (#127, #128) + // ================================================================== + + public function optimizeDb() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; } + $model = new \Moko\Component\MokoWaaS\Administrator\Model\MaintenanceModel(); + $this->jsonResponse($model->optimizeTables()); + } + + public function repairDb() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; } + $model = new \Moko\Component\MokoWaaS\Administrator\Model\MaintenanceModel(); + $this->jsonResponse($model->repairTables()); + } + + public function purgeSessions() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; } + $model = new \Moko\Component\MokoWaaS\Administrator\Model\MaintenanceModel(); + $this->jsonResponse($model->purgeSessions()); + } + + public function cleanDirectory() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + if (!$this->checkAcl('mokowaas.cache')) { $this->jsonForbidden(); return; } + $dirKey = Factory::getApplication()->getInput()->getString('dir_key', ''); + $model = new \Moko\Component\MokoWaaS\Administrator\Model\MaintenanceModel(); + $this->jsonResponse($model->cleanDirectory($dirKey)); + } + + // ================================================================== + // Helpdesk CRUD (#137, #138, #139) + // ================================================================== + + public function saveCategory() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + if (!$this->checkAcl('mokowaas.tickets')) { $this->jsonForbidden(); } + $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('#__mokowaas_ticket_categories', $data, 'id'); + } else { + $data->ordering = 0; + $db->insertObject('#__mokowaas_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('mokowaas.tickets')) { $this->jsonForbidden(); } + $db = Factory::getDbo(); + $db->setQuery($db->getQuery(true)->delete('#__mokowaas_ticket_categories')->where('id = ' . Factory::getApplication()->getInput()->getInt('id', 0)))->execute(); + $this->jsonResponse(['success' => true, 'message' => 'Category deleted.']); + } + + public function saveCanned() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + if (!$this->checkAcl('mokowaas.tickets')) { $this->jsonForbidden(); } + $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('#__mokowaas_ticket_canned', $data, 'id'); } + else { $db->insertObject('#__mokowaas_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('mokowaas.tickets')) { $this->jsonForbidden(); } + $db = Factory::getDbo(); + $db->setQuery($db->getQuery(true)->delete('#__mokowaas_ticket_canned')->where('id = ' . Factory::getApplication()->getInput()->getInt('id', 0)))->execute(); + $this->jsonResponse(['success' => true, 'message' => 'Canned response deleted.']); + } + + public function saveAutomation() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); } + $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', '[]'), + 'enabled' => 1, + 'ordering' => 0, + ]; + $id = $input->getInt('id', 0); + if ($id) { $data->id = $id; $db->updateObject('#__mokowaas_ticket_automation', $data, 'id'); } + else { $db->insertObject('#__mokowaas_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(); } + $db = Factory::getDbo(); + $db->setQuery($db->getQuery(true)->delete('#__mokowaas_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(); } + $input = Factory::getApplication()->getInput(); + $db = Factory::getDbo(); + $db->setQuery($db->getQuery(true)->update('#__mokowaas_ticket_automation') + ->set('enabled = ' . $input->getInt('enabled', 0)) + ->where('id = ' . $input->getInt('id', 0)))->execute(); + $this->jsonResponse(['success' => true, 'message' => 'Rule updated.']); + } + + // ================================================================== + // Settings Import/Export (#132) + // ================================================================== + + public function exportSettings() + { + Session::checkToken('get') or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('core.admin')) + { + $this->jsonForbidden(); + return; + } + + $db = Factory::getDbo(); + $settings = []; + + // Export all MokoWaaS plugin params + $plugins = ['mokowaas', 'mokowaas_firewall', 'mokowaas_tenant', 'mokowaas_devtools', 'mokowaas_offline']; + + foreach ($plugins as $element) + { + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote($element)) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('system')) + ); + $settings['plugins'][$element] = json_decode($db->loadResult() ?? '{}', true); + } + + // Export component params + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + ); + $settings['component'] = json_decode($db->loadResult() ?? '{}', true); + $settings['exported'] = gmdate('Y-m-d\TH:i:s\Z'); + $settings['site'] = Factory::getConfig()->get('sitename', ''); + + $this->jsonResponse(['success' => true, 'settings' => $settings]); + } + + public function importSettings() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('core.admin')) + { + $this->jsonForbidden(); + return; + } + + $json = Factory::getApplication()->getInput()->getRaw('settings_json', ''); + $data = json_decode($json, true); + + if (empty($data) || empty($data['plugins'])) + { + $this->jsonResponse(['success' => false, 'message' => 'Invalid settings JSON.']); + return; + } + + $db = Factory::getDbo(); + $count = 0; + + foreach ($data['plugins'] ?? [] as $element => $params) + { + if (!is_array($params)) + { + continue; + } + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($params))) + ->where($db->quoteName('element') . ' = ' . $db->quote($element)) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('system')) + )->execute(); + $count++; + } + + if (!empty($data['component']) && is_array($data['component'])) + { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($data['component']))) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + )->execute(); + $count++; + } + + $this->jsonResponse(['success' => true, 'message' => "Imported settings for {$count} extensions."]); + } + + // ================================================================== + // WAF Log + // ================================================================== + + public function purgeWafLog() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('core.admin')) + { + $this->jsonForbidden(); + return; + } + + $days = Factory::getApplication()->getInput()->getInt('days', 30); + $model = new \Moko\Component\MokoWaaS\Administrator\Model\WaflogModel(); + + $this->jsonResponse($model->purgeLogs($days)); + } + + public function banIpFromLog() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('core.admin')) + { + $this->jsonForbidden(); + return; + } + + $ip = Factory::getApplication()->getInput()->getString('ip', ''); + $model = new \Moko\Component\MokoWaaS\Administrator\Model\WaflogModel(); + + $this->jsonResponse($model->banIp($ip)); + } + + // ================================================================== + // Privacy Guard + // ================================================================== + + public function processDataRequest() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('core.admin')) + { + $this->jsonForbidden(); + return; + } + + $input = Factory::getApplication()->getInput(); + $model = new \Moko\Component\MokoWaaS\Administrator\Model\PrivacyModel(); + + $this->jsonResponse($model->processRequest( + $input->getInt('request_id', 0), + $input->getString('action', 'deny') + )); + } + + public function exportUserData() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('core.admin')) + { + $this->jsonForbidden(); + return; + } + + $model = new \Moko\Component\MokoWaaS\Administrator\Model\PrivacyModel(); + + $this->jsonResponse($model->exportUserData( + Factory::getApplication()->getInput()->getInt('user_id', 0) + )); + } + + // ================================================================== + // Importers + // ================================================================== + + public function importAts() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('mokowaas.tickets')) + { + $this->jsonForbidden(); + return; + } + + $this->jsonResponse($this->getModel('Import')->importAts()); + } + + public function importAdminTools() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + if (!$this->checkAcl('core.admin')) + { + $this->jsonForbidden(); + return; + } + + $this->jsonResponse($this->getModel('Import')->importAdminTools()); + } + + // ================================================================== + // Helpers + // ================================================================== + + /** + * Check a MokoWaaS ACL permission for the current user. + */ + private function checkAcl(string $action): bool + { + $user = Factory::getApplication()->getIdentity(); + + // Super admins always pass + if ($user->authorise('core.admin', 'com_mokowaas')) + { + return true; + } + + return $user->authorise($action, 'com_mokowaas'); + } + + /** + * Send a JSON response and close. + */ + private function jsonResponse(array $data): void + { + $app = Factory::getApplication(); + $app->setHeader('Content-Type', 'application/json'); + echo json_encode($data); + $app->close(); + } + + /** + * Send a 403 JSON response and close. + */ + private function jsonForbidden(): void + { + $this->jsonResponse(['success' => false, 'message' => Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN')]); +return; + } } diff --git a/src/packages/com_mokowaas/admin/src/Model/DashboardModel.php b/src/packages/com_mokowaas/admin/src/Model/DashboardModel.php index 8c9d3834..fbcb447f 100644 --- a/src/packages/com_mokowaas/admin/src/Model/DashboardModel.php +++ b/src/packages/com_mokowaas/admin/src/Model/DashboardModel.php @@ -22,39 +22,60 @@ class DashboardModel extends BaseDatabaseModel */ private const PLUGIN_META = [ 'mokowaas' => [ - 'icon' => 'icon-shield-alt', - 'category' => 'core', - 'label' => 'Core — Branding & Identity', - 'description' => 'White-label branding, master user enforcement, emergency access, and plugin protection.', - 'protected' => true, + 'icon' => 'icon-shield-alt', + 'category' => 'core', + 'label' => 'Core — Branding & Identity', + 'description' => 'White-label branding, master user enforcement, emergency access, and plugin protection.', + 'protected' => true, + 'configure_only' => false, ], 'mokowaas_firewall' => [ - 'icon' => 'icon-lock', - 'category' => 'security', - 'label' => 'Firewall', - 'description' => 'Web Application Firewall — SQLi, XSS, RFI, DFI shields, IP blocklist, admin secret URL, file protection.', - 'protected' => false, + 'icon' => 'icon-lock', + 'category' => 'security', + 'label' => 'Firewall', + 'description' => 'Web Application Firewall — SQLi, XSS, RFI, DFI shields, IP blocklist, admin secret URL, file protection.', + 'protected' => false, + 'configure_only' => false, ], 'mokowaas_tenant' => [ - 'icon' => 'icon-users', - 'category' => 'security', - 'label' => 'Tenant Restrictions', - 'description' => 'Installer, sysinfo, config, and template access restrictions for non-master users.', - 'protected' => false, + 'icon' => 'icon-users', + 'category' => 'security', + 'label' => 'Tenant Restrictions', + 'description' => 'Installer, sysinfo, config, and template access restrictions for non-master users.', + 'protected' => false, + 'configure_only' => false, + ], + 'mokowaas_offline' => [ + 'icon' => 'icon-globe', + 'category' => 'security', + 'label' => 'Offline Bypass', + 'description' => 'Keep selected pages (TOS, Privacy Policy) accessible during offline mode.', + 'protected' => false, + 'configure_only' => true, ], 'mokowaas_devtools' => [ - 'icon' => 'icon-wrench', - 'category' => 'tools', - 'label' => 'Developer Tools', - 'description' => 'Dev mode, hit counter reset, content version cleanup.', - 'protected' => false, + 'icon' => 'icon-wrench', + 'category' => 'tools', + 'label' => 'Developer Tools', + 'description' => 'Dev mode, hit counter reset, content version cleanup. Features are controlled inside the plugin settings.', + 'protected' => false, + 'configure_only' => true, ], - 'mokowaas_monitor' => [ - 'icon' => 'icon-heartbeat', - 'category' => 'monitoring', - 'label' => 'Health Monitor', - 'description' => 'Site health checks, Grafana heartbeat integration, and diagnostics.', - 'protected' => false, + 'mokowaasdemo' => [ + 'icon' => 'icon-undo', + 'category' => 'content', + 'label' => 'Demo Reset Task', + 'description' => 'Scheduled demo site reset with content snapshots.', + 'protected' => false, + 'configure_only' => true, + ], + 'mokowaassync' => [ + 'icon' => 'icon-sync', + 'category' => 'content', + 'label' => 'Content Sync Task', + 'description' => 'Scheduled content synchronisation to remote MokoWaaS sites.', + 'protected' => false, + 'configure_only' => true, ], ]; @@ -97,7 +118,8 @@ class DashboardModel extends BaseDatabaseModel '(' . $db->quoteName('type') . ' = ' . $db->quote('plugin') . ' AND ' . $db->quoteName('folder') . ' = ' . $db->quote('system') . ' AND (' . $db->quoteName('element') . ' = ' . $db->quote('mokowaas') - . ' OR ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mokowaas\_%') . '))' + . ' OR ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mokowaas\_%') . ')' + . ' AND ' . $db->quoteName('element') . ' != ' . $db->quote('mokowaas_monitor') . ')' // Webservices plugins . ' OR (' . $db->quoteName('type') . ' = ' . $db->quote('plugin') . ' AND ' . $db->quoteName('folder') . ' = ' . $db->quote('webservices') @@ -120,8 +142,10 @@ class DashboardModel extends BaseDatabaseModel $manifest = json_decode($row->manifest_cache ?? '{}'); $version = $manifest->version ?? ''; - // Build a lookup key: system plugins use element, others use folder_element - $metaKey = $row->element; + // Only system plugins and task plugins match PLUGIN_META by element + $metaKey = ($row->folder === 'system' || $row->folder === 'task') + ? $row->element + : $row->folder . '_' . $row->element; $meta = self::PLUGIN_META[$metaKey] ?? null; @@ -135,19 +159,20 @@ class DashboardModel extends BaseDatabaseModel $categoryInfo = self::CATEGORIES[$categoryKey] ?? self::CATEGORIES['tools']; $plugins[] = (object) [ - 'extension_id' => (int) $row->extension_id, - 'name' => $meta['label'] ?? $row->name, - 'element' => $row->element, - 'folder' => $row->folder, - 'type' => $row->type, - 'enabled' => (int) $row->enabled, - 'protected' => (int) $row->protected || ($meta['protected'] ?? false), - 'version' => $version, - 'icon' => $meta['icon'] ?? 'icon-puzzle-piece', - 'category' => $categoryKey, + 'extension_id' => (int) $row->extension_id, + 'name' => $meta['label'] ?? $row->name, + 'element' => $row->element, + 'folder' => $row->folder, + 'type' => $row->type, + 'enabled' => (int) $row->enabled, + 'protected' => (bool) ($meta['protected'] ?? false), + 'configure_only' => (bool) ($meta['configure_only'] ?? false), + 'version' => $version, + 'icon' => $meta['icon'] ?? 'icon-puzzle-piece', + 'category' => $categoryKey, 'categoryLabel' => $categoryInfo['label'], 'categoryBadge' => $categoryInfo['badge'], - 'description' => $meta['description'] ?? '', + 'description' => $meta['description'] ?? '', ]; } @@ -242,11 +267,46 @@ class DashboardModel extends BaseDatabaseModel { try { - $app = Factory::getApplication(); - $app->get('cache_handler', 'file'); + // Purge all file-based cache directories + $root = JPATH_ROOT; + $dirs = [ + $root . '/cache', + $root . '/administrator/cache', + ]; - // Clear site and admin caches - $cache = Factory::getContainer()->get(\Joomla\CMS\Cache\CacheControllerFactoryInterface::class); + foreach ($dirs as $dir) + { + if (!is_dir($dir)) + { + continue; + } + + $it = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($it as $file) + { + $name = $file->getFilename(); + + if ($name === 'index.html' || $name === '.htaccess') + { + continue; + } + + if ($file->isDir()) + { + @rmdir($file->getPathname()); + } + else + { + @unlink($file->getPathname()); + } + } + } + + // Also run Joomla's built-in cache GC for non-file handlers Factory::getCache('', '')->gc(); Factory::getCache('', '', 'administrator')->gc(); @@ -256,7 +316,7 @@ class DashboardModel extends BaseDatabaseModel \opcache_reset(); } - return ['success' => true, 'message' => 'Cache cleared successfully.']; + return ['success' => true, 'message' => 'All cache cleared successfully.']; } catch (\Throwable $e) { diff --git a/src/packages/com_mokowaas/admin/src/Model/ExtensionsModel.php b/src/packages/com_mokowaas/admin/src/Model/ExtensionsModel.php index e8402f12..76e3f6fa 100644 --- a/src/packages/com_mokowaas/admin/src/Model/ExtensionsModel.php +++ b/src/packages/com_mokowaas/admin/src/Model/ExtensionsModel.php @@ -96,6 +96,16 @@ class ExtensionsModel extends BaseDatabaseModel 'article' => 'https://mokoconsulting.tech/kb/mokogallerycalendar', 'protected' => false, ], + 'MokoJoomOpenGraph' => [ + 'label' => 'MokoJoomOpenGraph', + 'description' => 'Open Graph meta tags for articles, categories, and pages. Controls Facebook, Twitter, and LinkedIn link previews.', + 'element' => 'pkg_mokoog', + 'type' => 'package', + 'icon' => 'icon-share-alt', + 'category' => 'Components', + 'article' => 'https://mokoconsulting.tech/kb/mokojoomopengraph', + 'protected' => false, + ], ]; private const GITEA_URL = 'https://git.mokoconsulting.tech'; diff --git a/src/packages/com_mokowaas/admin/src/Model/HtaccessModel.php b/src/packages/com_mokowaas/admin/src/Model/HtaccessModel.php new file mode 100644 index 00000000..5997b19c --- /dev/null +++ b/src/packages/com_mokowaas/admin/src/Model/HtaccessModel.php @@ -0,0 +1,522 @@ + 1, + 'block_sensitive_files' => 1, + 'block_php_in_uploads' => 1, + 'disable_server_signature' => 1, + 'prevent_clickjacking' => 1, + 'prevent_mime_sniffing' => 1, + 'xss_protection' => 1, + 'disable_trace_track' => 1, + 'referrer_policy' => 'strict-origin-when-cross-origin', + 'hsts_enabled' => 0, + 'hsts_max_age' => 31536000, + 'hsts_subdomains' => 0, + 'csp_enabled' => 0, + 'csp_value' => '', + 'permissions_policy' => 0, + 'permissions_value' => '', + // Performance + 'enable_gzip' => 1, + 'enable_expires' => 1, + 'expires_html' => 3600, + 'expires_css_js' => 2592000, + 'expires_images' => 31536000, + 'etag_control' => 0, + // SEO + 'www_redirect' => 'off', + 'redirect_index_php' => 1, + 'force_trailing_slash' => 0, + // Custom + 'custom_rules' => '', + ]; + + /** + * Get saved options or defaults. + */ + public function getOptions(): array + { + $db = $this->getDatabase(); + $query = $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')); + $db->setQuery($query); + $params = new Registry($db->loadResult() ?? '{}'); + + $htaccess = $params->get('htaccess', null); + + if ($htaccess) + { + return array_merge(self::DEFAULTS, (array) json_decode(json_encode($htaccess), true)); + } + + return self::DEFAULTS; + } + + /** + * Save options to component params. + */ + public function saveOptions(array $options): array + { + try + { + $db = $this->getDatabase(); + $query = $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')); + $db->setQuery($query); + $params = new Registry($db->loadResult() ?? '{}'); + + $clean = []; + + foreach (self::DEFAULTS as $key => $default) + { + $clean[$key] = $options[$key] ?? $default; + } + + $params->set('htaccess', $clean); + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote($params->toString())) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + )->execute(); + + return ['success' => true, 'message' => 'Options saved.']; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Save failed: ' . $e->getMessage()]; + } + } + + /** + * Read the current .htaccess file. + */ + public function readCurrentHtaccess(): string + { + $path = JPATH_ROOT . '/.htaccess'; + + return file_exists($path) ? file_get_contents($path) : ''; + } + + /** + * Write .htaccess to disk with backup. + */ + public function saveHtaccess(string $content): array + { + $path = JPATH_ROOT . '/.htaccess'; + $backup = JPATH_ROOT . '/.htaccess.mokowaas.bak'; + + try + { + // Backup existing + if (file_exists($path)) + { + copy($path, $backup); + } + + $result = file_put_contents($path, $content); + + if ($result === false) + { + // Restore backup + if (file_exists($backup)) + { + copy($backup, $path); + } + + return ['success' => false, 'message' => '.htaccess is not writable.']; + } + + return ['success' => true, 'message' => '.htaccess saved. Backup at .htaccess.mokowaas.bak']; + } + catch (\Throwable $e) + { + if (file_exists($backup)) + { + @copy($backup, $path); + } + + return ['success' => false, 'message' => 'Write failed: ' . $e->getMessage()]; + } + } + + /** + * Generate .htaccess content from options. + */ + public function generateHtaccess(array $opts): string + { + $lines = []; + $lines[] = '##'; + $lines[] = '## MokoWaaS Generated .htaccess'; + $lines[] = '## Generated: ' . gmdate('Y-m-d H:i:s') . ' UTC'; + $lines[] = '## DO NOT EDIT — regenerate from MokoWaaS > .htaccess Maker'; + $lines[] = '##'; + $lines[] = ''; + + // --- Security --- + if (!empty($opts['disable_directory_listing'])) + { + $lines[] = '## Disable directory listing'; + $lines[] = 'Options -Indexes'; + $lines[] = ''; + } + + if (!empty($opts['disable_server_signature'])) + { + $lines[] = '## Hide server signature'; + $lines[] = 'ServerSignature Off'; + $lines[] = ''; + $lines[] = ' Header unset X-Powered-By'; + $lines[] = ' Header unset Server'; + $lines[] = ''; + $lines[] = ''; + } + + if (!empty($opts['block_sensitive_files'])) + { + $lines[] = '## Block access to sensitive files'; + $lines[] = ''; + $lines[] = ' '; + $lines[] = ' Require all denied'; + $lines[] = ' '; + $lines[] = ''; + $lines[] = ''; + } + + if (!empty($opts['block_php_in_uploads'])) + { + $lines[] = '## Block PHP execution in upload directories'; + $dirs = ['images', 'media', 'tmp', 'cache', 'logs']; + + foreach ($dirs as $dir) + { + $lines[] = ''; + $lines[] = ' '; + $lines[] = ' '; + $lines[] = ' Require all denied'; + $lines[] = ' '; + $lines[] = ' '; + $lines[] = ''; + } + + $lines[] = ''; + } + + if (!empty($opts['disable_trace_track'])) + { + $lines[] = '## Disable TRACE and TRACK methods'; + $lines[] = ''; + $lines[] = ' RewriteEngine On'; + $lines[] = ' RewriteCond %{REQUEST_METHOD} ^(TRACE|TRACK)'; + $lines[] = ' RewriteRule .* - [F]'; + $lines[] = ''; + $lines[] = ''; + } + + // Security headers + $headers = []; + + if (!empty($opts['prevent_clickjacking'])) + { + $headers[] = ' Header always set X-Frame-Options "SAMEORIGIN"'; + } + + if (!empty($opts['prevent_mime_sniffing'])) + { + $headers[] = ' Header always set X-Content-Type-Options "nosniff"'; + } + + if (!empty($opts['xss_protection'])) + { + $headers[] = ' Header always set X-XSS-Protection "1; mode=block"'; + } + + $referrer = $opts['referrer_policy'] ?? ''; + + if (!empty($referrer) && $referrer !== 'off') + { + $headers[] = ' Header always set Referrer-Policy "' . $referrer . '"'; + } + + if (!empty($opts['hsts_enabled'])) + { + $maxAge = (int) ($opts['hsts_max_age'] ?? 31536000); + $hsts = 'max-age=' . $maxAge; + + if (!empty($opts['hsts_subdomains'])) + { + $hsts .= '; includeSubDomains'; + } + + $headers[] = ' Header always set Strict-Transport-Security "' . $hsts . '"'; + } + + if (!empty($opts['csp_enabled']) && !empty($opts['csp_value'])) + { + $headers[] = ' Header always set Content-Security-Policy "' . str_replace('"', '', $opts['csp_value']) . '"'; + } + + if (!empty($opts['permissions_policy']) && !empty($opts['permissions_value'])) + { + $headers[] = ' Header always set Permissions-Policy "' . str_replace('"', '', $opts['permissions_value']) . '"'; + } + + if (!empty($headers)) + { + $lines[] = '## Security headers'; + $lines[] = ''; + $lines = array_merge($lines, $headers); + $lines[] = ''; + $lines[] = ''; + } + + // --- Performance --- + if (!empty($opts['enable_gzip'])) + { + $lines[] = '## GZip compression'; + $lines[] = ''; + $lines[] = ' AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css'; + $lines[] = ' AddOutputFilterByType DEFLATE text/javascript application/javascript application/x-javascript'; + $lines[] = ' AddOutputFilterByType DEFLATE application/json application/xml application/rss+xml'; + $lines[] = ' AddOutputFilterByType DEFLATE image/svg+xml application/font-woff application/font-woff2'; + $lines[] = ''; + $lines[] = ''; + } + + if (!empty($opts['enable_expires'])) + { + $html = (int) ($opts['expires_html'] ?? 3600); + $cssJs = (int) ($opts['expires_css_js'] ?? 2592000); + $images = (int) ($opts['expires_images'] ?? 31536000); + + $lines[] = '## Browser caching'; + $lines[] = ''; + $lines[] = ' ExpiresActive On'; + $lines[] = ' ExpiresDefault "access plus ' . $html . ' seconds"'; + $lines[] = ' ExpiresByType text/html "access plus ' . $html . ' seconds"'; + $lines[] = ' ExpiresByType text/css "access plus ' . $cssJs . ' seconds"'; + $lines[] = ' ExpiresByType text/javascript "access plus ' . $cssJs . ' seconds"'; + $lines[] = ' ExpiresByType application/javascript "access plus ' . $cssJs . ' seconds"'; + $lines[] = ' ExpiresByType image/jpeg "access plus ' . $images . ' seconds"'; + $lines[] = ' ExpiresByType image/png "access plus ' . $images . ' seconds"'; + $lines[] = ' ExpiresByType image/gif "access plus ' . $images . ' seconds"'; + $lines[] = ' ExpiresByType image/webp "access plus ' . $images . ' seconds"'; + $lines[] = ' ExpiresByType image/svg+xml "access plus ' . $images . ' seconds"'; + $lines[] = ' ExpiresByType font/woff2 "access plus ' . $images . ' seconds"'; + $lines[] = ''; + $lines[] = ''; + } + + if (!empty($opts['etag_control'])) + { + $lines[] = '## Disable ETags (for load-balanced environments)'; + $lines[] = ''; + $lines[] = ' Header unset ETag'; + $lines[] = ''; + $lines[] = 'FileETag None'; + $lines[] = ''; + } + + // --- SEO / Redirects --- + $wwwRedirect = $opts['www_redirect'] ?? 'off'; + + if ($wwwRedirect !== 'off' || !empty($opts['redirect_index_php']) || !empty($opts['force_trailing_slash'])) + { + $lines[] = '## SEO redirects'; + $lines[] = ''; + $lines[] = ' RewriteEngine On'; + + if ($wwwRedirect === 'www') + { + $lines[] = ''; + $lines[] = ' ## Force www'; + $lines[] = ' RewriteCond %{HTTP_HOST} !^www\. [NC]'; + $lines[] = ' RewriteRule ^(.*)$ https://www.%{HTTP_HOST}/$1 [R=301,L]'; + } + elseif ($wwwRedirect === 'non-www') + { + $lines[] = ''; + $lines[] = ' ## Force non-www'; + $lines[] = ' RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]'; + $lines[] = ' RewriteRule ^(.*)$ https://%1/$1 [R=301,L]'; + } + + if (!empty($opts['redirect_index_php'])) + { + $lines[] = ''; + $lines[] = ' ## Redirect /index.php to root'; + $lines[] = ' RewriteCond %{THE_REQUEST} ^[A-Z]{3,}\s/+index\.php\s [NC]'; + $lines[] = ' RewriteRule ^index\.php/?(.*)$ /$1 [R=301,L]'; + } + + if (!empty($opts['force_trailing_slash'])) + { + $lines[] = ''; + $lines[] = ' ## Force trailing slash'; + $lines[] = ' RewriteCond %{REQUEST_FILENAME} !-f'; + $lines[] = ' RewriteCond %{REQUEST_URI} !(.*)/$'; + $lines[] = ' RewriteRule ^(.*)$ /$1/ [R=301,L]'; + } + + $lines[] = ''; + $lines[] = ''; + } + + // --- Custom rules --- + $custom = trim($opts['custom_rules'] ?? ''); + + if (!empty($custom)) + { + $lines[] = '## Custom rules'; + $lines[] = $custom; + $lines[] = ''; + } + + return implode("\n", $lines); + } + + /** + * Generate equivalent NginX configuration snippet. + */ + public function generateNginx(array $opts): string + { + $lines = []; + $lines[] = '## MokoWaaS Generated NginX Configuration'; + $lines[] = '## Add these directives inside your server { } block'; + $lines[] = ''; + + if (!empty($opts['disable_directory_listing'])) + { + $lines[] = '# Disable directory listing'; + $lines[] = 'autoindex off;'; + $lines[] = ''; + } + + if (!empty($opts['disable_server_signature'])) + { + $lines[] = '# Hide server version'; + $lines[] = 'server_tokens off;'; + $lines[] = ''; + } + + if (!empty($opts['block_sensitive_files'])) + { + $lines[] = '# Block sensitive files'; + $lines[] = 'location ~* (htaccess\.txt|web\.config\.txt|configuration\.php-dist|README\.txt|LICENSE\.txt)$ {'; + $lines[] = ' deny all;'; + $lines[] = '}'; + $lines[] = ''; + } + + if (!empty($opts['block_php_in_uploads'])) + { + $lines[] = '# Block PHP in upload directories'; + $lines[] = 'location ~* ^/(images|media|tmp|cache|logs)/.*\.php$ {'; + $lines[] = ' deny all;'; + $lines[] = '}'; + $lines[] = ''; + } + + // Headers + $hdrs = []; + + if (!empty($opts['prevent_clickjacking'])) + { + $hdrs[] = 'add_header X-Frame-Options "SAMEORIGIN" always;'; + } + + if (!empty($opts['prevent_mime_sniffing'])) + { + $hdrs[] = 'add_header X-Content-Type-Options "nosniff" always;'; + } + + if (!empty($opts['xss_protection'])) + { + $hdrs[] = 'add_header X-XSS-Protection "1; mode=block" always;'; + } + + $referrer = $opts['referrer_policy'] ?? ''; + + if (!empty($referrer) && $referrer !== 'off') + { + $hdrs[] = 'add_header Referrer-Policy "' . $referrer . '" always;'; + } + + if (!empty($opts['hsts_enabled'])) + { + $maxAge = (int) ($opts['hsts_max_age'] ?? 31536000); + $hsts = 'max-age=' . $maxAge; + + if (!empty($opts['hsts_subdomains'])) + { + $hsts .= '; includeSubDomains'; + } + + $hdrs[] = 'add_header Strict-Transport-Security "' . $hsts . '" always;'; + } + + if (!empty($hdrs)) + { + $lines[] = '# Security headers'; + $lines = array_merge($lines, $hdrs); + $lines[] = ''; + } + + if (!empty($opts['enable_gzip'])) + { + $lines[] = '# GZip compression'; + $lines[] = 'gzip on;'; + $lines[] = 'gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;'; + $lines[] = 'gzip_min_length 256;'; + $lines[] = ''; + } + + if (!empty($opts['enable_expires'])) + { + $cssJs = (int) ($opts['expires_css_js'] ?? 2592000); + $images = (int) ($opts['expires_images'] ?? 31536000); + + $lines[] = '# Browser caching'; + $lines[] = 'location ~* \.(css|js)$ {'; + $lines[] = ' expires ' . round($cssJs / 86400) . 'd;'; + $lines[] = '}'; + $lines[] = 'location ~* \.(jpg|jpeg|png|gif|webp|svg|ico|woff2)$ {'; + $lines[] = ' expires ' . round($images / 86400) . 'd;'; + $lines[] = '}'; + $lines[] = ''; + } + + return implode("\n", $lines); + } +} diff --git a/src/packages/com_mokowaas/admin/src/Model/ImportModel.php b/src/packages/com_mokowaas/admin/src/Model/ImportModel.php new file mode 100644 index 00000000..352c6adf --- /dev/null +++ b/src/packages/com_mokowaas/admin/src/Model/ImportModel.php @@ -0,0 +1,688 @@ +wasImported('admintools')) + { + return null; + } + + $db = $this->getDatabase(); + + try + { + $result = (object) [ + 'component' => false, + 'waf_config' => false, + 'storage' => false, + 'ip_blocks' => 0, + ]; + + // Check component + $db->setQuery("SELECT COUNT(*) FROM #__extensions WHERE element = 'com_admintools' AND type = 'component'"); + $result->component = (int) $db->loadResult() > 0; + + // Check WAF config table + $db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_wafconfig%')); + + if ($db->loadResult()) + { + $result->waf_config = true; + $db->setQuery('SELECT COUNT(*) FROM #__admintools_wafconfig'); + $result->waf_settings = (int) $db->loadResult(); + } + + // Check storage table + $db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_storage%')); + + if ($db->loadResult()) + { + $result->storage = true; + } + + // Check IP blocklist + $db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_ipblock%')); + + if ($db->loadResult()) + { + $db->setQuery('SELECT COUNT(*) FROM #__admintools_ipblock'); + $result->ip_blocks = (int) $db->loadResult(); + } + + // Only available if at least one data source exists + if (!$result->component && !$result->waf_config && !$result->storage) + { + return null; + } + + return $result; + } + catch (\Throwable $e) + { + return null; + } + } + + /** + * Import Admin Tools settings into MokoWaaS. + */ + public function importAdminTools(): array + { + $db = $this->getDatabase(); + $results = ['firewall' => 0, 'htaccess' => 0, 'ip_blocks' => 0, 'disabled' => false]; + + try + { + // ============================================================ + // 1. Import WAF Config → Firewall plugin params + // ============================================================ + $wafSettings = $this->readWafConfig($db); + $firewallParams = $this->mapWafToFirewall($wafSettings); + + if (!empty($firewallParams)) + { + $this->mergePluginParams('mokowaas_firewall', 'system', $firewallParams); + $results['firewall'] = \count($firewallParams); + } + + // ============================================================ + // 2. Import htaccess settings → component htaccess options + // ============================================================ + $htaccessSettings = $this->readHtaccessConfig($db); + $htaccessOptions = $this->mapToHtaccess($htaccessSettings, $wafSettings); + + if (!empty($htaccessOptions)) + { + $this->mergeComponentHtaccessOptions($htaccessOptions); + $results['htaccess'] = \count($htaccessOptions); + } + + // ============================================================ + // 3. Import IP blocklist → Firewall IP deny list + // ============================================================ + $ipBlocks = $this->readIpBlocklist($db); + + if (!empty($ipBlocks)) + { + $this->mergeIpBlocklist($ipBlocks); + $results['ip_blocks'] = \count($ipBlocks); + } + + // ============================================================ + // 4. Disable Admin Tools + // ============================================================ + $this->disableAdminTools($db); + $results['disabled'] = true; + + $this->markImported('admintools'); + + return [ + 'success' => true, + 'message' => \sprintf( + 'Imported %d firewall settings, %d htaccess options, %d blocked IPs from Admin Tools. Admin Tools has been disabled.', + $results['firewall'], $results['htaccess'], $results['ip_blocks'] + ), + 'counts' => $results, + ]; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Import failed: ' . $e->getMessage()]; + } + } + + /** + * Read WAF config from #__admintools_wafconfig. + */ + private function readWafConfig($db): array + { + try + { + $db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_wafconfig%')); + + if (!$db->loadResult()) + { + return []; + } + + $db->setQuery('SELECT * FROM #__admintools_wafconfig'); + $rows = $db->loadObjectList() ?: []; + + $config = []; + + foreach ($rows as $row) + { + $key = $row->key ?? $row->option ?? ''; + + if (!empty($key)) + { + $config[$key] = $row->value ?? ''; + } + } + + return $config; + } + catch (\Throwable $e) + { + return []; + } + } + + /** + * Read htaccess/server config from #__admintools_storage. + */ + private function readHtaccessConfig($db): array + { + try + { + $db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_storage%')); + + if (!$db->loadResult()) + { + return []; + } + + $db->setQuery('SELECT * FROM #__admintools_storage'); + $rows = $db->loadObjectList() ?: []; + + $config = []; + + foreach ($rows as $row) + { + $key = $row->key ?? ''; + + if (!empty($key)) + { + $config[$key] = $row->value ?? ''; + } + } + + return $config; + } + catch (\Throwable $e) + { + return []; + } + } + + /** + * Read IP blocklist from #__admintools_ipblock. + */ + private function readIpBlocklist($db): array + { + try + { + $db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_ipblock%')); + + if (!$db->loadResult()) + { + return []; + } + + $db->setQuery('SELECT ip FROM #__admintools_ipblock'); + + return $db->loadColumn() ?: []; + } + catch (\Throwable $e) + { + return []; + } + } + + /** + * Map Admin Tools WAF config to MokoWaaS firewall plugin params. + */ + private function mapWafToFirewall(array $waf): array + { + $params = []; + + // WAF shields + if (isset($waf['sqlishield'])) + { + $params['waf_sqli'] = (int) $waf['sqlishield'] ? 1 : 0; + } + + if (isset($waf['antispam'])) + { + $params['waf_xss'] = (int) $waf['antispam'] ? 1 : 0; + } + + if (isset($waf['muashield'])) + { + $params['waf_mua'] = (int) $waf['muashield'] ? 1 : 0; + } + + if (isset($waf['rfishield'])) + { + $params['waf_rfi'] = (int) $waf['rfishield'] ? 1 : 0; + } + + if (isset($waf['dfishield'])) + { + $params['waf_dfi'] = (int) $waf['dfishield'] ? 1 : 0; + } + + if (isset($waf['uploadshield'])) + { + // Map to our block_direct_php + $params['block_direct_php'] = (int) $waf['uploadshield'] ? 1 : 0; + } + + // Admin secret URL + if (!empty($waf['adminpw'])) + { + $params['admin_secret'] = $waf['adminpw']; + } + + // Block frontend super user login + if (isset($waf['nofesalogin'])) + { + $params['block_frontend_superuser'] = (int) $waf['nofesalogin'] ? 1 : 0; + } + + // Session timeout + if (!empty($waf['sessionshield']) && !empty($waf['session_timeout'])) + { + $params['admin_session_timeout'] = (int) $waf['session_timeout']; + } + + // Template switch blocking + if (isset($waf['tmpl'])) + { + $params['block_template_switch'] = (int) $waf['tmpl'] ? 1 : 0; + } + + // Blocked sensitive files + if (isset($waf['hogfiles'])) + { + $params['block_sensitive_files'] = (int) $waf['hogfiles'] ? 1 : 0; + } + + return $params; + } + + /** + * Map Admin Tools config to MokoWaaS htaccess maker options. + */ + private function mapToHtaccess(array $storage, array $waf): array + { + $opts = []; + + // Server signature + if (isset($waf['serversignature']) || isset($storage['serversignature'])) + { + $opts['disable_server_signature'] = 1; + } + + // Clickjacking + if (isset($waf['clickjacking']) || isset($storage['xframeoptions'])) + { + $opts['prevent_clickjacking'] = 1; + } + + // HSTS + if (!empty($storage['hstsheader']) || !empty($waf['hstsheader'])) + { + $opts['hsts_enabled'] = 1; + + if (!empty($storage['hstsmaxage'])) + { + $opts['hsts_max_age'] = (int) $storage['hstsmaxage']; + } + } + + // GZip + if (isset($storage['gzipcompression'])) + { + $opts['enable_gzip'] = (int) $storage['gzipcompression'] ? 1 : 0; + } + + // Expiration + if (isset($storage['exptime'])) + { + $opts['enable_expires'] = (int) $storage['exptime'] ? 1 : 0; + } + + // ETag + if (isset($storage['etagtype'])) + { + $opts['etag_control'] = ($storage['etagtype'] === 'none') ? 1 : 0; + } + + // Redirect www / non-www + if (!empty($storage['wwwredir'])) + { + $map = ['www' => 'www', 'nowww' => 'non-www']; + $opts['www_redirect'] = $map[$storage['wwwredir']] ?? 'off'; + } + + // Directory listing + if (isset($storage['nodirlisting'])) + { + $opts['disable_directory_listing'] = (int) $storage['nodirlisting'] ? 1 : 0; + } + + // Block PHP in uploads + if (isset($storage['phpuploadexec'])) + { + $opts['block_php_in_uploads'] = (int) $storage['phpuploadexec'] ? 1 : 0; + } + + // Sensitive files + if (isset($storage['hogfiles'])) + { + $opts['block_sensitive_files'] = (int) $storage['hogfiles'] ? 1 : 0; + } + + return $opts; + } + + /** + * Merge params into a plugin's existing params. + */ + private function mergePluginParams(string $element, string $folder, array $newParams): void + { + $db = $this->getDatabase(); + + $query = $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote($element)) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote($folder)); + $db->setQuery($query); + $current = new Registry($db->loadResult() ?? '{}'); + + foreach ($newParams as $key => $value) + { + $current->set($key, $value); + } + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote($current->toString())) + ->where($db->quoteName('element') . ' = ' . $db->quote($element)) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote($folder)) + )->execute(); + } + + /** + * Merge htaccess options into the component params. + */ + private function mergeComponentHtaccessOptions(array $options): void + { + $db = $this->getDatabase(); + + $query = $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')); + $db->setQuery($query); + $params = new Registry($db->loadResult() ?? '{}'); + + $htaccess = (array) json_decode(json_encode($params->get('htaccess', new \stdClass())), true); + + foreach ($options as $key => $value) + { + $htaccess[$key] = $value; + } + + $params->set('htaccess', $htaccess); + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote($params->toString())) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + )->execute(); + } + + /** + * Merge imported IPs into the firewall IP blocklist. + */ + private function mergeIpBlocklist(array $ips): void + { + $db = $this->getDatabase(); + + $query = $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('system')); + $db->setQuery($query); + $params = new Registry($db->loadResult() ?? '{}'); + + $blocklist = json_decode($params->get('ip_blocklist', '[]'), true) ?: []; + + $existingIps = array_column($blocklist, 'ip'); + + foreach ($ips as $ip) + { + $ip = trim($ip); + + if (empty($ip) || \in_array($ip, $existingIps, true)) + { + continue; + } + + $blocklist[] = [ + 'ip' => $ip, + 'enabled' => '1', + 'label' => 'Imported from Admin Tools', + ]; + } + + $params->set('ip_blocklist', json_encode($blocklist)); + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote($params->toString())) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('system')) + )->execute(); + } + + /** + * Disable Admin Tools component and plugins. + */ + private function disableAdminTools($db): void + { + // Disable component + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('enabled') . ' = 0') + ->where($db->quoteName('element') . ' = ' . $db->quote('com_admintools')) + )->execute(); + + // Disable all Admin Tools plugins + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('enabled') . ' = 0') + ->where($db->quoteName('element') . ' LIKE ' . $db->quote('admintools%')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + )->execute(); + + Log::add('Admin Tools component and plugins disabled after MokoWaaS import', Log::INFO, 'mokowaas'); + } + + // ================================================================== + // Akeeba Ticket System Import + // ================================================================== + + /** + * Check if ATS tables exist. + * Returns null if already imported or no data found. + */ + public function checkAtsAvailable(): ?object + { + if ($this->wasImported('ats')) + { + return null; + } + + $db = $this->getDatabase(); + + try + { + $db->setQuery('SHOW TABLES LIKE ' . $db->quote('%ats_tickets%')); + + if (!$db->loadResult()) + { + return null; + } + + $db->setQuery('SELECT COUNT(*) FROM #__ats_tickets'); + $tickets = (int) $db->loadResult(); + + $db->setQuery('SELECT COUNT(*) FROM #__ats_posts'); + $posts = (int) $db->loadResult(); + + return (object) ['tickets' => $tickets, 'posts' => $posts]; + } + catch (\Throwable $e) + { + return null; + } + } + + /** + * Import from Akeeba Ticket System and disable it. + */ + public function importAts(): array + { + // Delegate to TicketsModel for the actual import + $ticketsModel = new TicketsModel(); + $result = $ticketsModel->importFromAts(); + + if (!$result['success']) + { + return $result; + } + + // Disable ATS after successful import + try + { + $db = $this->getDatabase(); + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('enabled') . ' = 0') + ->where($db->quoteName('element') . ' = ' . $db->quote('com_ats')) + )->execute(); + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('enabled') . ' = 0') + ->where($db->quoteName('element') . ' LIKE ' . $db->quote('ats%')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + )->execute(); + + $result['message'] .= ' Akeeba Ticket System has been disabled.'; + Log::add('Akeeba Ticket System disabled after MokoWaaS import', Log::INFO, 'mokowaas'); + } + catch (\Throwable $e) + { + $result['message'] .= ' Warning: could not disable ATS: ' . $e->getMessage(); + } + + $this->markImported('ats'); + + return $result; + } + + // ================================================================== + // Import markers (stored in component params) + // ================================================================== + + private function wasImported(string $key): bool + { + try + { + $db = $this->getDatabase(); + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + ); + $params = new Registry($db->loadResult() ?? '{}'); + + return (bool) $params->get('imported_' . $key, false); + } + catch (\Throwable $e) + { + return false; + } + } + + private function markImported(string $key): void + { + try + { + $db = $this->getDatabase(); + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + ); + $params = new Registry($db->loadResult() ?? '{}'); + $params->set('imported_' . $key, 1); + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote($params->toString())) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + )->execute(); + } + catch (\Throwable $e) + { + Log::add('Import marker error: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); + } + } +} diff --git a/src/packages/com_mokowaas/admin/src/Model/MaintenanceModel.php b/src/packages/com_mokowaas/admin/src/Model/MaintenanceModel.php new file mode 100644 index 00000000..9d8aa946 --- /dev/null +++ b/src/packages/com_mokowaas/admin/src/Model/MaintenanceModel.php @@ -0,0 +1,251 @@ +getDatabase(); + $prefix = $db->getPrefix(); + + $db->setQuery('SHOW TABLE STATUS'); + $tables = $db->loadObjectList() ?: []; + + $results = []; + $totalSize = 0; + $totalOverhead = 0; + + foreach ($tables as $t) + { + $sizeMb = round(($t->Data_length + $t->Index_length) / 1048576, 2); + $overheadKb = round(($t->Data_free ?? 0) / 1024, 1); + $totalSize += $sizeMb; + $totalOverhead += $overheadKb; + + $results[] = (object) [ + 'name' => $t->Name, + 'rows' => (int) $t->Rows, + 'engine' => $t->Engine, + 'size_mb' => $sizeMb, + 'overhead_kb' => $overheadKb, + 'is_moko' => str_contains($t->Name, 'mokowaas'), + ]; + } + + usort($results, fn($a, $b) => $b->size_mb <=> $a->size_mb); + + return ['tables' => $results, 'total_size_mb' => round($totalSize, 2), 'total_overhead_kb' => round($totalOverhead, 1), 'count' => \count($results)]; + } + + /** + * Optimize all tables or specific ones. + */ + public function optimizeTables(array $tableNames = []): array + { + $db = $this->getDatabase(); + $count = 0; + + try + { + if (empty($tableNames)) + { + $db->setQuery('SHOW TABLE STATUS WHERE Data_free > 0'); + $tables = $db->loadObjectList() ?: []; + $tableNames = array_column($tables, 'Name'); + } + + foreach ($tableNames as $name) + { + $db->setQuery('OPTIMIZE TABLE ' . $db->quoteName($name)); + $db->execute(); + $count++; + } + + return ['success' => true, 'message' => "Optimized {$count} tables."]; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Optimize failed: ' . $e->getMessage()]; + } + } + + /** + * Repair all tables. + */ + public function repairTables(): array + { + $db = $this->getDatabase(); + + try + { + $db->setQuery('SHOW TABLE STATUS'); + $tables = $db->loadObjectList() ?: []; + $count = 0; + + foreach ($tables as $t) + { + if ($t->Engine === 'InnoDB' || $t->Engine === 'MyISAM') + { + $db->setQuery('REPAIR TABLE ' . $db->quoteName($t->Name)); + $db->execute(); + $count++; + } + } + + return ['success' => true, 'message' => "Repaired {$count} tables."]; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Repair failed: ' . $e->getMessage()]; + } + } + + /** + * Purge expired sessions. + */ + public function purgeSessions(): array + { + try + { + $db = $this->getDatabase(); + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__session')) + ->where($db->quoteName('time') . ' < ' . (time() - 86400)) + )->execute(); + + return ['success' => true, 'message' => 'Expired sessions purged. ' . $db->getAffectedRows() . ' removed.']; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => $e->getMessage()]; + } + } + + // ================================================================== + // Temp/Cache Cleanup (#128) + // ================================================================== + + /** + * Get directory sizes for cleanup. + */ + public function getCleanupInfo(): array + { + $dirs = [ + ['path' => JPATH_ROOT . '/cache', 'label' => 'Site Cache'], + ['path' => JPATH_ADMINISTRATOR . '/cache', 'label' => 'Admin Cache'], + ['path' => JPATH_ROOT . '/tmp', 'label' => 'Temp Directory'], + ['path' => JPATH_ADMINISTRATOR . '/logs', 'label' => 'Log Files'], + ]; + + $results = []; + + foreach ($dirs as $dir) + { + $size = 0; + $files = 0; + + if (is_dir($dir['path'])) + { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir['path'], \RecursiveDirectoryIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) + { + if ($file->isFile()) + { + $size += $file->getSize(); + $files++; + } + } + } + + $results[] = (object) [ + 'label' => $dir['label'], + 'path' => $dir['path'], + 'size_mb' => round($size / 1048576, 2), + 'files' => $files, + 'writable' => is_writable($dir['path']), + ]; + } + + return $results; + } + + /** + * Clean a specific directory. + */ + public function cleanDirectory(string $dirKey): array + { + $allowed = [ + 'site_cache' => JPATH_ROOT . '/cache', + 'admin_cache' => JPATH_ADMINISTRATOR . '/cache', + 'tmp' => JPATH_ROOT . '/tmp', + 'logs' => JPATH_ADMINISTRATOR . '/logs', + ]; + + if (!isset($allowed[$dirKey])) + { + return ['success' => false, 'message' => 'Invalid directory.']; + } + + $dir = $allowed[$dirKey]; + + if (!is_dir($dir)) + { + return ['success' => false, 'message' => 'Directory not found.']; + } + + $count = 0; + + try + { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $item) + { + // Keep index.html and .htaccess files + $name = $item->getFilename(); + + if ($name === 'index.html' || $name === '.htaccess') + { + continue; + } + + if ($item->isDir()) + { + @rmdir($item->getPathname()); + } + else + { + @unlink($item->getPathname()); + $count++; + } + } + + // Also clear opcache + if (\function_exists('opcache_reset')) + { + \opcache_reset(); + } + + return ['success' => true, 'message' => "Cleaned {$count} files from {$dirKey}."]; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Cleanup failed: ' . $e->getMessage()]; + } + } +} diff --git a/src/packages/com_mokowaas/admin/src/Model/PrivacyModel.php b/src/packages/com_mokowaas/admin/src/Model/PrivacyModel.php new file mode 100644 index 00000000..3f91e084 --- /dev/null +++ b/src/packages/com_mokowaas/admin/src/Model/PrivacyModel.php @@ -0,0 +1,612 @@ +getDatabase(); + $query = $db->getQuery(true) + ->select([ + $db->quoteName('r') . '.*', + $db->quoteName('u.name', 'user_name'), + $db->quoteName('u.email', 'user_email'), + $db->quoteName('u.username'), + $db->quoteName('p.name', 'processed_by_name'), + ]) + ->from($db->quoteName('#__mokowaas_data_requests', 'r')) + ->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id') + ->leftJoin($db->quoteName('#__users', 'p') . ' ON p.id = r.processed_by'); + + if ($filterStatus) + { + $query->where($db->quoteName('r.status') . ' = ' . $db->quote($filterStatus)); + } + + $query->order($db->quoteName('r.created') . ' DESC')->setLimit(50); + $db->setQuery($query); + + return $db->loadObjectList() ?: []; + } + + /** + * Create a data request (from admin or user self-service). + */ + public function createRequest(int $userId, string $type, string $notes = ''): array + { + $validTypes = ['export', 'delete', 'anonymize']; + + if (!\in_array($type, $validTypes, true)) + { + return ['success' => false, 'message' => 'Invalid request type.']; + } + + try + { + $db = $this->getDatabase(); + $row = (object) [ + 'user_id' => $userId, + 'type' => $type, + 'status' => 'pending', + 'notes' => $notes, + 'created' => Factory::getDate()->toSql(), + ]; + + $db->insertObject('#__mokowaas_data_requests', $row, 'id'); + + return ['success' => true, 'message' => ucfirst($type) . ' request #' . $row->id . ' created.', 'id' => (int) $row->id]; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()]; + } + } + + /** + * Process a data request (approve and execute). + */ + public function processRequest(int $requestId, string $action): array + { + $db = $this->getDatabase(); + + try + { + $db->setQuery( + $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokowaas_data_requests')) + ->where($db->quoteName('id') . ' = ' . $requestId) + ); + $request = $db->loadObject(); + + if (!$request) + { + return ['success' => false, 'message' => 'Request not found.']; + } + + if ($action === 'deny') + { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokowaas_data_requests')) + ->set($db->quoteName('status') . ' = ' . $db->quote('denied')) + ->set($db->quoteName('processed_by') . ' = ' . (int) Factory::getApplication()->getIdentity()->id) + ->set($db->quoteName('processed') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . $requestId) + )->execute(); + + return ['success' => true, 'message' => 'Request denied.']; + } + + // Mark as processing + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokowaas_data_requests')) + ->set($db->quoteName('status') . ' = ' . $db->quote('processing')) + ->where($db->quoteName('id') . ' = ' . $requestId) + )->execute(); + + // Execute the request + $result = null; + + switch ($request->type) + { + case 'export': + $result = $this->exportUserData((int) $request->user_id); + break; + + case 'delete': + $result = $this->deleteUserData((int) $request->user_id); + break; + + case 'anonymize': + $result = $this->anonymizeUserData((int) $request->user_id); + break; + } + + // Mark completed + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokowaas_data_requests')) + ->set($db->quoteName('status') . ' = ' . $db->quote('completed')) + ->set($db->quoteName('processed_by') . ' = ' . (int) Factory::getApplication()->getIdentity()->id) + ->set($db->quoteName('processed') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . $requestId) + )->execute(); + + return $result ?? ['success' => true, 'message' => 'Request processed.']; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Processing failed: ' . $e->getMessage()]; + } + } + + /** + * Export all data for a user as a structured array. + */ + public function exportUserData(int $userId): array + { + $db = $this->getDatabase(); + $data = ['user_id' => $userId, 'exported' => gmdate('Y-m-d\TH:i:s\Z')]; + + try + { + // User profile + $db->setQuery( + $db->getQuery(true) + ->select(['id', 'name', 'username', 'email', 'registerDate', 'lastvisitDate', 'params']) + ->from($db->quoteName('#__users')) + ->where($db->quoteName('id') . ' = ' . $userId) + ); + $data['profile'] = $db->loadObject(); + + // Content (articles) + $db->setQuery( + $db->getQuery(true) + ->select(['id', 'title', 'alias', 'created', 'modified', 'hits']) + ->from($db->quoteName('#__content')) + ->where($db->quoteName('created_by') . ' = ' . $userId) + ); + $data['articles'] = $db->loadObjectList() ?: []; + + // Action logs + $db->setQuery( + $db->getQuery(true) + ->select(['message', 'log_date', 'ip_address']) + ->from($db->quoteName('#__action_logs')) + ->where($db->quoteName('user_id') . ' = ' . $userId) + ->order('log_date DESC') + ->setLimit(100) + ); + $data['action_logs'] = $db->loadObjectList() ?: []; + + // Support tickets + $db->setQuery( + $db->getQuery(true) + ->select(['id', 'subject', 'body', 'status', 'priority', 'created']) + ->from($db->quoteName('#__mokowaas_tickets')) + ->where($db->quoteName('created_by') . ' = ' . $userId) + ); + $data['tickets'] = $db->loadObjectList() ?: []; + + // Ticket replies + $db->setQuery( + $db->getQuery(true) + ->select(['r.id', 'r.ticket_id', 'r.body', 'r.created']) + ->from($db->quoteName('#__mokowaas_ticket_replies', 'r')) + ->where($db->quoteName('r.user_id') . ' = ' . $userId) + ); + $data['ticket_replies'] = $db->loadObjectList() ?: []; + + // Consent log + $db->setQuery( + $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokowaas_consent_log')) + ->where($db->quoteName('user_id') . ' = ' . $userId) + ->order('created ASC') + ); + $data['consent_history'] = $db->loadObjectList() ?: []; + + // Community Builder profile (if table exists) + try + { + $db->setQuery('SHOW TABLES LIKE ' . $db->quote('%comprofiler%')); + + if ($db->loadResult()) + { + $db->setQuery( + $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__comprofiler')) + ->where($db->quoteName('user_id') . ' = ' . $userId) + ); + $data['community_builder'] = $db->loadObject(); + } + } + catch (\Throwable $e) {} + + return ['success' => true, 'message' => 'Data exported.', 'data' => $data]; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Export failed: ' . $e->getMessage()]; + } + } + + /** + * Anonymize a user's data (GDPR right to be forgotten — soft). + */ + public function anonymizeUserData(int $userId): array + { + $db = $this->getDatabase(); + $now = Factory::getDate()->toSql(); + $anon = 'Anonymous User #' . $userId; + + try + { + // Anonymize user record + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__users')) + ->set([ + $db->quoteName('name') . ' = ' . $db->quote($anon), + $db->quoteName('username') . ' = ' . $db->quote('anon_' . $userId), + $db->quoteName('email') . ' = ' . $db->quote('anon_' . $userId . '@deleted.local'), + $db->quoteName('password') . ' = ' . $db->quote(''), + $db->quoteName('block') . ' = 1', + $db->quoteName('params') . ' = ' . $db->quote('{}'), + ]) + ->where($db->quoteName('id') . ' = ' . $userId) + )->execute(); + + // Anonymize article authorship + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__content')) + ->set($db->quoteName('created_by_alias') . ' = ' . $db->quote($anon)) + ->where($db->quoteName('created_by') . ' = ' . $userId) + )->execute(); + + // Delete action logs + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__action_logs')) + ->where($db->quoteName('user_id') . ' = ' . $userId) + )->execute(); + + // Anonymize ticket replies + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokowaas_ticket_replies')) + ->set($db->quoteName('body') . ' = ' . $db->quote('[Content removed per data request]')) + ->where($db->quoteName('user_id') . ' = ' . $userId) + )->execute(); + + // Community Builder + try + { + $db->setQuery('SHOW TABLES LIKE ' . $db->quote('%comprofiler%')); + + if ($db->loadResult()) + { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__comprofiler')) + ->set([ + $db->quoteName('firstname') . ' = ' . $db->quote('Anonymous'), + $db->quoteName('lastname') . ' = ' . $db->quote('User'), + $db->quoteName('middlename') . ' = ' . $db->quote(''), + ]) + ->where($db->quoteName('user_id') . ' = ' . $userId) + )->execute(); + } + } + catch (\Throwable $e) {} + + // Clear Joomla user profile fields (#7) + try + { + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__user_profiles')) + ->where($db->quoteName('user_id') . ' = ' . $userId) + )->execute(); + } + catch (\Throwable $e) {} + + // Clear contact details if linked + try + { + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__contact_details')) + ->where($db->quoteName('user_id') . ' = ' . $userId) + )->execute(); + } + catch (\Throwable $e) {} + + // Log the anonymization + $this->logConsent($userId, 'account_anonymized', 'granted'); + + return ['success' => true, 'message' => 'User #' . $userId . ' data anonymized.']; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Anonymization failed: ' . $e->getMessage()]; + } + } + + /** + * Delete a user's data completely (hard delete). + */ + public function deleteUserData(int $userId): array + { + $result = $this->anonymizeUserData($userId); + + if (!$result['success']) + { + return $result; + } + + $db = $this->getDatabase(); + + try + { + // Delete tickets and replies + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName('#__mokowaas_tickets')) + ->where($db->quoteName('created_by') . ' = ' . $userId) + ); + $ticketIds = $db->loadColumn() ?: []; + + if (!empty($ticketIds)) + { + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__mokowaas_ticket_replies')) + ->where($db->quoteName('ticket_id') . ' IN (' . implode(',', $ticketIds) . ')') + )->execute(); + + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__mokowaas_tickets')) + ->where($db->quoteName('created_by') . ' = ' . $userId) + )->execute(); + } + + // Delete consent log + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__mokowaas_consent_log')) + ->where($db->quoteName('user_id') . ' = ' . $userId) + )->execute(); + + // Delete user record + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__users')) + ->where($db->quoteName('id') . ' = ' . $userId) + )->execute(); + + return ['success' => true, 'message' => 'User #' . $userId . ' data permanently deleted.']; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Deletion failed: ' . $e->getMessage()]; + } + } + + // ================================================================== + // Consent Management + // ================================================================== + + /** + * Get consent status for a user. + */ + public function getUserConsent(int $userId): array + { + $db = $this->getDatabase(); + $db->setQuery( + $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokowaas_consent_log')) + ->where($db->quoteName('user_id') . ' = ' . $userId) + ->order($db->quoteName('created') . ' DESC') + ); + + return $db->loadObjectList() ?: []; + } + + /** + * Record a consent action. + */ + public function logConsent(int $userId, string $category, string $action): void + { + $db = $this->getDatabase(); + $row = (object) [ + 'user_id' => $userId, + 'category' => $category, + 'action' => $action === 'revoked' ? 'revoked' : 'granted', + 'ip_address' => $_SERVER['REMOTE_ADDR'] ?? '', + 'created' => Factory::getDate()->toSql(), + ]; + $db->insertObject('#__mokowaas_consent_log', $row, 'id'); + } + + // ================================================================== + // Retention Policy Enforcement + // ================================================================== + + /** + * Get all retention policies. + */ + public function getRetentionPolicies(): array + { + $db = $this->getDatabase(); + $db->setQuery( + $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokowaas_retention_policies')) + ->order($db->quoteName('id') . ' ASC') + ); + + return $db->loadObjectList() ?: []; + } + + /** + * Run retention policy enforcement (called by scheduled task). + */ + public function enforceRetentionPolicies(): array + { + $db = $this->getDatabase(); + $results = ['policies_run' => 0, 'items_affected' => 0]; + $policies = $this->getRetentionPolicies(); + + foreach ($policies as $policy) + { + if (!(int) $policy->enabled) + { + continue; + } + + $cutoff = Factory::getDate('-' . (int) $policy->retention_days . ' days')->toSql(); + $count = 0; + + try + { + switch ($policy->content_type) + { + case 'action_logs': + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__action_logs')) + ->where($db->quoteName('log_date') . ' < ' . $db->quote($cutoff)) + )->execute(); + $count = $db->getAffectedRows(); + break; + + case 'waf_logs': + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__mokowaas_waf_log')) + ->where($db->quoteName('created') . ' < ' . $db->quote($cutoff)) + )->execute(); + $count = $db->getAffectedRows(); + break; + + case 'sessions': + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__session')) + ->where($db->quoteName('time') . ' < ' . (int) strtotime($cutoff)) + )->execute(); + $count = $db->getAffectedRows(); + break; + + case 'closed_tickets': + if ($policy->action === 'anonymize') + { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokowaas_tickets')) + ->set($db->quoteName('body') . ' = ' . $db->quote('[Removed per retention policy]')) + ->where($db->quoteName('status') . ' = ' . $db->quote('closed')) + ->where($db->quoteName('closed') . ' < ' . $db->quote($cutoff)) + ->where($db->quoteName('body') . ' != ' . $db->quote('[Removed per retention policy]')) + )->execute(); + $count = $db->getAffectedRows(); + } + break; + + case 'inactive_users': + if ($policy->action === 'anonymize') + { + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName('#__users')) + ->where($db->quoteName('lastvisitDate') . ' < ' . $db->quote($cutoff)) + ->where($db->quoteName('lastvisitDate') . ' != ' . $db->quote('0000-00-00 00:00:00')) + ->where($db->quoteName('block') . ' = 0') + ->where($db->quoteName('username') . ' NOT LIKE ' . $db->quote('anon_%')) + ); + $userIds = $db->loadColumn() ?: []; + + foreach ($userIds as $uid) + { + $this->anonymizeUserData((int) $uid); + $count++; + } + } + break; + } + + if ($count > 0) + { + $results['policies_run']++; + $results['items_affected'] += $count; + Log::add(\sprintf('Retention: %s — %d items affected', $policy->content_type, $count), Log::INFO, 'mokowaas'); + } + } + catch (\Throwable $e) + { + Log::add('Retention policy error (' . $policy->content_type . '): ' . $e->getMessage(), Log::WARNING, 'mokowaas'); + } + } + + return $results; + } + + /** + * Get privacy dashboard summary counts. + */ + public function getDashboardSummary(): object + { + $db = $this->getDatabase(); + + $summary = (object) [ + 'pending_requests' => 0, + 'total_requests' => 0, + 'consent_entries' => 0, + 'policies_active' => 0, + ]; + + try + { + $db->setQuery('SELECT COUNT(*) FROM #__mokowaas_data_requests WHERE status = ' . $db->quote('pending')); + $summary->pending_requests = (int) $db->loadResult(); + + $db->setQuery('SELECT COUNT(*) FROM #__mokowaas_data_requests'); + $summary->total_requests = (int) $db->loadResult(); + + $db->setQuery('SELECT COUNT(*) FROM #__mokowaas_consent_log'); + $summary->consent_entries = (int) $db->loadResult(); + + $db->setQuery('SELECT COUNT(*) FROM #__mokowaas_retention_policies WHERE enabled = 1'); + $summary->policies_active = (int) $db->loadResult(); + } + catch (\Throwable $e) {} + + return $summary; + } +} diff --git a/src/packages/com_mokowaas/admin/src/Model/TicketsModel.php b/src/packages/com_mokowaas/admin/src/Model/TicketsModel.php new file mode 100644 index 00000000..34bfc928 --- /dev/null +++ b/src/packages/com_mokowaas/admin/src/Model/TicketsModel.php @@ -0,0 +1,945 @@ +getDatabase(); + $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.modified'), + $db->quoteName('t.sla_response_due'), + $db->quoteName('t.sla_resolution_due'), + $db->quoteName('t.sla_responded'), + $db->quoteName('c.title', 'category_title'), + $db->quoteName('u.name', 'created_by_name'), + $db->quoteName('a.name', 'assigned_to_name'), + ]) + ->from($db->quoteName('#__mokowaas_tickets', 't')) + ->leftJoin($db->quoteName('#__mokowaas_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 (!empty($filters['status'])) + { + $query->where($db->quoteName('t.status') . ' = ' . $db->quote($filters['status'])); + } + + if (!empty($filters['priority'])) + { + $query->where($db->quoteName('t.priority') . ' = ' . $db->quote($filters['priority'])); + } + + if (!empty($filters['assigned_to'])) + { + $query->where($db->quoteName('t.assigned_to') . ' = ' . (int) $filters['assigned_to']); + } + + if (!empty($filters['category_id'])) + { + $query->where($db->quoteName('t.category_id') . ' = ' . (int) $filters['category_id']); + } + + $query->order($db->quoteName('t.created') . ' DESC'); + $query->setLimit(50); + + $db->setQuery($query); + + return $db->loadObjectList() ?: []; + } + + /** + * Get a single ticket with all replies. + */ + public function getTicket(int $id): ?object + { + $db = $this->getDatabase(); + $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('#__mokowaas_tickets', 't')) + ->leftJoin($db->quoteName('#__mokowaas_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); + $db->setQuery($query); + $ticket = $db->loadObject(); + + if (!$ticket) + { + return null; + } + + // Load replies + $query = $db->getQuery(true) + ->select([ + $db->quoteName('r') . '.*', + $db->quoteName('u.name', 'user_name'), + ]) + ->from($db->quoteName('#__mokowaas_ticket_replies', 'r')) + ->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id') + ->where($db->quoteName('r.ticket_id') . ' = ' . $id) + ->order($db->quoteName('r.created') . ' ASC'); + $db->setQuery($query); + $ticket->replies = $db->loadObjectList() ?: []; + + // Reply count + $ticket->reply_count = \count($ticket->replies); + + return $ticket; + } + + /** + * Create a new ticket. + */ + public function createTicket(array $data): array + { + try + { + $db = $this->getDatabase(); + $user = Factory::getApplication()->getIdentity(); + $now = Factory::getDate()->toSql(); + + $ticket = (object) [ + 'subject' => $data['subject'] ?? '', + 'body' => $data['body'] ?? '', + 'status' => 'open', + 'priority' => $data['priority'] ?? 'normal', + 'category_id' => (int) ($data['category_id'] ?? 0) ?: null, + 'created_by' => $user->id, + 'assigned_to' => (int) ($data['assigned_to'] ?? 0) ?: null, + 'created' => $now, + 'modified' => $now, + ]; + + // Auto-assign from category + if (!$ticket->assigned_to && $ticket->category_id) + { + $query = $db->getQuery(true) + ->select($db->quoteName('auto_assign_user')) + ->from($db->quoteName('#__mokowaas_ticket_categories')) + ->where($db->quoteName('id') . ' = ' . (int) $ticket->category_id); + $db->setQuery($query); + $autoAssign = (int) $db->loadResult(); + + if ($autoAssign) + { + $ticket->assigned_to = $autoAssign; + } + } + + // SLA deadlines from category + if ($ticket->category_id) + { + $query = $db->getQuery(true) + ->select([$db->quoteName('sla_response_minutes'), $db->quoteName('sla_resolution_minutes')]) + ->from($db->quoteName('#__mokowaas_ticket_categories')) + ->where($db->quoteName('id') . ' = ' . (int) $ticket->category_id); + $db->setQuery($query); + $sla = $db->loadObject(); + + if ($sla) + { + $ticket->sla_response_due = Factory::getDate($now)->modify('+' . (int) $sla->sla_response_minutes . ' minutes')->toSql(); + $ticket->sla_resolution_due = Factory::getDate($now)->modify('+' . (int) $sla->sla_resolution_minutes . ' minutes')->toSql(); + } + } + + $db->insertObject('#__mokowaas_tickets', $ticket, 'id'); + + // Run automation + notifications + $this->runAutomation('ticket_created', (int) $ticket->id); + NotificationService::notify('ticket_created', $this->getTicket((int) $ticket->id)); + + return ['success' => true, 'message' => 'Ticket #' . $ticket->id . ' created.', 'id' => (int) $ticket->id]; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()]; + } + } + + /** + * Add a reply to a ticket. + */ + public function addReply(int $ticketId, string $body, bool $isInternal = false): array + { + try + { + $db = $this->getDatabase(); + $user = Factory::getApplication()->getIdentity(); + $now = Factory::getDate()->toSql(); + + $reply = (object) [ + 'ticket_id' => $ticketId, + 'user_id' => $user->id, + 'body' => $body, + 'is_internal' => $isInternal ? 1 : 0, + 'created' => $now, + ]; + + $db->insertObject('#__mokowaas_ticket_replies', $reply, 'id'); + + // Mark SLA as responded only for staff replies (not customer self-replies) + $ticket = $this->getTicket($ticketId); + $isStaffReply = $ticket && (int) $user->id !== (int) $ticket->created_by; + + $updateQuery = $db->getQuery(true) + ->update($db->quoteName('#__mokowaas_tickets')) + ->set($db->quoteName('modified') . ' = ' . $db->quote($now)) + ->where($db->quoteName('id') . ' = ' . $ticketId); + + if ($isStaffReply) + { + $updateQuery->set($db->quoteName('sla_responded') . ' = 1') + ->where($db->quoteName('sla_responded') . ' = 0'); + } + + $db->setQuery($updateQuery)->execute(); + + // Run automation + notifications (skip internal notes) + $this->runAutomation('ticket_replied', $ticketId); + + if (!$isInternal) + { + NotificationService::notify('ticket_replied', $this->getTicket($ticketId), ['reply_body' => $body]); + } + + return ['success' => true, 'message' => 'Reply added.']; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()]; + } + } + + /** + * Update ticket status. + */ + public function updateStatus(int $ticketId, string $status): array + { + $valid = ['open', 'in_progress', 'waiting', 'resolved', 'closed']; + + if (!\in_array($status, $valid, true)) + { + return ['success' => false, 'message' => 'Invalid status.']; + } + + try + { + $db = $this->getDatabase(); + $now = Factory::getDate()->toSql(); + + // Capture old status for notification + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('status')) + ->from($db->quoteName('#__mokowaas_tickets')) + ->where($db->quoteName('id') . ' = ' . $ticketId) + ); + $oldStatus = $db->loadResult() ?? ''; + + $sets = [ + $db->quoteName('status') . ' = ' . $db->quote($status), + $db->quoteName('modified') . ' = ' . $db->quote($now), + ]; + + if ($status === 'resolved') + { + $sets[] = $db->quoteName('resolved') . ' = ' . $db->quote($now); + } + + if ($status === 'closed') + { + $sets[] = $db->quoteName('closed') . ' = ' . $db->quote($now); + } + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokowaas_tickets')) + ->set($sets) + ->where($db->quoteName('id') . ' = ' . $ticketId) + )->execute(); + + // Run automation + notifications + $this->runAutomation('status_changed', $ticketId); + NotificationService::notify('status_changed', $this->getTicket($ticketId), ['old_status' => $oldStatus]); + + return ['success' => true, 'message' => 'Status updated to ' . $status . '.']; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()]; + } + } + + /** + * Get all ticket categories. + */ + public function getCategories(): array + { + $db = $this->getDatabase(); + $db->setQuery( + $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokowaas_ticket_categories')) + ->where($db->quoteName('published') . ' = 1') + ->order($db->quoteName('ordering') . ' ASC') + ); + + return $db->loadObjectList() ?: []; + } + + /** + * Get canned responses, optionally filtered by category. + */ + public function getCannedResponses(int $categoryId = 0): array + { + $db = $this->getDatabase(); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokowaas_ticket_canned')) + ->order($db->quoteName('ordering') . ' ASC'); + + if ($categoryId) + { + $query->where('(' . $db->quoteName('category_id') . ' = ' . $categoryId + . ' OR ' . $db->quoteName('category_id') . ' IS NULL)'); + } + + $db->setQuery($query); + + return $db->loadObjectList() ?: []; + } + + /** + * Get ticket counts by status for dashboard. + */ + public function getStatusCounts(): object + { + $db = $this->getDatabase(); + $db->setQuery( + $db->getQuery(true) + ->select([$db->quoteName('status'), 'COUNT(*) AS ' . $db->quoteName('cnt')]) + ->from($db->quoteName('#__mokowaas_tickets')) + ->group($db->quoteName('status')) + ); + $rows = $db->loadObjectList('status') ?: []; + + return (object) [ + 'open' => (int) ($rows['open']->cnt ?? 0), + 'in_progress' => (int) ($rows['in_progress']->cnt ?? 0), + 'waiting' => (int) ($rows['waiting']->cnt ?? 0), + 'resolved' => (int) ($rows['resolved']->cnt ?? 0), + 'closed' => (int) ($rows['closed']->cnt ?? 0), + 'total' => array_sum(array_map(fn($r) => (int) $r->cnt, $rows)), + ]; + } + + /** + * Get overdue tickets (SLA breached). + */ + public function getOverdueTickets(): array + { + $db = $this->getDatabase(); + $now = Factory::getDate()->toSql(); + + $query = $db->getQuery(true) + ->select([$db->quoteName('id'), $db->quoteName('subject'), $db->quoteName('priority'), + $db->quoteName('sla_response_due'), $db->quoteName('sla_resolution_due'), $db->quoteName('sla_responded')]) + ->from($db->quoteName('#__mokowaas_tickets')) + ->where($db->quoteName('status') . ' NOT IN (' . $db->quote('resolved') . ',' . $db->quote('closed') . ')') + ->where('((' . $db->quoteName('sla_response_due') . ' < ' . $db->quote($now) . ' AND ' . $db->quoteName('sla_responded') . ' = 0)' + . ' OR ' . $db->quoteName('sla_resolution_due') . ' < ' . $db->quote($now) . ')') + ->order($db->quoteName('sla_resolution_due') . ' ASC'); + $db->setQuery($query); + + return $db->loadObjectList() ?: []; + } + + // ================================================================== + // Automation Engine + // ================================================================== + + /** + * Run automation rules for a specific trigger event against a ticket. + * + * @param string $event trigger_event: ticket_created, ticket_replied, status_changed, scheduled + * @param int $ticketId The ticket to evaluate + */ + public function runAutomation(string $event, int $ticketId): void + { + try + { + $db = $this->getDatabase(); + + // Load enabled rules for this event + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokowaas_ticket_automation')) + ->where($db->quoteName('trigger_event') . ' = ' . $db->quote($event)) + ->where($db->quoteName('enabled') . ' = 1') + ->order($db->quoteName('ordering') . ' ASC'); + $db->setQuery($query); + $rules = $db->loadObjectList() ?: []; + + if (empty($rules)) + { + return; + } + + // Load the ticket + $ticket = $this->getTicket($ticketId); + + if (!$ticket) + { + return; + } + + // Calculate age in hours + $ticket->age_hours = (time() - strtotime($ticket->created)) / 3600; + + foreach ($rules as $rule) + { + $conditions = json_decode($rule->conditions, true) ?: []; + $actions = json_decode($rule->actions, true) ?: []; + + if ($this->evaluateConditions($conditions, $ticket)) + { + $this->executeActions($actions, $ticketId, $ticket); + } + } + } + catch (\Throwable $e) + { + \Joomla\CMS\Log\Log::add('Automation error: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokowaas'); + } + } + + /** + * Run all scheduled automation rules against all open tickets. + */ + public function runScheduledAutomation(): array + { + $db = $this->getDatabase(); + $results = ['evaluated' => 0, 'acted' => 0]; + + // Load scheduled rules + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokowaas_ticket_automation')) + ->where($db->quoteName('trigger_event') . ' = ' . $db->quote('scheduled')) + ->where($db->quoteName('enabled') . ' = 1') + ->order($db->quoteName('ordering') . ' ASC'); + $db->setQuery($query); + $rules = $db->loadObjectList() ?: []; + + if (empty($rules)) + { + return $results; + } + + // Load all non-closed tickets + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokowaas_tickets')) + ->where($db->quoteName('status') . ' != ' . $db->quote('closed')); + $db->setQuery($query); + $tickets = $db->loadObjectList() ?: []; + + foreach ($tickets as $ticket) + { + $ticket->age_hours = (time() - strtotime($ticket->created)) / 3600; + $ticket->replies = []; + $results['evaluated']++; + + foreach ($rules as $rule) + { + $conditions = json_decode($rule->conditions, true) ?: []; + $actions = json_decode($rule->actions, true) ?: []; + + if ($this->evaluateConditions($conditions, $ticket)) + { + $this->executeActions($actions, (int) $ticket->id, $ticket); + $results['acted']++; + } + } + } + + return $results; + } + + /** + * Evaluate a set of conditions against a ticket (all must match). + */ + private function evaluateConditions(array $conditions, object $ticket): bool + { + foreach ($conditions as $cond) + { + $field = $cond['field'] ?? ''; + $op = $cond['op'] ?? 'eq'; + $value = $cond['value'] ?? ''; + + $ticketValue = $ticket->{$field} ?? null; + + if ($ticketValue === null) + { + return false; + } + + switch ($op) + { + case 'eq': + if ((string) $ticketValue !== (string) $value) return false; + break; + case 'neq': + if ((string) $ticketValue === (string) $value) return false; + break; + case 'gt': + if ((float) $ticketValue <= (float) $value) return false; + break; + case 'lt': + if ((float) $ticketValue >= (float) $value) return false; + break; + case 'in': + $list = array_map('trim', explode(',', $value)); + if (!\in_array((string) $ticketValue, $list, true)) return false; + break; + case 'not_in': + $list = array_map('trim', explode(',', $value)); + if (\in_array((string) $ticketValue, $list, true)) return false; + break; + default: + return false; + } + } + + return true; + } + + /** + * Execute a set of actions on a ticket. + */ + private function executeActions(array $actions, int $ticketId, object $ticket): void + { + $db = $this->getDatabase(); + $now = Factory::getDate()->toSql(); + + foreach ($actions as $action) + { + $type = $action['type'] ?? ''; + $value = $action['value'] ?? ''; + + switch ($type) + { + case 'set_status': + $this->updateStatus($ticketId, $value); + break; + + case 'set_priority': + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokowaas_tickets')) + ->set($db->quoteName('priority') . ' = ' . $db->quote($value)) + ->set($db->quoteName('modified') . ' = ' . $db->quote($now)) + ->where($db->quoteName('id') . ' = ' . $ticketId) + )->execute(); + break; + + case 'assign': + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokowaas_tickets')) + ->set($db->quoteName('assigned_to') . ' = ' . (int) $value) + ->set($db->quoteName('modified') . ' = ' . $db->quote($now)) + ->where($db->quoteName('id') . ' = ' . $ticketId) + )->execute(); + break; + + case 'add_note': + $reply = (object) [ + 'ticket_id' => $ticketId, + 'user_id' => 0, + 'body' => $value, + 'is_internal' => 1, + 'created' => $now, + ]; + $db->insertObject('#__mokowaas_ticket_replies', $reply, 'id'); + break; + + case 'send_email': + // value = email address or comma-separated list + $emails = array_filter(array_map('trim', explode(',', $value))); + + foreach ($emails as $email) + { + try + { + $mailer = Factory::getMailer(); + $mailer->addRecipient($email); + $mailer->setSubject('[Ticket #' . $ticketId . '] Automation Alert'); + $mailer->setBody('Automation rule triggered for ticket #' . $ticketId . ': ' . ($ticket->subject ?? '')); + $mailer->isHtml(false); + $mailer->Send(); + } + catch (\Throwable $e) + { + \Joomla\CMS\Log\Log::add('Automation email failed: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokowaas'); + } + } + break; + + case 'create_ticket': + // value = JSON: {"subject":"...","body":"...","category_id":1,"priority":"normal","behavior":"append"} + $ticketData = json_decode($value, true) ?: []; + $behavior = $ticketData['behavior'] ?? 'append'; + $userId = (int) ($ticket->created_by ?? 0); + $catId = (int) ($ticketData['category_id'] ?? 0); + + if ($behavior === 'append' && $userId > 0) + { + // Check for existing open ticket from this user in this category + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName('#__mokowaas_tickets')) + ->where($db->quoteName('created_by') . ' = ' . $userId) + ->where($db->quoteName('status') . ' NOT IN (' . $db->quote('resolved') . ',' . $db->quote('closed') . ')') + ->where($catId ? $db->quoteName('category_id') . ' = ' . $catId : '1=1') + ->order($db->quoteName('created') . ' DESC') + ->setLimit(1) + ); + $existingId = (int) $db->loadResult(); + + if ($existingId) + { + $this->addReply($existingId, $ticketData['body'] ?? 'Automation event', true); + break; + } + } + elseif ($behavior === 'skip_if_open' && $userId > 0) + { + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokowaas_tickets')) + ->where($db->quoteName('created_by') . ' = ' . $userId) + ->where($db->quoteName('status') . ' NOT IN (' . $db->quote('resolved') . ',' . $db->quote('closed') . ')') + ); + + if ((int) $db->loadResult() > 0) + { + break; + } + } + + // Create new ticket + $this->createTicket([ + 'subject' => $ticketData['subject'] ?? 'Automation: ' . ($ticket->subject ?? 'System event'), + 'body' => $ticketData['body'] ?? '', + 'priority' => $ticketData['priority'] ?? 'normal', + 'category_id' => $catId, + ]); + break; + } + } + } + + /** + * Run automation for a system event (not tied to a specific ticket). + * Creates a virtual ticket context from event data. + */ + public function runSystemEventAutomation(string $event, array $eventData = []): void + { + try + { + $db = $this->getDatabase(); + + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokowaas_ticket_automation')) + ->where($db->quoteName('trigger_event') . ' = ' . $db->quote($event)) + ->where($db->quoteName('enabled') . ' = 1') + ->order($db->quoteName('ordering') . ' ASC'); + $db->setQuery($query); + $rules = $db->loadObjectList() ?: []; + + if (empty($rules)) + { + return; + } + + // Build a virtual ticket-like object from event data + $context = (object) array_merge([ + 'id' => 0, + 'subject' => $eventData['subject'] ?? $event, + 'body' => $eventData['body'] ?? '', + 'status' => 'open', + 'priority' => $eventData['priority'] ?? 'normal', + 'created_by' => $eventData['user_id'] ?? 0, + 'created' => gmdate('Y-m-d H:i:s'), + 'age_hours' => 0, + ], $eventData); + + foreach ($rules as $rule) + { + $conditions = json_decode($rule->conditions, true) ?: []; + $actions = json_decode($rule->actions, true) ?: []; + + if (empty($conditions) || $this->evaluateConditions($conditions, $context)) + { + $this->executeActions($actions, 0, $context); + } + } + } + catch (\Throwable $e) + { + \Joomla\CMS\Log\Log::add('System event automation error: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokowaas'); + } + } + + /** + * Get all automation rules. + */ + public function getAutomationRules(): array + { + $db = $this->getDatabase(); + $db->setQuery( + $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokowaas_ticket_automation')) + ->order($db->quoteName('ordering') . ' ASC') + ); + + return $db->loadObjectList() ?: []; + } + + // ================================================================== + // Akeeba Ticket System Importer + // ================================================================== + + /** + * Check if ATS tables exist and return counts. + */ + public function checkAtsAvailable(): ?object + { + $db = $this->getDatabase(); + + try + { + $db->setQuery('SELECT COUNT(*) FROM #__ats_tickets'); + $tickets = (int) $db->loadResult(); + + $db->setQuery('SELECT COUNT(*) FROM #__ats_posts'); + $posts = (int) $db->loadResult(); + + $db->setQuery('SELECT COUNT(*) FROM #__ats_cannedreplies'); + $canned = (int) $db->loadResult(); + + return (object) ['tickets' => $tickets, 'posts' => $posts, 'canned' => $canned]; + } + catch (\Throwable $e) + { + return null; + } + } + + /** + * Import tickets, replies, and canned responses from Akeeba Ticket System. + */ + public function importFromAts(): array + { + $db = $this->getDatabase(); + $results = ['tickets' => 0, 'replies' => 0, 'canned' => 0, 'errors' => []]; + + try + { + // Status mapping: ATS → MokoWaaS + $statusMap = [ + 'O' => 'open', // Open + 'P' => 'in_progress', // Pending (staff action needed) + 'C' => 'closed', // Closed + ]; + // Numeric statuses 1-99 are custom — map to open + for ($i = 1; $i <= 99; $i++) + { + $statusMap[(string) $i] = 'open'; + } + + // Priority mapping: ATS uses 1-5, we use enum + $priorityMap = [ + 1 => 'low', + 2 => 'low', + 3 => 'normal', + 4 => 'high', + 5 => 'urgent', + ]; + + // Category mapping: ATS uses Joomla categories, map catid to our category + // Default all to General Support (1) — admin can reassign later + $defaultCategory = 1; + + // Import canned replies first + $db->setQuery('SELECT * FROM #__ats_cannedreplies WHERE enabled = 1 ORDER BY ordering'); + $atsCanned = $db->loadObjectList() ?: []; + + foreach ($atsCanned as $c) + { + $exists = $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from('#__mokowaas_ticket_canned') + ->where($db->quoteName('title') . ' = ' . $db->quote($c->title)) + )->loadResult(); + + if ((int) $exists > 0) + { + continue; + } + + $row = (object) [ + 'title' => $c->title, + 'body' => strip_tags($c->reply ?? ''), + 'category_id' => null, + 'ordering' => (int) ($c->ordering ?? 0), + ]; + $db->insertObject('#__mokowaas_ticket_canned', $row, 'id'); + $results['canned']++; + } + + // Import tickets + $db->setQuery('SELECT * FROM #__ats_tickets ORDER BY id'); + $atsTickets = $db->loadObjectList() ?: []; + + $ticketIdMap = []; // ATS id → MokoWaaS id + + foreach ($atsTickets as $t) + { + // Skip if already imported (check by subject + created_by + created) + $exists = $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from('#__mokowaas_tickets') + ->where($db->quoteName('subject') . ' = ' . $db->quote($t->title)) + ->where($db->quoteName('created_by') . ' = ' . (int) $t->created_by) + )->loadResult(); + + if ((int) $exists > 0) + { + continue; + } + + $status = $statusMap[$t->status] ?? 'open'; + $priority = $priorityMap[(int) $t->priority] ?? 'normal'; + + $row = (object) [ + 'subject' => $t->title, + 'body' => '', + 'status' => $status, + 'priority' => $priority, + 'category_id' => $defaultCategory, + 'created_by' => (int) $t->created_by, + 'assigned_to' => (int) $t->assigned_to ?: null, + 'created' => $t->created ?: Factory::getDate()->toSql(), + 'modified' => $t->modified, + 'resolved' => $status === 'closed' ? ($t->modified ?: $t->created) : null, + 'closed' => $status === 'closed' ? ($t->modified ?: $t->created) : null, + 'sla_responded' => 1, + ]; + + $db->insertObject('#__mokowaas_tickets', $row, 'id'); + $ticketIdMap[(int) $t->id] = (int) $row->id; + $results['tickets']++; + } + + // Import posts (replies) + $db->setQuery('SELECT * FROM #__ats_posts ORDER BY id'); + $atsPosts = $db->loadObjectList() ?: []; + + foreach ($atsPosts as $p) + { + $newTicketId = $ticketIdMap[(int) $p->ticket_id] ?? null; + + if (!$newTicketId) + { + continue; + } + + // First post of a ticket is usually the ticket body — update the ticket + if (empty($results['first_post_' . $p->ticket_id])) + { + $results['first_post_' . $p->ticket_id] = true; + $body = strip_tags($p->content_html ?? ''); + $db->setQuery( + $db->getQuery(true) + ->update('#__mokowaas_tickets') + ->set($db->quoteName('body') . ' = ' . $db->quote($body)) + ->where($db->quoteName('id') . ' = ' . $newTicketId) + )->execute(); + + continue; + } + + $row = (object) [ + 'ticket_id' => $newTicketId, + 'user_id' => (int) $p->created_by, + 'body' => strip_tags($p->content_html ?? ''), + 'is_internal' => 0, + 'created' => $p->created ?: Factory::getDate()->toSql(), + ]; + + $db->insertObject('#__mokowaas_ticket_replies', $row, 'id'); + $results['replies']++; + } + + // Clean up temp tracking keys + foreach (array_keys($results) as $k) + { + if (str_starts_with($k, 'first_post_')) + { + unset($results[$k]); + } + } + + return [ + 'success' => true, + 'message' => sprintf( + 'Imported %d tickets, %d replies, %d canned responses from Akeeba Ticket System.', + $results['tickets'], $results['replies'], $results['canned'] + ), + 'counts' => $results, + ]; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Import failed: ' . $e->getMessage()]; + } + } +} diff --git a/src/packages/com_mokowaas/admin/src/Model/WaflogModel.php b/src/packages/com_mokowaas/admin/src/Model/WaflogModel.php new file mode 100644 index 00000000..591ba310 --- /dev/null +++ b/src/packages/com_mokowaas/admin/src/Model/WaflogModel.php @@ -0,0 +1,215 @@ +getDatabase(); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokowaas_waf_log')); + + if (!empty($filters['rule'])) + { + $query->where($db->quoteName('rule') . ' = ' . $db->quote($filters['rule'])); + } + + if (!empty($filters['ip'])) + { + $query->where($db->quoteName('ip') . ' LIKE ' . $db->quote('%' . $db->escape($filters['ip'], true) . '%')); + } + + if (!empty($filters['search'])) + { + $search = $db->quote('%' . $db->escape($filters['search'], true) . '%'); + $query->where('(' . $db->quoteName('uri') . ' LIKE ' . $search + . ' OR ' . $db->quoteName('detail') . ' LIKE ' . $search + . ' OR ' . $db->quoteName('user_agent') . ' LIKE ' . $search . ')'); + } + + if (!empty($filters['date_from'])) + { + $query->where($db->quoteName('created') . ' >= ' . $db->quote($filters['date_from'] . ' 00:00:00')); + } + + if (!empty($filters['date_to'])) + { + $query->where($db->quoteName('created') . ' <= ' . $db->quote($filters['date_to'] . ' 23:59:59')); + } + + $query->order($db->quoteName('created') . ' DESC'); + $query->setLimit($limit, $offset); + + $db->setQuery($query); + + return $db->loadObjectList() ?: []; + } + + /** + * Get total count for pagination. + */ + public function getTotal(array $filters = []): int + { + $db = $this->getDatabase(); + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokowaas_waf_log')); + + if (!empty($filters['rule'])) + { + $query->where($db->quoteName('rule') . ' = ' . $db->quote($filters['rule'])); + } + + if (!empty($filters['ip'])) + { + $query->where($db->quoteName('ip') . ' LIKE ' . $db->quote('%' . $db->escape($filters['ip'], true) . '%')); + } + + $db->setQuery($query); + + return (int) $db->loadResult(); + } + + /** + * Get block counts grouped by rule for the summary bar. + */ + public function getRuleCounts(): array + { + $db = $this->getDatabase(); + $db->setQuery( + $db->getQuery(true) + ->select([$db->quoteName('rule'), 'COUNT(*) AS ' . $db->quoteName('cnt')]) + ->from($db->quoteName('#__mokowaas_waf_log')) + ->group($db->quoteName('rule')) + ->order($db->quoteName('cnt') . ' DESC') + ); + + return $db->loadObjectList() ?: []; + } + + /** + * Get top blocked IPs. + */ + public function getTopIps(int $limit = 10): array + { + $db = $this->getDatabase(); + $db->setQuery( + $db->getQuery(true) + ->select([$db->quoteName('ip'), 'COUNT(*) AS ' . $db->quoteName('cnt'), + 'MAX(' . $db->quoteName('created') . ') AS ' . $db->quoteName('last_seen')]) + ->from($db->quoteName('#__mokowaas_waf_log')) + ->group($db->quoteName('ip')) + ->order($db->quoteName('cnt') . ' DESC') + ->setLimit($limit) + ); + + return $db->loadObjectList() ?: []; + } + + /** + * Get distinct rule names for the filter dropdown. + */ + public function getRuleNames(): array + { + $db = $this->getDatabase(); + $db->setQuery( + $db->getQuery(true) + ->select('DISTINCT ' . $db->quoteName('rule')) + ->from($db->quoteName('#__mokowaas_waf_log')) + ->order($db->quoteName('rule') . ' ASC') + ); + + return $db->loadColumn() ?: []; + } + + /** + * Delete logs older than N days. + */ + public function purgeLogs(int $days): array + { + try + { + $db = $this->getDatabase(); + $cutoff = Factory::getDate('-' . $days . ' days')->toSql(); + + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__mokowaas_waf_log')) + ->where($db->quoteName('created') . ' < ' . $db->quote($cutoff)) + )->execute(); + + $count = $db->getAffectedRows(); + + return ['success' => true, 'message' => "Purged {$count} log entries older than {$days} days."]; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Purge failed: ' . $e->getMessage()]; + } + } + + /** + * Add an IP to the firewall blocklist. + */ + public function banIp(string $ip, string $reason = 'Banned from WAF log'): array + { + try + { + $db = $this->getDatabase(); + + $query = $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('system')); + $db->setQuery($query); + + $params = new \Joomla\Registry\Registry($db->loadResult() ?? '{}'); + $blocklist = json_decode($params->get('ip_blocklist', '[]'), true) ?: []; + + // Check if already blocked + foreach ($blocklist as $entry) + { + if (($entry['ip'] ?? '') === $ip) + { + return ['success' => false, 'message' => $ip . ' is already blocked.']; + } + } + + $blocklist[] = ['ip' => $ip, 'enabled' => '1', 'label' => $reason]; + $params->set('ip_blocklist', json_encode($blocklist)); + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote($params->toString())) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('system')) + )->execute(); + + return ['success' => true, 'message' => $ip . ' has been added to the IP blocklist.']; + } + catch (\Throwable $e) + { + return ['success' => false, 'message' => 'Ban failed: ' . $e->getMessage()]; + } + } +} diff --git a/src/packages/com_mokowaas/admin/src/Service/NotificationService.php b/src/packages/com_mokowaas/admin/src/Service/NotificationService.php new file mode 100644 index 00000000..5f4ed16b --- /dev/null +++ b/src/packages/com_mokowaas/admin/src/Service/NotificationService.php @@ -0,0 +1,416 @@ +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, 'mokowaas'); + } + } + } + catch (\Throwable $e) + { + Log::add('Notification error: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); + } + } + + /** + * 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_mokowaas&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 MokoWaaS'; + + 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) + { + 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_mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + ); + + $params = json_decode($db->loadResult() ?? '{}', true); + + return $params['notifications'] ?? []; + } + catch (\Throwable $e) + { + return []; + } + } + + // ================================================================== + // 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 . ' | MokoWaaS 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, 'mokowaas'); + } + } + } + catch (\Throwable $e) + { + Log::add('Security alert error: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); + } + } +} diff --git a/src/packages/com_mokowaas/admin/src/View/Automation/HtmlView.php b/src/packages/com_mokowaas/admin/src/View/Automation/HtmlView.php new file mode 100644 index 00000000..01928e4a --- /dev/null +++ b/src/packages/com_mokowaas/admin/src/View/Automation/HtmlView.php @@ -0,0 +1,27 @@ +rules = $model->getAutomationRules(); + + ToolbarHelper::title('Automation Rules', 'cogs'); + ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas&view=tickets'); + + $wa = Factory::getApplication()->getDocument()->getWebAssetManager(); + $wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css'); + + parent::display($tpl); + } +} diff --git a/src/packages/com_mokowaas/admin/src/View/Canned/HtmlView.php b/src/packages/com_mokowaas/admin/src/View/Canned/HtmlView.php new file mode 100644 index 00000000..2a391df2 --- /dev/null +++ b/src/packages/com_mokowaas/admin/src/View/Canned/HtmlView.php @@ -0,0 +1,33 @@ +get('Joomla\Database\DatabaseInterface'); + + $db->setQuery('SELECT * FROM #__mokowaas_ticket_canned ORDER BY ordering ASC'); + $this->responses = $db->loadObjectList() ?: []; + + $db->setQuery('SELECT id, title FROM #__mokowaas_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_mokowaas&view=tickets'); + + $wa = Factory::getApplication()->getDocument()->getWebAssetManager(); + $wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css'); + + parent::display($tpl); + } +} diff --git a/src/packages/com_mokowaas/admin/src/View/Categories/HtmlView.php b/src/packages/com_mokowaas/admin/src/View/Categories/HtmlView.php new file mode 100644 index 00000000..bebffae8 --- /dev/null +++ b/src/packages/com_mokowaas/admin/src/View/Categories/HtmlView.php @@ -0,0 +1,41 @@ +get('Joomla\Database\DatabaseInterface'); + + $db->setQuery('SELECT * FROM #__mokowaas_ticket_categories ORDER BY ordering ASC'); + $this->categories = $db->loadObjectList() ?: []; + + // Get admin users for auto-assign dropdown + $db->setQuery( + $db->getQuery(true) + ->select([$db->quoteName('id'), $db->quoteName('name')]) + ->from($db->quoteName('#__users')) + ->where($db->quoteName('block') . ' = 0') + ->order($db->quoteName('name') . ' ASC') + ->setLimit(100) + ); + $this->users = $db->loadObjectList() ?: []; + + ToolbarHelper::title('Ticket Categories', 'folder'); + ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas&view=tickets'); + + $wa = Factory::getApplication()->getDocument()->getWebAssetManager(); + $wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css'); + + parent::display($tpl); + } +} diff --git a/src/packages/com_mokowaas/admin/src/View/Cleanup/HtmlView.php b/src/packages/com_mokowaas/admin/src/View/Cleanup/HtmlView.php new file mode 100644 index 00000000..14a9b44b --- /dev/null +++ b/src/packages/com_mokowaas/admin/src/View/Cleanup/HtmlView.php @@ -0,0 +1,27 @@ +dirs = $model->getCleanupInfo(); + + ToolbarHelper::title('Cache & Temp Cleanup', 'trash'); + ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas'); + + $wa = Factory::getApplication()->getDocument()->getWebAssetManager(); + $wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css'); + + parent::display($tpl); + } +} diff --git a/src/packages/com_mokowaas/admin/src/View/Dashboard/HtmlView.php b/src/packages/com_mokowaas/admin/src/View/Dashboard/HtmlView.php index 323febf5..b8b2f0db 100644 --- a/src/packages/com_mokowaas/admin/src/View/Dashboard/HtmlView.php +++ b/src/packages/com_mokowaas/admin/src/View/Dashboard/HtmlView.php @@ -35,6 +35,19 @@ class HtmlView extends BaseHtmlView $this->checkedOutItems = $model->getCheckedOutItems(); $this->wafBlocks = $model->getRecentWafBlocks(5); + // Check for importable Akeeba data + try + { + $importModel = new \Moko\Component\MokoWaaS\Administrator\Model\ImportModel(); + $this->adminToolsAvailable = $importModel->checkAdminToolsAvailable(); + $this->atsAvailable = $importModel->checkAtsAvailable(); + } + catch (\Throwable $e) + { + $this->adminToolsAvailable = null; + $this->atsAvailable = null; + } + $this->addToolbar(); $wa = Factory::getApplication()->getDocument()->getWebAssetManager(); diff --git a/src/packages/com_mokowaas/admin/src/View/Database/HtmlView.php b/src/packages/com_mokowaas/admin/src/View/Database/HtmlView.php new file mode 100644 index 00000000..6c91723d --- /dev/null +++ b/src/packages/com_mokowaas/admin/src/View/Database/HtmlView.php @@ -0,0 +1,27 @@ +tableData = $model->getTableStatus(); + + ToolbarHelper::title('Database Tools', 'database'); + ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas'); + + $wa = Factory::getApplication()->getDocument()->getWebAssetManager(); + $wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css'); + + parent::display($tpl); + } +} diff --git a/src/packages/com_mokowaas/admin/src/View/Htaccess/HtmlView.php b/src/packages/com_mokowaas/admin/src/View/Htaccess/HtmlView.php new file mode 100644 index 00000000..1a7dc9de --- /dev/null +++ b/src/packages/com_mokowaas/admin/src/View/Htaccess/HtmlView.php @@ -0,0 +1,47 @@ +getModel(); + + $this->options = $model->getOptions(); + $this->preview = $model->generateHtaccess($this->options); + $this->nginxPreview = $model->generateNginx($this->options); + $this->currentHtaccess = $model->readCurrentHtaccess(); + + $this->addToolbar(); + + $wa = Factory::getApplication()->getDocument()->getWebAssetManager(); + $wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css'); + + parent::display($tpl); + } + + protected function addToolbar(): void + { + ToolbarHelper::title(Text::_('COM_MOKOWAAS_HTACCESS_TITLE'), 'file-code'); + ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas'); + } +} diff --git a/src/packages/com_mokowaas/admin/src/View/Privacy/HtmlView.php b/src/packages/com_mokowaas/admin/src/View/Privacy/HtmlView.php new file mode 100644 index 00000000..b4d7e52d --- /dev/null +++ b/src/packages/com_mokowaas/admin/src/View/Privacy/HtmlView.php @@ -0,0 +1,39 @@ +getInput()->getString('filter_status', ''); + $this->requests = $model->getDataRequests($filterStatus); + $this->policies = $model->getRetentionPolicies(); + $this->summary = $model->getDashboardSummary(); + + $this->addToolbar(); + + $wa = Factory::getApplication()->getDocument()->getWebAssetManager(); + $wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css'); + + parent::display($tpl); + } + + protected function addToolbar(): void + { + ToolbarHelper::title('Privacy Guard', 'lock'); + ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas'); + } +} diff --git a/src/packages/com_mokowaas/admin/src/View/Ticket/HtmlView.php b/src/packages/com_mokowaas/admin/src/View/Ticket/HtmlView.php new file mode 100644 index 00000000..b4c00476 --- /dev/null +++ b/src/packages/com_mokowaas/admin/src/View/Ticket/HtmlView.php @@ -0,0 +1,53 @@ +getModel('Tickets'); + $id = Factory::getApplication()->getInput()->getInt('id', 0); + + $this->ticket = $model->getTicket($id); + $this->cannedResponses = $model->getCannedResponses((int) ($this->ticket->category_id ?? 0)); + + if (!$this->ticket) + { + Factory::getApplication()->enqueueMessage('Ticket not found.', 'error'); + Factory::getApplication()->redirect('index.php?option=com_mokowaas&view=tickets'); + + return; + } + + $this->addToolbar(); + + $wa = Factory::getApplication()->getDocument()->getWebAssetManager(); + $wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css'); + + parent::display($tpl); + } + + protected function addToolbar(): void + { + $title = $this->ticket ? 'Ticket #' . $this->ticket->id . ' — ' . $this->ticket->subject : 'Ticket'; + ToolbarHelper::title($title, 'headphones'); + ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas&view=tickets'); + } +} diff --git a/src/packages/com_mokowaas/admin/src/View/Tickets/HtmlView.php b/src/packages/com_mokowaas/admin/src/View/Tickets/HtmlView.php new file mode 100644 index 00000000..98cacb97 --- /dev/null +++ b/src/packages/com_mokowaas/admin/src/View/Tickets/HtmlView.php @@ -0,0 +1,56 @@ +getModel(); + $app = Factory::getApplication(); + + $filters = [ + 'status' => $app->getInput()->getString('filter_status', ''), + 'priority' => $app->getInput()->getString('filter_priority', ''), + 'category_id' => $app->getInput()->getInt('filter_category', 0), + ]; + + $this->tickets = $model->getTickets($filters); + $this->categories = $model->getCategories(); + $this->statusCounts = $model->getStatusCounts(); + $this->overdue = $model->getOverdueTickets(); + $this->atsAvailable = $model->checkAtsAvailable(); + + $this->addToolbar(); + + $wa = Factory::getApplication()->getDocument()->getWebAssetManager(); + $wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css'); + + parent::display($tpl); + } + + protected function addToolbar(): void + { + ToolbarHelper::title(Text::_('COM_MOKOWAAS_TICKETS_TITLE'), 'headphones'); + ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas'); + } +} diff --git a/src/packages/com_mokowaas/admin/src/View/Waflog/HtmlView.php b/src/packages/com_mokowaas/admin/src/View/Waflog/HtmlView.php new file mode 100644 index 00000000..e1f73a9c --- /dev/null +++ b/src/packages/com_mokowaas/admin/src/View/Waflog/HtmlView.php @@ -0,0 +1,55 @@ +getInput(); + + $this->filters = [ + 'rule' => $input->getString('filter_rule', ''), + 'ip' => $input->getString('filter_ip', ''), + 'search' => $input->getString('filter_search', ''), + 'date_from' => $input->getString('filter_date_from', ''), + 'date_to' => $input->getString('filter_date_to', ''), + ]; + + $page = max(1, $input->getInt('page', 1)); + $limit = 50; + $offset = ($page - 1) * $limit; + + $this->logs = $model->getLogs($this->filters, $limit, $offset); + $this->total = $model->getTotal($this->filters); + $this->ruleCounts = $model->getRuleCounts(); + $this->topIps = $model->getTopIps(10); + $this->ruleNames = $model->getRuleNames(); + + $this->addToolbar(); + + $wa = Factory::getApplication()->getDocument()->getWebAssetManager(); + $wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css'); + + parent::display($tpl); + } + + protected function addToolbar(): void + { + ToolbarHelper::title('WAF Log Viewer', 'shield-alt'); + ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas'); + } +} diff --git a/src/packages/com_mokowaas/admin/tmpl/automation/default.php b/src/packages/com_mokowaas/admin/tmpl/automation/default.php new file mode 100644 index 00000000..e9fd493d --- /dev/null +++ b/src/packages/com_mokowaas/admin/tmpl/automation/default.php @@ -0,0 +1,141 @@ +rules; +$token = Session::getFormToken(); +$saveUrl = Route::_('index.php?option=com_mokowaas&task=display.saveAutomation&format=json'); +$deleteUrl = Route::_('index.php?option=com_mokowaas&task=display.deleteAutomation&format=json'); +$toggleUrl = Route::_('index.php?option=com_mokowaas&task=display.toggleAutomation&format=json'); + +$triggerLabels = ['ticket_created' => 'On Ticket Created', 'ticket_replied' => 'On Reply', 'status_changed' => 'On Status Change', 'scheduled' => 'Scheduled (Cron)']; +?> + +
+
+

Automation Rules

+ +
+ + + conditions, true) ?: []; $actions = json_decode($r->actions, true) ?: []; ?> +
+
+
+
+
+
+ enabled ? 'checked' : ''; ?>> +
+ title); ?> + trigger_event] ?? $r->trigger_event; ?> +
+
+ IF + $c): ?> + 0 ? ' AND ' : ''; ?> + + THEN + + = + +
+
+ +
+
+
+ + + +
No automation rules. Click "Add Rule" to create one.
+ +
+ + + + + diff --git a/src/packages/com_mokowaas/admin/tmpl/canned/default.php b/src/packages/com_mokowaas/admin/tmpl/canned/default.php new file mode 100644 index 00000000..49a2bb31 --- /dev/null +++ b/src/packages/com_mokowaas/admin/tmpl/canned/default.php @@ -0,0 +1,107 @@ +responses; +$categories = $this->categories; +$token = Session::getFormToken(); +$saveUrl = Route::_('index.php?option=com_mokowaas&task=display.saveCanned&format=json'); +$deleteUrl = Route::_('index.php?option=com_mokowaas&task=display.deleteCanned&format=json'); +?> + +
+
+

Canned Responses

+ +
+ + +
+
+
+
+ title); ?> +

body, 0, 150)); ?>

+
+ +
+
+
+ + + +
No canned responses yet. Click "Add Response" to create one.
+ +
+ + + + + diff --git a/src/packages/com_mokowaas/admin/tmpl/categories/default.php b/src/packages/com_mokowaas/admin/tmpl/categories/default.php new file mode 100644 index 00000000..d52cb7ac --- /dev/null +++ b/src/packages/com_mokowaas/admin/tmpl/categories/default.php @@ -0,0 +1,126 @@ +categories; +$users = $this->users; +$token = Session::getFormToken(); +$saveUrl = Route::_('index.php?option=com_mokowaas&task=display.saveCategory&format=json'); +$deleteUrl = Route::_('index.php?option=com_mokowaas&task=display.deleteCategory&format=json'); +?> + +
+
+

Categories

+ +
+ +
+
+ + + + + + + + + + + + + + +
TitleSLA ResponseSLA ResolutionAuto-AssignActive
min min + + + published ? 'checked' : ''; ?>> + + + +
+
+
+
+ + diff --git a/src/packages/com_mokowaas/admin/tmpl/cleanup/default.php b/src/packages/com_mokowaas/admin/tmpl/cleanup/default.php new file mode 100644 index 00000000..073c623d --- /dev/null +++ b/src/packages/com_mokowaas/admin/tmpl/cleanup/default.php @@ -0,0 +1,63 @@ +dirs; +$token = Session::getFormToken(); +$cleanUrl = Route::_('index.php?option=com_mokowaas&task=display.cleanDirectory&format=json'); + +$dirKeys = ['site_cache', 'admin_cache', 'tmp', 'logs']; +$totalMb = 0; +$totalFiles = 0; +foreach ($dirs as $d) { $totalMb += $d->size_mb; $totalFiles += $d->files; } +?> + +
+
+
MBTotal Size
+
Total Files
+
+ +
+ $d): ?> +
+
+
+
label); ?>
+

size_mb, 1); ?> MB

+

files); ?> files

+ writable): ?> + Not writable + + + +
+
+
+ +
+
+ + diff --git a/src/packages/com_mokowaas/admin/tmpl/dashboard/default.php b/src/packages/com_mokowaas/admin/tmpl/dashboard/default.php index c8922884..186ad4f0 100644 --- a/src/packages/com_mokowaas/admin/tmpl/dashboard/default.php +++ b/src/packages/com_mokowaas/admin/tmpl/dashboard/default.php @@ -19,6 +19,8 @@ $siteInfo = $this->siteInfo; $plugins = $this->plugins; $recentLogins = $this->recentLogins; $pendingUpdates = $this->pendingUpdates; +$adminToolsAvail = $this->adminToolsAvailable ?? null; +$atsAvail = $this->atsAvailable ?? null; $checkedOut = $this->checkedOutItems; $wafBlocks = $this->wafBlocks; $token = Session::getFormToken(); @@ -63,29 +65,93 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api']; offline): ?> +
+ + escape($_SERVER['REMOTE_ADDR'] ?? ''); ?> +
+ + +
+ + Akeeba data detected — import into MokoWaaS: + + + + + + +
+ +
-
+
-
+ - @@ -122,6 +188,10 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
protected): ?> + configure_only): ?> + + enabled ? Text::_('COM_MOKOWAAS_ENABLED') : Text::_('COM_MOKOWAAS_DISABLED'); ?> +
tableData; +$tables = $data['tables'] ?? []; +$token = Session::getFormToken(); +$optimizeUrl = Route::_('index.php?option=com_mokowaas&task=display.optimizeDb&format=json'); +$repairUrl = Route::_('index.php?option=com_mokowaas&task=display.repairDb&format=json'); +$purgeUrl = Route::_('index.php?option=com_mokowaas&task=display.purgeSessions&format=json'); +?> + +
+
+
Tables
+
MBTotal Size
+
KBOverhead
+
+
+ + + +
+
+
+ +
+
+ + + + + + + + + + + + + +
TableEngineRowsSizeOverhead
name); ?>engine); ?>rows); ?>size_mb; ?> MBoverhead_kb > 0 ? $t->overhead_kb . ' KB' : '—'; ?>
+
+
+
+ + diff --git a/src/packages/com_mokowaas/admin/tmpl/htaccess/default.php b/src/packages/com_mokowaas/admin/tmpl/htaccess/default.php new file mode 100644 index 00000000..79155233 --- /dev/null +++ b/src/packages/com_mokowaas/admin/tmpl/htaccess/default.php @@ -0,0 +1,306 @@ +options; +$preview = $this->preview; +$nginx = $this->nginxPreview; +$current = $this->currentHtaccess; +$token = Session::getFormToken(); +$saveUrl = Route::_('index.php?option=com_mokowaas&task=display.saveHtaccess&format=json'); +$genUrl = Route::_('index.php?option=com_mokowaas&task=display.generateHtaccess&format=json'); + +// Helper for toggle switch +$sw = function($name, $label, $desc = '') use ($opts) { + $checked = !empty($opts[$name]) ? 'checked' : ''; + echo '
'; + echo '
' . htmlspecialchars($label) . ''; + if ($desc) echo '
' . htmlspecialchars($desc) . ''; + echo '
'; + echo '
'; + echo ''; + echo '
'; +}; +?> + +
+ + +
+ +
+
+ +
+ +
+
Security
+
+ + + + + + + + + +
+ + +
+ + +
+
+
+ + +
+
+
+ > + +
+
+
+
+ + +
+ +
+ + +
+ +
+
+
+ +
+
Performance
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ +
+
SEO / Redirects
+
+
+ + +
+ + +
+
+ +
+
Custom Rules
+
+ +
+
+
+ + +
+
+
+ Preview + lines +
+
+ +
+ +
+
+
+
+ + +
+
+
NginX Configuration Snippet
+
+ +
+ +
+
+ + +
+
+
Current .htaccess on Disk
+
+ +
+
+
+
+
+ + diff --git a/src/packages/com_mokowaas/admin/tmpl/privacy/default.php b/src/packages/com_mokowaas/admin/tmpl/privacy/default.php new file mode 100644 index 00000000..0b6f6f72 --- /dev/null +++ b/src/packages/com_mokowaas/admin/tmpl/privacy/default.php @@ -0,0 +1,184 @@ +requests; +$policies = $this->policies; +$summary = $this->summary; +$token = Session::getFormToken(); + +$statusBadge = [ + 'pending' => 'bg-warning text-dark', + 'processing' => 'bg-info', + 'completed' => 'bg-success', + 'denied' => 'bg-secondary', +]; +$typeBadge = [ + 'export' => 'bg-primary', + 'delete' => 'bg-danger', + 'anonymize' => 'bg-warning text-dark', +]; +?> + +
+ +
+
+
+ pending_requests; ?> + Pending Requests +
+
+
+
+ total_requests; ?> + Total Requests +
+
+
+
+ consent_entries; ?> + Consent Entries +
+
+
+
+ policies_active; ?> + Active Policies +
+
+
+ +
+ +
+
+
+ Data Subject Requests +
+ + + +
+
+ +
No data requests found.
+ +
+ + + + + + + + + + + + + + + +
#UserTypeStatusCreatedProcessedActions
id; ?>escape($r->user_name ?? ''); ?>
escape($r->user_email ?? ''); ?>
type); ?>status); ?>created, 'M d, Y H:i'); ?>processed ? HTMLHelper::_('date', $r->processed, 'M d, Y H:i') : '—'; ?> + status === 'pending'): ?> +
+ + +
+ status === 'completed' && $r->type === 'export'): ?> + + +
+
+ +
+
+ + +
+
+
Retention Policies
+
+ + + + + + + + + + + + +
TypeDaysActionActive
escape($p->content_type); ?>retention_days; ?>action; ?>enabled ? 'Yes' : 'No'; ?>
+
+
+
+
+
+ + diff --git a/src/packages/com_mokowaas/admin/tmpl/ticket/default.php b/src/packages/com_mokowaas/admin/tmpl/ticket/default.php new file mode 100644 index 00000000..e7ceb8ee --- /dev/null +++ b/src/packages/com_mokowaas/admin/tmpl/ticket/default.php @@ -0,0 +1,198 @@ +ticket; +$canned = $this->cannedResponses; +$token = Session::getFormToken(); + +$statusBadge = [ + 'open' => 'bg-primary', 'in_progress' => 'bg-info', + 'waiting' => 'bg-warning text-dark', 'resolved' => 'bg-success', 'closed' => 'bg-secondary', +]; +$priorityBadge = [ + 'low' => 'bg-secondary', 'normal' => 'bg-primary', 'high' => 'bg-warning text-dark', 'urgent' => 'bg-danger', +]; +?> + +
+ +
+ +
+
+
+ escape($t->created_by_name); ?> + created, 'M d, Y H:i'); ?> +
+ Original +
+
escape($t->body)); ?>
+
+ + + replies as $reply): ?> +
+
+
+ escape($reply->user_name ?? 'System'); ?> + created, 'M d, Y H:i'); ?> +
+ is_internal): ?> + Internal Note + +
+
escape($reply->body)); ?>
+
+ + + +
+
Reply
+
+ +
+ +
+ + +
+ + +
+
+
+
+ + +
+
+
Details
+
+ + + + + + + + resolved): ?> + closed): ?> + +
Statusstatus)); ?>
Prioritypriority); ?>
Categoryescape($t->category_title ?? '—'); ?>
Created Byescape($t->created_by_name); ?>
escape($t->created_by_email ?? ''); ?>
Assigned Toescape($t->assigned_to_name ?? 'Unassigned'); ?>
Createdcreated, 'M d, Y H:i'); ?>
Resolvedresolved, 'M d, Y H:i'); ?>
Closedclosed, 'M d, Y H:i'); ?>
Repliesreply_count; ?>
+
+
+ + + sla_response_due || $t->sla_resolution_due): ?> +
+
SLA
+
+ sla_response_due): ?> +
+ Response Due
+ sla_responded && strtotime($t->sla_response_due) < time(); + ?> + + sla_responded ? 'Responded' : HTMLHelper::_('date', $t->sla_response_due, 'M d H:i'); ?> + + +
+ + sla_resolution_due): ?> +
+ Resolution Due
+ status, ['resolved','closed']) && strtotime($t->sla_resolution_due) < time(); + ?> + + status, ['resolved','closed']) ? 'Met' : HTMLHelper::_('date', $t->sla_resolution_due, 'M d H:i'); ?> + + +
+ +
+
+ + + +
+
Actions
+
+ 'Reopen', 'in_progress' => 'In Progress', 'waiting' => 'Waiting', 'resolved' => 'Resolve', 'closed' => 'Close'] as $s => $label): ?> + status): ?> + + + +
+
+
+
+ + diff --git a/src/packages/com_mokowaas/admin/tmpl/tickets/default.php b/src/packages/com_mokowaas/admin/tmpl/tickets/default.php new file mode 100644 index 00000000..33c7f502 --- /dev/null +++ b/src/packages/com_mokowaas/admin/tmpl/tickets/default.php @@ -0,0 +1,291 @@ +tickets; +$categories = $this->categories; +$counts = $this->statusCounts; +$overdue = $this->overdue; +$atsAvailable = $this->atsAvailable; +$token = Session::getFormToken(); + +$statusBadge = [ + 'open' => 'bg-primary', + 'in_progress' => 'bg-info', + 'waiting' => 'bg-warning text-dark', + 'resolved' => 'bg-success', + 'closed' => 'bg-secondary', +]; + +$priorityBadge = [ + 'low' => 'bg-secondary', + 'normal' => 'bg-primary', + 'high' => 'bg-warning text-dark', + 'urgent' => 'bg-danger', +]; +?> + +
+ +
+
open; ?>Open
+
in_progress; ?>In Progress
+
waiting; ?>Waiting
+
resolved; ?>Resolved
+
closed; ?>Closed
+ 0): ?> +
SLA Overdue
+ +
+ + +
+
+ + + + +
+
+ + + + +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + sla_response_due && !$t->sla_responded && strtotime($t->sla_response_due) < $now) $slaClass = 'table-danger'; + elseif ($t->sla_resolution_due && strtotime($t->sla_resolution_due) < $now && !\in_array($t->status, ['resolved','closed'])) $slaClass = 'table-danger'; + elseif ($t->sla_response_due && !$t->sla_responded && strtotime($t->sla_response_due) < $now + 3600) $slaClass = 'table-warning'; + ?> + + + + + + + + + + + + + + +
#SubjectStatusPriorityCategoryCreated ByAssigned ToCreatedSLA
No tickets found.
id; ?>escape(mb_substr($t->subject, 0, 60)); ?>status)); ?>priority); ?>escape($t->category_title ?? '—'); ?>escape($t->created_by_name ?? ''); ?>assigned_to_name ? $this->escape($t->assigned_to_name) : 'Unassigned'; ?>created, 'M d H:i'); ?> + sla_response_due && !$t->sla_responded): ?> + sla_response_due, 'M d H:i'); ?> + sla_resolution_due): ?> + sla_resolution_due, 'M d H:i'); ?> + +
+
+
+
+ + + + + diff --git a/src/packages/com_mokowaas/admin/tmpl/waflog/default.php b/src/packages/com_mokowaas/admin/tmpl/waflog/default.php new file mode 100644 index 00000000..4fab7ab2 --- /dev/null +++ b/src/packages/com_mokowaas/admin/tmpl/waflog/default.php @@ -0,0 +1,212 @@ +logs; +$ruleCounts = $this->ruleCounts; +$topIps = $this->topIps; +$ruleNames = $this->ruleNames; +$total = $this->total; +$filters = $this->filters; +$token = Session::getFormToken(); +$input = Factory::getApplication()->getInput(); +$page = max(1, $input->getInt('page', 1)); +$totalPages = max(1, ceil($total / 50)); + +$ruleBadge = [ + 'sqli' => 'bg-danger', 'xss' => 'bg-danger', 'mua' => 'bg-warning text-dark', + 'rfi' => 'bg-danger', 'dfi' => 'bg-danger', 'blocked_file' => 'bg-info', + 'blocked_php' => 'bg-info', 'tmpl_switch' => 'bg-secondary', + 'ip_blocklist' => 'bg-dark', 'admin_secret' => 'bg-dark', +]; +?> + +
+ +
+ +
+ rule); ?> + cnt); ?> +
+ +
+ Total + +
+
+ +
+ +
+ +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + Reset +
+
+
+
+ + +
+
+ blocked requests + +
+
+ + + + + + + + + + + + + + + + + + + + + +
TimeIPRuleURIDetailUser Agent
No blocked requests found.
created, 'M d H:i:s'); ?>ip); ?>rule); ?>uri, 0, 60)); ?>detail, 0, 50)); ?>user_agent, 0, 40)); ?> + +
+
+ + 1): ?> + + +
+
+ + +
+
+
Top Blocked IPs
+
+ + + + + + + + + + + + +
IPBlocksLast
ip); ?>cnt; ?>last_seen, 'M d'); ?> + +
+
+
+
+
+
+ + diff --git a/src/packages/com_mokowaas/media/js/dashboard.js b/src/packages/com_mokowaas/media/js/dashboard.js index df8433ed..e6aa671c 100644 --- a/src/packages/com_mokowaas/media/js/dashboard.js +++ b/src/packages/com_mokowaas/media/js/dashboard.js @@ -109,4 +109,26 @@ document.addEventListener('DOMContentLoaded', function () { }); }); } + + // Akeeba import buttons + ['btn-import-admintools', 'btn-import-ats-dash'].forEach(function(id) { + var btn = document.getElementById(id); + if (!btn) return; + btn.addEventListener('click', function() { + var el = this; + if (!confirm('Import Akeeba data into MokoWaaS? Akeeba extensions will be disabled after import.')) return; + el.disabled = true; + var origText = el.textContent; + el.textContent = ' Importing...'; + var fd = new FormData(); + fd.append(el.dataset.token, '1'); + fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}}) + .then(function(r){return r.json()}) + .then(function(d){ + if (d.success) { Joomla.renderMessages({message:[d.message]}); setTimeout(function(){location.reload()}, 2000); } + else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; el.textContent = origText; } + }) + .catch(function(){ Joomla.renderMessages({error:['Network error']}); el.disabled = false; el.textContent = origText; }); + }); + }); }); diff --git a/src/packages/com_mokowaas/mokowaas.xml b/src/packages/com_mokowaas/mokowaas.xml index 803387a9..8313f8c0 100644 --- a/src/packages/com_mokowaas/mokowaas.xml +++ b/src/packages/com_mokowaas/mokowaas.xml @@ -20,21 +20,52 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.33.00 + 02.32.52 MokoWaaS admin dashboard and REST API. Provides a control panel for managing MokoWaaS feature plugins, site health monitoring, and remote management endpoints. Moko\Component\MokoWaaS MokoWaaS + + COM_MOKOWAAS_MENU_DASHBOARD + COM_MOKOWAAS_MENU_EXTENSIONS + COM_MOKOWAAS_MENU_TICKETS + COM_MOKOWAAS_MENU_HTACCESS + COM_MOKOWAAS_MENU_PRIVACY + COM_MOKOWAAS_MENU_WAFLOG + COM_MOKOWAAS_MENU_DATABASE + COM_MOKOWAAS_MENU_CLEANUP + COM_MOKOWAAS_MENU_PLUGINS + COM_MOKOWAAS_MENU_UPDATES + COM_MOKOWAAS_MENU_CHECKIN + COM_MOKOWAAS_MENU_CACHE + + access.xml + config.xml language services + sql src tmpl + + en-GB/com_mokowaas.sys.ini + + + language + services + src + tmpl + + + + admin/sql/install.mysql.sql + + src diff --git a/src/packages/com_mokowaas/site/language/en-GB/com_mokowaas.ini b/src/packages/com_mokowaas/site/language/en-GB/com_mokowaas.ini new file mode 100644 index 00000000..3047c85e --- /dev/null +++ b/src/packages/com_mokowaas/site/language/en-GB/com_mokowaas.ini @@ -0,0 +1,11 @@ +; MokoWaaS Customer Portal - Language Strings +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GPL-3.0-or-later + +COM_MOKOWAAS_PORTAL_TITLE="Support Portal" +COM_MOKOWAAS_PORTAL_MY_TICKETS="My Support Tickets" +COM_MOKOWAAS_PORTAL_NEW_TICKET="New Ticket" +COM_MOKOWAAS_PORTAL_SUBMIT="Submit Ticket" +COM_MOKOWAAS_PORTAL_REPLY="Send Reply" +COM_MOKOWAAS_PORTAL_NO_TICKETS="You haven't submitted any support tickets yet." +COM_MOKOWAAS_PORTAL_LOGIN_REQUIRED="Please log in to access the support portal." diff --git a/src/packages/com_mokowaas/site/services/provider.php b/src/packages/com_mokowaas/site/services/provider.php new file mode 100644 index 00000000..cb74ca34 --- /dev/null +++ b/src/packages/com_mokowaas/site/services/provider.php @@ -0,0 +1,38 @@ +registerServiceProvider(new MVCFactory('\\Moko\\Component\\MokoWaaS')); + $container->registerServiceProvider(new ComponentDispatcherFactory('\\Moko\\Component\\MokoWaaS')); + + $container->set( + ComponentInterface::class, + function (Container $container) { + $component = new \Joomla\CMS\Extension\MVCComponent( + $container->get(ComponentDispatcherFactoryInterface::class) + ); + $component->setMVCFactory($container->get(MVCFactoryInterface::class)); + + return $component; + } + ); + } +}; diff --git a/src/packages/com_mokowaas/site/src/Controller/DisplayController.php b/src/packages/com_mokowaas/site/src/Controller/DisplayController.php new file mode 100644 index 00000000..1018e9eb --- /dev/null +++ b/src/packages/com_mokowaas/site/src/Controller/DisplayController.php @@ -0,0 +1,267 @@ +getIdentity(); + + if ($user->guest) + { + Factory::getApplication()->enqueueMessage('Please log in to access the support portal.', 'warning'); + Factory::getApplication()->redirect(Route::_( + 'index.php?option=com_users&view=login&return=' . base64_encode('index.php?option=com_mokowaas&view=tickets'), + false + )); + + return; + } + + return parent::display($cachable, $urlparams); + } + + /** + * Submit a new ticket. + */ + public function submitTicket() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + $user = Factory::getApplication()->getIdentity(); + + if ($user->guest) + { + $this->jsonResponse(['success' => false, 'message' => 'Please log in.']); + return; + } + + $input = Factory::getApplication()->getInput(); + + // Use admin TicketsModel + $model = $this->getModel('Tickets', 'Administrator'); + + $this->jsonResponse($model->createTicket([ + 'subject' => $input->getString('subject', ''), + 'body' => $input->getRaw('body', ''), + 'priority' => $input->getString('priority', 'normal'), + 'category_id' => $input->getInt('category_id', 0), + ])); + } + + /** + * Submit a reply. + */ + public function submitReply() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + $user = Factory::getApplication()->getIdentity(); + $input = Factory::getApplication()->getInput(); + + if ($user->guest) + { + $this->jsonResponse(['success' => false, 'message' => 'Please log in.']); + return; + } + + $ticketId = $input->getInt('ticket_id', 0); + $model = $this->getModel('Tickets', 'Administrator'); + $ticket = $model->getTicket($ticketId); + + if (!$ticket) + { + $this->jsonResponse(['success' => false, 'message' => 'Ticket not found.']); + return; + } + + // Customers can only reply to their own tickets; staff can reply to any + if ((int) $ticket->created_by !== $user->id && !$this->isStaff($user)) + { + $this->jsonResponse(['success' => false, 'message' => 'Access denied.']); + return; + } + + // Staff replies from frontend are not internal notes + $this->jsonResponse($model->addReply( + $ticketId, + $input->getRaw('body', ''), + false + )); + } + + /** + * Update ticket status (staff/manager only from frontend). + */ + public function updateStatus() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + $user = Factory::getApplication()->getIdentity(); + + if (!$this->isStaff($user)) + { + $this->jsonResponse(['success' => false, 'message' => 'Access denied.']); + return; + } + + $input = Factory::getApplication()->getInput(); + $model = $this->getModel('Tickets', 'Administrator'); + + $this->jsonResponse($model->updateStatus( + $input->getInt('ticket_id', 0), + $input->getString('status', '') + )); + } + + /** + * Assign a ticket (manager only from frontend). + */ + public function assignTicket() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + $user = Factory::getApplication()->getIdentity(); + + if (!$user->authorise('mokowaas.tickets.assign', 'com_mokowaas')) + { + $this->jsonResponse(['success' => false, 'message' => 'Access denied.']); + return; + } + + $input = Factory::getApplication()->getInput(); + $ticketId = $input->getInt('ticket_id', 0); + $assignTo = $input->getInt('assigned_to', 0); + + try + { + $db = Factory::getDbo(); + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokowaas_tickets')) + ->set($db->quoteName('assigned_to') . ' = ' . ($assignTo ?: 'NULL')) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . $ticketId) + )->execute(); + + $this->jsonResponse(['success' => true, 'message' => 'Ticket assigned.']); + } + catch (\Throwable $e) + { + $this->jsonResponse(['success' => false, 'message' => $e->getMessage()]); + return; + } + } + + /** + * Submit a data privacy request from frontend. + */ + public function submitDataRequest() + { + Session::checkToken() or die(Text::_('JINVALID_TOKEN')); + + $user = Factory::getApplication()->getIdentity(); + + if ($user->guest) + { + $this->jsonResponse(['success' => false, 'message' => 'Please log in.']); + return; + } + + $type = Factory::getApplication()->getInput()->getString('type', ''); + $model = new \Moko\Component\MokoWaaS\Administrator\Model\PrivacyModel(); + + $this->jsonResponse($model->createRequest($user->id, $type, 'Submitted via self-service portal')); + } + + /** + * Check if user is support staff (can manage tickets beyond their own). + */ + private function isStaff($user): bool + { + if ($user->guest) + { + return false; + } + + // Super admins always staff + if ($user->authorise('core.admin')) + { + return true; + } + + // Anyone with mokowaas.tickets ACL on the component is staff + return $user->authorise('mokowaas.tickets', 'com_mokowaas'); + } + + /** + * Search KB articles via Smart Search (com_finder). + */ + public function searchKb() + { + $query = Factory::getApplication()->getInput()->getString('q', ''); + + if (strlen($query) < 3) + { + $this->jsonResponse(['results' => []]); + } + + try + { + $db = Factory::getDbo(); + $escaped = $db->quote('%' . $db->escape($query, true) . '%'); + + $results = $db->setQuery( + $db->getQuery(true) + ->select([ + $db->quoteName('l.link_id'), + $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() ?: []; + + foreach ($results as $r) + { + $r->description = mb_substr(strip_tags($r->description ?? ''), 0, 150); + } + + $this->jsonResponse(['results' => $results]); + } + catch (\Throwable $e) + { + $this->jsonResponse(['results' => []]); + } + } + + private function jsonResponse(array $data): void + { + $app = Factory::getApplication(); + $app->setHeader('Content-Type', 'application/json'); + echo json_encode($data); + $app->close(); + } +} diff --git a/src/packages/com_mokowaas/site/src/View/Privacy/HtmlView.php b/src/packages/com_mokowaas/site/src/View/Privacy/HtmlView.php new file mode 100644 index 00000000..a6b70082 --- /dev/null +++ b/src/packages/com_mokowaas/site/src/View/Privacy/HtmlView.php @@ -0,0 +1,68 @@ +getIdentity(); + + if ($user->guest) + { + Factory::getApplication()->redirect(Route::_( + 'index.php?option=com_users&view=login&return=' . base64_encode('index.php?option=com_mokowaas&view=privacy'), + false + )); + + return; + } + + $db = Factory::getContainer()->get('Joomla\Database\DatabaseInterface'); + + // Get user's data requests + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokowaas_data_requests')) + ->where($db->quoteName('user_id') . ' = ' . (int) $user->id) + ->order($db->quoteName('created') . ' DESC'); + + try + { + $db->setQuery($query); + $this->requests = $db->loadObjectList() ?: []; + } + catch (\Throwable $e) + { + $this->requests = []; + } + + // Get consent history + try + { + $db->setQuery( + $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokowaas_consent_log')) + ->where($db->quoteName('user_id') . ' = ' . (int) $user->id) + ->order($db->quoteName('created') . ' DESC') + ->setLimit(20) + ); + $this->consent = $db->loadObjectList() ?: []; + } + catch (\Throwable $e) + { + $this->consent = []; + } + + parent::display($tpl); + } +} diff --git a/src/packages/com_mokowaas/site/src/View/Ticket/HtmlView.php b/src/packages/com_mokowaas/site/src/View/Ticket/HtmlView.php new file mode 100644 index 00000000..4a4289e6 --- /dev/null +++ b/src/packages/com_mokowaas/site/src/View/Ticket/HtmlView.php @@ -0,0 +1,84 @@ +get('Joomla\Database\DatabaseInterface'); + $user = Factory::getApplication()->getIdentity(); + $id = Factory::getApplication()->getInput()->getInt('id', 0); + + $this->isStaff = $user->authorise('core.admin') || $user->authorise('mokowaas.tickets', 'com_mokowaas'); + $this->canAssign = $user->authorise('core.admin') || $user->authorise('mokowaas.tickets.assign', 'com_mokowaas'); + + // 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('#__mokowaas_tickets', 't')) + ->leftJoin($db->quoteName('#__mokowaas_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_mokowaas&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('#__mokowaas_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/src/packages/com_mokowaas/site/src/View/Tickets/HtmlView.php b/src/packages/com_mokowaas/site/src/View/Tickets/HtmlView.php new file mode 100644 index 00000000..5988fba9 --- /dev/null +++ b/src/packages/com_mokowaas/site/src/View/Tickets/HtmlView.php @@ -0,0 +1,75 @@ +get('Joomla\Database\DatabaseInterface'); + $user = Factory::getApplication()->getIdentity(); + + $this->isStaff = $user->authorise('core.admin') + || $user->authorise('mokowaas.tickets', 'com_mokowaas'); + + // 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('#__mokowaas_tickets', 't')) + ->leftJoin($db->quoteName('#__mokowaas_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('#__mokowaas_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/src/packages/com_mokowaas/site/tmpl/privacy/default.php b/src/packages/com_mokowaas/site/tmpl/privacy/default.php new file mode 100644 index 00000000..f26b4e6a --- /dev/null +++ b/src/packages/com_mokowaas/site/tmpl/privacy/default.php @@ -0,0 +1,114 @@ +getIdentity(); +$requests = $this->requests; +$consent = $this->consent; +$token = Session::getFormToken(); + +$statusLabel = ['pending' => 'Pending', 'processing' => 'Processing', 'completed' => 'Completed', 'denied' => 'Denied']; +$statusClass = ['pending' => 'warning', 'processing' => 'info', 'completed' => 'success', 'denied' => 'secondary']; +?> + +
+

My Privacy & Data

+

Manage your personal data, download your information, or request account deletion.

+ + +
+
+ +
+
+ +
+
+ +
+
+ + + +
+
My Data Requests
+
+ + + + + + + + + + + + +
TypeStatusSubmittedProcessed
type); ?>status] ?? $r->status; ?>created, 'M d, Y H:i'); ?>processed ? HTMLHelper::_('date', $r->processed, 'M d, Y H:i') : '—'; ?>
+
+
+ + + + +
+
Consent History
+
+ + + + + + + + + + + +
CategoryActionDate
category))); ?>action); ?>created, 'M d, Y H:i'); ?>
+
+
+ +
+ + diff --git a/src/packages/com_mokowaas/site/tmpl/ticket/default.php b/src/packages/com_mokowaas/site/tmpl/ticket/default.php new file mode 100644 index 00000000..7f84e579 --- /dev/null +++ b/src/packages/com_mokowaas/site/tmpl/ticket/default.php @@ -0,0 +1,241 @@ +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/src/packages/com_mokowaas/site/tmpl/tickets/default.php b/src/packages/com_mokowaas/site/tmpl/tickets/default.php new file mode 100644 index 00000000..8ed9e1a3 --- /dev/null +++ b/src/packages/com_mokowaas/site/tmpl/tickets/default.php @@ -0,0 +1,83 @@ +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/src/packages/com_mokowaas/site/tmpl/tickets/submit.php b/src/packages/com_mokowaas/site/tmpl/tickets/submit.php new file mode 100644 index 00000000..cc5da1b8 --- /dev/null +++ b/src/packages/com_mokowaas/site/tmpl/tickets/submit.php @@ -0,0 +1,204 @@ +categories; +$token = Session::getFormToken(); +$searchUrl = Route::_('index.php?option=com_mokowaas&task=display.searchKb&format=json'); +$submitUrl = Route::_('index.php?option=com_mokowaas&task=display.submitTicket&format=json'); +$ticketUrl = Route::_('index.php?option=com_mokowaas&view=ticket&id='); +$ticketsUrl = Route::_('index.php?option=com_mokowaas&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/src/packages/mod_mokowaas_cache/language/en-GB/mod_mokowaas_cache.ini b/src/packages/mod_mokowaas_cache/language/en-GB/mod_mokowaas_cache.ini new file mode 100644 index 00000000..dd3acea8 --- /dev/null +++ b/src/packages/mod_mokowaas_cache/language/en-GB/mod_mokowaas_cache.ini @@ -0,0 +1,3 @@ +MOD_MOKOWAAS_CACHE="MokoWaaS Cache Cleaner" +MOD_MOKOWAAS_CACHE_DESC="One-click cache cleaner in the admin status bar. Clears all Joomla cache (site, admin, and expired)." +MOD_MOKOWAAS_CACHE_CLEAR_ALL="Clear All Cache" diff --git a/src/packages/mod_mokowaas_cache/language/en-GB/mod_mokowaas_cache.sys.ini b/src/packages/mod_mokowaas_cache/language/en-GB/mod_mokowaas_cache.sys.ini new file mode 100644 index 00000000..25f62d28 --- /dev/null +++ b/src/packages/mod_mokowaas_cache/language/en-GB/mod_mokowaas_cache.sys.ini @@ -0,0 +1,2 @@ +MOD_MOKOWAAS_CACHE="MokoWaaS Cache Cleaner" +MOD_MOKOWAAS_CACHE_DESC="One-click cache cleaner in the admin status bar. Clears all Joomla cache (site, admin, and expired)." diff --git a/src/packages/mod_mokowaas_cache/mod_mokowaas_cache.xml b/src/packages/mod_mokowaas_cache/mod_mokowaas_cache.xml new file mode 100644 index 00000000..6364ea8c --- /dev/null +++ b/src/packages/mod_mokowaas_cache/mod_mokowaas_cache.xml @@ -0,0 +1,24 @@ + + + mod_mokowaas_cache + Moko Consulting + 2026-06-04 + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + hello@mokoconsulting.tech + https://mokoconsulting.tech + 02.32.52 + MOD_MOKOWAAS_CACHE_DESC + Moko\Module\MokoWaaSCache + + + services + src + tmpl + + + + en-GB/mod_mokowaas_cache.ini + en-GB/mod_mokowaas_cache.sys.ini + + diff --git a/src/packages/mod_mokowaas_cache/services/provider.php b/src/packages/mod_mokowaas_cache/services/provider.php new file mode 100644 index 00000000..cf5c25c4 --- /dev/null +++ b/src/packages/mod_mokowaas_cache/services/provider.php @@ -0,0 +1,23 @@ +registerServiceProvider(new ModuleDispatcherFactory('\\Moko\\Module\\MokoWaaSCache')); + $container->registerServiceProvider(new Module()); + } +}; diff --git a/src/packages/mod_mokowaas_cache/src/Dispatcher/Dispatcher.php b/src/packages/mod_mokowaas_cache/src/Dispatcher/Dispatcher.php new file mode 100644 index 00000000..b67aad8d --- /dev/null +++ b/src/packages/mod_mokowaas_cache/src/Dispatcher/Dispatcher.php @@ -0,0 +1,14 @@ + + + +
+ +
+
+ +
+
+ + diff --git a/src/packages/mod_mokowaas_cpanel/mod_mokowaas_cpanel.xml b/src/packages/mod_mokowaas_cpanel/mod_mokowaas_cpanel.xml index 4f12f37c..21f389b9 100644 --- a/src/packages/mod_mokowaas_cpanel/mod_mokowaas_cpanel.xml +++ b/src/packages/mod_mokowaas_cpanel/mod_mokowaas_cpanel.xml @@ -7,7 +7,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.33.00 + 02.32.52 MOD_MOKOWAAS_CPANEL_DESC Moko\Module\MokoWaaSCpanel diff --git a/src/packages/mod_mokowaas_cpanel/src/Dispatcher/Dispatcher.php b/src/packages/mod_mokowaas_cpanel/src/Dispatcher/Dispatcher.php index d3b1c191..d5b14142 100644 --- a/src/packages/mod_mokowaas_cpanel/src/Dispatcher/Dispatcher.php +++ b/src/packages/mod_mokowaas_cpanel/src/Dispatcher/Dispatcher.php @@ -33,6 +33,7 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI $data['counts'] = $helper->getCounts($db); $data['disk'] = $helper->getDiskInfo(); $data['currentIp'] = $helper->getCurrentIp(); + $data['ssl'] = $helper->getSslStatus(); return $data; } diff --git a/src/packages/mod_mokowaas_cpanel/src/Helper/CpanelHelper.php b/src/packages/mod_mokowaas_cpanel/src/Helper/CpanelHelper.php index 7160329e..87fff882 100644 --- a/src/packages/mod_mokowaas_cpanel/src/Helper/CpanelHelper.php +++ b/src/packages/mod_mokowaas_cpanel/src/Helper/CpanelHelper.php @@ -87,10 +87,11 @@ class CpanelHelper public function getCounts(DatabaseInterface $db): object { $counts = (object) [ - 'articles' => 0, - 'users' => 0, - 'extensions' => 0, - 'updates' => 0, + 'articles' => 0, + 'users' => 0, + 'extensions' => 0, + 'updates' => 0, + 'moko_updates' => 0, ]; try @@ -106,6 +107,20 @@ class CpanelHelper $db->setQuery($db->getQuery(true)->select('COUNT(*)')->from($db->quoteName('#__updates'))->where($db->quoteName('extension_id') . ' != 0')); $counts->updates = (int) $db->loadResult(); + + // MokoWaaS-specific updates + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__updates', 'u')) + ->join('INNER', $db->quoteName('#__extensions', 'e') . ' ON e.extension_id = u.extension_id') + ->where('(' . $db->quoteName('e.element') . ' LIKE ' . $db->quote('mokowaas%') + . ' OR ' . $db->quoteName('e.element') . ' LIKE ' . $db->quote('pkg_mokowaas%') + . ' OR ' . $db->quoteName('e.element') . ' LIKE ' . $db->quote('com_mokowaas%') + . ' OR ' . $db->quoteName('e.element') . ' LIKE ' . $db->quote('mod_mokowaas%') + . ' OR ' . $db->quoteName('e.element') . ' = ' . $db->quote('mokoonyx') . ')') + ); + $counts->moko_updates = (int) $db->loadResult(); } catch (\Throwable $e) { @@ -136,4 +151,54 @@ class CpanelHelper { return $_SERVER['REMOTE_ADDR'] ?? ''; } + + /** + * Check SSL certificate expiry (#148). + * + * @return object|null {expires, days_remaining, warning} or null if check fails + */ + public function getSslStatus(): ?object + { + try + { + $host = parse_url(\Joomla\CMS\Uri\Uri::root(), PHP_URL_HOST); + + if (empty($host)) + { + return null; + } + + $context = stream_context_create(['ssl' => ['capture_peer_cert' => true, 'verify_peer' => false]]); + $client = @stream_socket_client('ssl://' . $host . ':443', $errno, $errstr, 5, STREAM_CLIENT_CONNECT, $context); + + if (!$client) + { + return null; + } + + $params = stream_context_get_params($client); + fclose($client); + + $cert = openssl_x509_parse($params['options']['ssl']['peer_certificate'] ?? ''); + + if (empty($cert['validTo_time_t'])) + { + return null; + } + + $expires = $cert['validTo_time_t']; + $days = (int) floor(($expires - time()) / 86400); + + return (object) [ + 'expires' => date('Y-m-d', $expires), + 'days_remaining' => $days, + 'warning' => $days <= 30, + 'critical' => $days <= 7, + ]; + } + catch (\Throwable $e) + { + return null; + } + } } diff --git a/src/packages/mod_mokowaas_cpanel/tmpl/default.php b/src/packages/mod_mokowaas_cpanel/tmpl/default.php index b3ed62f2..66eea9f4 100644 --- a/src/packages/mod_mokowaas_cpanel/tmpl/default.php +++ b/src/packages/mod_mokowaas_cpanel/tmpl/default.php @@ -67,6 +67,16 @@ $diskColor = ($diskPct !== null && $diskPct > 90) ? 'bg-danger' : (($diskPct !== offline)): ?> Offline + moko_updates ?? 0) > 0): ?> + + moko_updates; ?> MokoWaaS updatemoko_updates > 1 ? 's' : ''; ?> + + + updates > 0 && $counts->updates !== ($counts->moko_updates ?? 0)): ?> + + updates - ($counts->moko_updates ?? 0); ?> updateupdates - ($counts->moko_updates ?? 0)) > 1 ? 's' : ''; ?> + + @@ -130,6 +140,12 @@ $diskColor = ($diskPct !== null && $diskPct > 90) ? 'bg-danger' : (($diskPct !== + + + + SSL days_remaining; ?>d + + Jjoomla_version ?? ''); ?> / PHP php_version ?? ''); ?> diff --git a/src/packages/mod_mokowaas_menu/language/en-GB/mod_mokowaas_menu.ini b/src/packages/mod_mokowaas_menu/language/en-GB/mod_mokowaas_menu.ini new file mode 100644 index 00000000..dff9f13a --- /dev/null +++ b/src/packages/mod_mokowaas_menu/language/en-GB/mod_mokowaas_menu.ini @@ -0,0 +1 @@ +MOD_MOKOWAAS_MENU="MokoWaaS Admin Menu" diff --git a/src/packages/mod_mokowaas_menu/language/en-GB/mod_mokowaas_menu.sys.ini b/src/packages/mod_mokowaas_menu/language/en-GB/mod_mokowaas_menu.sys.ini new file mode 100644 index 00000000..898a3832 --- /dev/null +++ b/src/packages/mod_mokowaas_menu/language/en-GB/mod_mokowaas_menu.sys.ini @@ -0,0 +1,2 @@ +MOD_MOKOWAAS_MENU="MokoWaaS Admin Menu" +MOD_MOKOWAAS_MENU_DESC="Dedicated MokoWaaS section in the admin sidebar menu." diff --git a/src/packages/mod_mokowaas_menu/mod_mokowaas_menu.xml b/src/packages/mod_mokowaas_menu/mod_mokowaas_menu.xml new file mode 100644 index 00000000..feaa5e4d --- /dev/null +++ b/src/packages/mod_mokowaas_menu/mod_mokowaas_menu.xml @@ -0,0 +1,24 @@ + + + mod_mokowaas_menu + Moko Consulting + 2026-06-04 + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + hello@mokoconsulting.tech + https://mokoconsulting.tech + 02.32.52 + MokoWaaS admin sidebar menu — renders a dedicated MokoWaaS section in the admin menu before Joomla's default menu. + Moko\Module\MokoWaaSMenu + + + services + src + tmpl + + + + en-GB/mod_mokowaas_menu.ini + en-GB/mod_mokowaas_menu.sys.ini + + diff --git a/src/packages/mod_mokowaas_menu/services/provider.php b/src/packages/mod_mokowaas_menu/services/provider.php new file mode 100644 index 00000000..67feaece --- /dev/null +++ b/src/packages/mod_mokowaas_menu/services/provider.php @@ -0,0 +1,18 @@ +registerServiceProvider(new ModuleDispatcherFactory('\\Moko\\Module\\MokoWaaSMenu')); + $container->registerServiceProvider(new HelperFactory('\\Moko\\Module\\MokoWaaSMenu\\Administrator\\Helper')); + $container->registerServiceProvider(new Module()); + } +}; diff --git a/src/packages/mod_mokowaas_menu/src/Dispatcher/Dispatcher.php b/src/packages/mod_mokowaas_menu/src/Dispatcher/Dispatcher.php new file mode 100644 index 00000000..b5d4dcc2 --- /dev/null +++ b/src/packages/mod_mokowaas_menu/src/Dispatcher/Dispatcher.php @@ -0,0 +1,14 @@ + 'icon-cogs', 'title' => 'Dashboard', 'link' => 'index.php?option=com_mokowaas'], + ['icon' => 'fa-solid fa-handshake-angle', 'title' => 'Helpdesk', 'link' => 'index.php?option=com_mokowaas&view=tickets'], + ['icon' => 'icon-puzzle-piece', 'title' => 'Extensions', 'link' => 'index.php?option=com_mokowaas&view=extensions'], + ['icon' => 'fa-solid fa-file-code', 'title' => '.htaccess Maker', 'link' => 'index.php?option=com_mokowaas&view=htaccess'], + ['icon' => 'icon-lock', 'title' => 'Privacy Guard', 'link' => 'index.php?option=com_mokowaas&view=privacy'], + ['icon' => 'icon-shield-alt', 'title' => 'WAF Log', 'link' => 'index.php?option=com_mokowaas&view=waflog'], + ['icon' => 'icon-database', 'title' => 'Database Tools', 'link' => 'index.php?option=com_mokowaas&view=database'], + ['icon' => 'icon-trash', 'title' => 'Cache Cleanup', 'link' => 'index.php?option=com_mokowaas&view=cleanup'], + ['icon' => 'icon-power-off', 'title' => 'Feature Plugins', 'link' => 'index.php?option=com_plugins&filter[folder]=system&filter[search]=mokowaas'], +]; + +$app = \Joomla\CMS\Factory::getApplication(); +$currentOption = $app->getInput()->get('option', ''); +$currentView = $app->getInput()->get('view', ''); + +// Determine if any child is active (auto-expand) +$anyActive = ($currentOption === 'com_mokowaas'); +$parentClass = 'item parent item-level-1' . ($anyActive ? ' mm-active' : ''); +$collapseClass = 'collapse-level-1 mm-collapse' . ($anyActive ? ' mm-show' : ''); +?> + + diff --git a/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php b/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php index 4fb3e50b..c65cafe0 100644 --- a/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php +++ b/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php @@ -22,7 +22,7 @@ * DEFGROUP: Joomla.Plugin * INGROUP: MokoWaaS * REPO: https://github.com/mokoconsulting-tech/mokowaas - * VERSION: 02.33.00 + * VERSION: 02.32.52 * PATH: /src/Extension/MokoWaaS.php * NOTE: Handles Joomla system events for rebranding functionality */ @@ -161,20 +161,7 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface */ public function boot(ContainerInterface $container): void { - $timeout = (int) $this->params->get('admin_session_timeout', 0); - - if ($timeout <= 0) - { - return; - } - - if ($this->ipIsTrusted()) - { - // Set both PHP and Joomla session lifetimes before the - // session handler runs its expiry check. - ini_set('session.gc_maxlifetime', 315360000); - Factory::getConfig()->set('lifetime', 525600); - } + // Session lifetime for trusted IPs is now handled by the firewall plugin } /** @@ -189,12 +176,7 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface */ public function onAfterInitialise() { - // Security: HTTPS redirect (runs for all clients) - $this->enforceHttps(); - - // Site alias handling: offline page and backend redirect. - // Must run in onAfterInitialise (not onAfterRoute) so that - // Joomla's offline check in doExecute() sees the updated config. + // Site alias handling $this->handleSiteAlias(); // MokoWaaS API endpoints (run before routing) @@ -205,18 +187,15 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface $this->handleMokoApi($mokoAction); } - // Dev mode: disable caching - $this->enforceDevMode(); - - // Admin-only WaaS controls + // Admin-only core controls (branding, emergency access, master user) + // NOTE: enforceHttps, enforceDevMode, enforceAdminSessionTimeout, + // enforceUploadRestrictions are now in feature plugins if ($this->app->isClient('administrator')) { $this->handleEmergencyAccess(); $this->enforceMasterUser(); $this->enforceLoginSupportUrls(); $this->enforceAtumBranding(); - $this->enforceAdminSessionTimeout(); - $this->enforceUploadRestrictions(); } $this->loadLanguageOverrides(); @@ -815,7 +794,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface return $strings; } - /** * Event triggered after an extension's config is saved. * @@ -883,41 +861,7 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface // Grafana auto-provisioning $this->handleGrafanaProvisioning($params, $app); - if ((int) $params->get('reset_hits', 0) === 1) - { - $count = $this->resetAllHits(); - $params->set('reset_hits', '0'); - $changed = true; - - $app->enqueueMessage( - sprintf('Reset hit counters on %d articles.', $count), - 'message' - ); - - Log::add( - sprintf('All article hits reset (%d rows) by MokoWaaS', $count), - Log::WARNING, - 'mokowaas' - ); - } - - if ((int) $params->get('delete_versions', 0) === 1) - { - $count = $this->deleteAllVersions(); - $params->set('delete_versions', '0'); - $changed = true; - - $app->enqueueMessage( - sprintf('Deleted %d version history records.', $count), - 'message' - ); - - Log::add( - sprintf('All content versions purged (%d rows) by MokoWaaS', $count), - Log::WARNING, - 'mokowaas' - ); - } + // NOTE: reset_hits and delete_versions now handled by devtools plugin // Content Sync: Push Now if ((int) $params->get('sync_push_now', 0) === 1) @@ -977,48 +921,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface } } - /** - * Reset all article hit counters to zero. - * - * @return int Number of rows affected - * - * @since 02.01.08 - */ - protected function resetAllHits() - { - $db = Factory::getDbo(); - - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__content')) - ->set($db->quoteName('hits') . ' = 0') - ->where($db->quoteName('hits') . ' > 0') - ); - $db->execute(); - - return $db->getAffectedRows(); - } - - /** - * Delete all content version history records. - * - * @return int Number of rows deleted - * - * @since 02.01.08 - */ - protected function deleteAllVersions() - { - $db = Factory::getDbo(); - - $db->setQuery( - $db->getQuery(true) - ->delete($db->quoteName('#__history')) - ); - $db->execute(); - - return $db->getAffectedRows(); - } - /** * Event triggered after the route has been determined. * @@ -1036,11 +938,71 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface return; } - $this->warnMissingLicenseKey(); - $this->enforceAdminRestrictions(); $this->protectPlugin(); } + // ------------------------------------------------------------------ + // Automation event hooks (#151) — delegate to ticket automation engine + // ------------------------------------------------------------------ + + public function onUserLogin($user, $options = []) + { + // Security alert for admin logins (#131) + if ($this->app->isClient('administrator')) + { + try + { + \Moko\Component\MokoWaaS\Administrator\Service\NotificationService::securityAlert( + 'admin_login', + 'Admin Login: ' . ($user['username'] ?? ''), + 'User: ' . ($user['username'] ?? '') . "\nIP: " . ($_SERVER['REMOTE_ADDR'] ?? '') . "\nTime: " . gmdate('Y-m-d H:i:s') . ' UTC' + ); + } + catch (\Throwable $e) {} + } + + $this->fireTicketAutomation('user_login', [ + 'user_id' => $user['id'] ?? 0, + 'username' => $user['username'] ?? '', + 'subject' => 'User login: ' . ($user['username'] ?? ''), + 'body' => 'User ' . ($user['username'] ?? '') . ' logged in from ' . ($_SERVER['REMOTE_ADDR'] ?? ''), + ]); + } + + public function onUserAfterSave($user, $isNew, $success, $msg) + { + if ($isNew && $success) + { + $this->fireTicketAutomation('user_register', [ + 'user_id' => $user['id'] ?? 0, + 'username' => $user['username'] ?? '', + 'subject' => 'New user registered: ' . ($user['username'] ?? ''), + 'body' => 'New user: ' . ($user['name'] ?? '') . ' (' . ($user['email'] ?? '') . ')', + ]); + } + } + + public function onUserLoginFailure($response) + { + $this->fireTicketAutomation('user_login_failed', [ + 'subject' => 'Failed login attempt', + 'body' => 'Failed login from ' . ($_SERVER['REMOTE_ADDR'] ?? '') . ': ' . ($response['username'] ?? ''), + ]); + } + + private function fireTicketAutomation(string $event, array $data): void + { + try + { + $model = new \Moko\Component\MokoWaaS\Administrator\Model\TicketsModel(); + $model->runSystemEventAutomation($event, $data); + } + catch (\Throwable $e) + { + // Silent — automation should never break the main flow + } + } + /** * Inject visual branding into the document head. * @@ -1509,7 +1471,7 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface $db->quote('mokowaas_firewall'), $db->quote('mokowaas_tenant'), $db->quote('mokowaas_devtools'), - $db->quote('mokowaas_monitor'), + $db->quote('mokowaas_offline'), $db->quote('mod_mokowaas_cpanel'), ]; @@ -1553,107 +1515,8 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface } } - /** - * Filter admin menu items for non-master users. - * - * @param string $context Menu context - * @param array &$items Menu items (by reference) - * @param mixed $params Module params - * @param mixed $enabled Whether module is enabled - * - * @return void - * - * @since 02.01.08 - */ - public function onPreprocessMenuItems($context, &$items, $params, $enabled) - { - if (!$this->app->isClient('administrator')) - { - return; - } - - if ($this->isMasterUser()) - { - return; - } - - $hidden = $this->getHiddenMenuComponents(); - - if (empty($hidden)) - { - return; - } - - foreach ($items as $key => $item) - { - foreach ($hidden as $component) - { - if (isset($item->link) - && strpos($item->link, 'option=' . $component) !== false) - { - unset($items[$key]); - break; - } - } - } - } - - /** - * Enforce password policy before user save. - * - * @param array $oldUser Existing user data - * @param boolean $isNew Whether this is a new user - * @param array $newUser New user data being saved - * - * @return boolean True to allow save - * - * @since 02.01.08 - */ - public function onUserBeforeSave($oldUser, $isNew, $newUser) - { - if (empty($newUser['password_clear'])) - { - return true; - } - - $password = $newUser['password_clear']; - $errors = []; - - $minLen = (int) $this->params->get('password_min_length', 12); - - if (strlen($password) < $minLen) - { - $errors[] = sprintf( - 'Password must be at least %d characters.', $minLen - ); - } - - if ($this->params->get('password_require_uppercase', 1) - && !preg_match('/[A-Z]/', $password)) - { - $errors[] = 'Password must contain an uppercase letter.'; - } - - if ($this->params->get('password_require_number', 1) - && !preg_match('/\d/', $password)) - { - $errors[] = 'Password must contain a number.'; - } - - if ($this->params->get('password_require_special', 1) - && !preg_match('/[^A-Za-z0-9]/', $password)) - { - $errors[] = 'Password must contain a special character.'; - } - - if (!empty($errors)) - { - throw new \RuntimeException(implode(' ', $errors)); - } - - return true; - } - + // onPreprocessMenuItems — REMOVED, now in plg_system_mokowaas_tenant + // onUserBeforeSave — REMOVED, now in plg_system_mokowaas_firewall // ------------------------------------------------------------------ // Diagnostics / Health Endpoint (called from onAfterInitialise) @@ -4420,130 +4283,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface // License key check (called from onAfterRoute) // ------------------------------------------------------------------ - /** - * Show a persistent admin warning if no license key is set on the - * MokoWaaS update site. - * - * Checks the extra_query column in #__update_sites for a dlid value. - * Also validates the key against MokoGitea on a heartbeat interval - * (once per day) and warns if the key is invalid or expired. - * - * @return void - * - * @since 02.31.00 - */ - protected function warnMissingLicenseKey(): void - { - // Only show to master users - if (!$this->isMasterUser()) - { - return; - } - - // Only warn once per session - $session = Factory::getSession(); - - if ($session->get('mokowaas.license_warned', false)) - { - return; - } - - $session->set('mokowaas.license_warned', true); - - try - { - $db = Factory::getDbo(); - - $query = $db->getQuery(true) - ->select($db->quoteName('extra_query')) - ->from($db->quoteName('#__update_sites')) - ->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoWaaS%') - . ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoWaaS%') . ')') - ->setLimit(1); - $db->setQuery($query); - $extraQuery = (string) $db->loadResult(); - - if (empty($extraQuery) || strpos($extraQuery, 'dlid=') === false) - { - $this->app->enqueueMessage( - 'Moko Consulting License Key Required — ' - . 'No download key is configured. Updates will not be available until a valid license key is entered. ' - . 'Go to System → Update Sites ' - . 'and enter your license key in the Download Key field for the MokoWaaS update site.', - 'warning' - ); - - return; - } - - // Extract the key value from extra_query - parse_str($extraQuery, $parsed); - $licenseKey = $parsed['dlid'] ?? ''; - - if (empty($licenseKey)) - { - return; - } - - // Heartbeat validation — check once per day - $session = Factory::getSession(); - $lastCheck = (int) $session->get('mokowaas.license_check', 0); - $now = time(); - - if (($now - $lastCheck) < 86400) - { - // Show cached warning if key was invalid last check - if ($session->get('mokowaas.license_invalid', false)) - { - $this->app->enqueueMessage( - 'Moko Consulting License Key Invalid — ' - . 'Your license key could not be validated. Please verify your key in ' - . 'System → Update Sites.', - 'error' - ); - } - - return; - } - - // Validate against MokoGitea - $session->set('mokowaas.license_check', $now); - - $validateUrl = 'https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/updates.xml' - . '?dlid=' . urlencode($licenseKey) - . '&domain=' . urlencode(Uri::root()); - - $ch = curl_init($validateUrl); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_TIMEOUT, 10); - curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - $response = curl_exec($ch); - $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - // Empty or non-200 means invalid key - $isValid = ($httpCode === 200 && $response && strpos($response, '') !== false); - - $session->set('mokowaas.license_invalid', !$isValid); - - if (!$isValid) - { - $this->app->enqueueMessage( - 'Moko Consulting License Key Invalid — ' - . 'Your license key could not be validated. Updates will not be available. ' - . 'Please verify your key in ' - . 'System → Update Sites.', - 'error' - ); - } - } - catch (\Throwable $e) - { - // Silent — license check is non-critical - } - } - // ------------------------------------------------------------------ /** @@ -4679,110 +4418,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface * * @since 02.01.08 */ - /** - * Enforce development mode settings. - * - * When dev mode is ON: - * - Disable Joomla caching - * - Enable Joomla debug mode (Global Config) - * - Enable MokoOnyx template debug - * - Disable article hit recording - * - * When dev mode is OFF (and was previously on): - * - Reset all content version history - * - Reset article published dates to now - * - * @return void - * - * @since 02.01.15 - */ - protected function enforceDevMode() - { - if (!$this->params->get('dev_mode', 0)) - { - return; - } - - // Disable caching - $config = Factory::getConfig(); - $config->set('caching', 0); - - // Enable Joomla debug - $config->set('debug', 1); - - // Enable MokoOnyx template debug - $this->setTemplateParam('mokoonyx', 'debug', 1); - - // Show offline page on primary domain only — site aliases - // and dev.* subdomains bypass offline mode for development - $currentHost = $_SERVER['HTTP_HOST'] ?? ''; - $primaryDomain = $this->params->get('primary_domain', ''); - - if (!empty($primaryDomain) && $currentHost === $primaryDomain) - { - $config->set('offline', 1); - } - - // Suppress hit recording - try - { - $db = Factory::getDbo(); - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__content')) - ->set($db->quoteName('hits') . ' = 0') - ->where($db->quoteName('hits') . ' > 0') - )->execute(); - } - catch (\Throwable $e) - { - // Silent - } - } - - /** - * Actions to run when dev mode is turned off. - * - * Resets content versions and hits, disables debug. - * - * @return void - * - * @since 02.31.00 - */ - protected function onDevModeDisabled(): void - { - try - { - $db = Factory::getDbo(); - - // Delete all content version history - $db->setQuery( - $db->getQuery(true)->delete($db->quoteName('#__history')) - )->execute(); - - // Reset hits - $db->setQuery( - $db->getQuery(true) - ->update($db->quoteName('#__content')) - ->set($db->quoteName('hits') . ' = 0') - )->execute(); - - // Disable debug - $this->setTemplateParam('mokoonyx', 'debug', 0); - - // Take site back online - Factory::getConfig()->set('offline', 0); - - $this->app->enqueueMessage( - 'Development mode disabled — versions cleared, hits reset, debug off, site online.', - 'message' - ); - } - catch (\Throwable $e) - { - Log::add('Dev mode cleanup failed: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); - } - } /** * Set a parameter on a template style. @@ -4830,194 +4465,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface } } - protected function enforceHttps() - { - if (!$this->params->get('force_https', 0)) - { - return; - } - - if ($this->app->isClient('cli')) - { - return; - } - - $isHttps = (!empty($_SERVER['HTTPS']) - && $_SERVER['HTTPS'] !== 'off') - || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'; - - if (!$isHttps) - { - $this->app->redirect( - 'https://' . $_SERVER['HTTP_HOST'] - . $_SERVER['REQUEST_URI'], 301 - ); - } - } - - /** - * Enforce admin session idle timeout. - * - * @return void - * - * @since 02.01.08 - */ - protected function enforceAdminSessionTimeout() - { - $timeout = (int) $this->params->get('admin_session_timeout', 0); - - if ($timeout <= 0) - { - return; - } - - // Don't timeout the master user - if ($this->isMasterUser()) - { - return; - } - - // Trusted IPs — session lifetime already extended in boot() - if ($this->ipIsTrusted()) - { - return; - } - - $session = Factory::getSession(); - $lastHit = $session->get('mokowaas.last_activity', 0); - $now = time(); - - if ($lastHit > 0 && ($now - $lastHit) > ($timeout * 60)) - { - $this->app->logout(); - $this->app->redirect( - Route::_('index.php', false) - ); - - return; - } - - $session->set('mokowaas.last_activity', $now); - } - - /** - * Check whether the current request IP matches any trusted IP entry. - * - * Supports exact IPs, CIDR notation (e.g. 10.0.0.0/8), and - * wildcard patterns (e.g. 192.168.1.*). - * - * @return bool True if the current IP is in the trusted list. - * - * @since 02.11.00 - */ - protected function ipIsTrusted(): bool - { - $entries = $this->params->get('trusted_ips', ''); - - if (empty($entries)) - { - return false; - } - - // Subform stores as JSON string or array - if (\is_string($entries)) - { - $entries = json_decode($entries, true); - } - - if (!\is_array($entries)) - { - return false; - } - - $ip = $this->app - ? $this->app->input->server->getString('REMOTE_ADDR', '') - : ($_SERVER['REMOTE_ADDR'] ?? ''); - $ipLong = ip2long($ip); - - if ($ipLong === false) - { - return false; - } - - foreach ($entries as $entry) - { - if (empty($entry['enabled']) || empty($entry['ip'])) - { - continue; - } - - $range = trim($entry['ip']); - - // Wildcard: 192.168.1.* - if (str_contains($range, '*')) - { - $pattern = '/^' . str_replace(['.', '*'], ['\\.', '\\d+'], $range) . '$/'; - - if (preg_match($pattern, $ip)) - { - return true; - } - - continue; - } - - // CIDR: 10.0.0.0/8 - if (str_contains($range, '/')) - { - [$subnet, $bits] = explode('/', $range, 2); - $subnetLong = ip2long($subnet); - $mask = -1 << (32 - (int) $bits); - - if ($subnetLong !== false && ($ipLong & $mask) === ($subnetLong & $mask)) - { - return true; - } - - continue; - } - - // Exact match - if ($ip === $range) - { - return true; - } - } - - return false; - } - - - /** - * Override Joomla upload restrictions at runtime. - * - * @return void - * - * @since 02.01.08 - */ - protected function enforceUploadRestrictions() - { - $types = $this->params->get('upload_allowed_types', ''); - $maxMb = (int) $this->params->get('upload_max_size_mb', 0); - - if (empty($types) && $maxMb <= 0) - { - return; - } - - $config = $this->app->getConfig(); - - if (!empty($types)) - { - $config->set('upload_extensions', $types); - } - - if ($maxMb > 0) - { - $config->set('upload_maxsize', $maxMb); - } - } - /** * Enforce login support module URLs on admin requests. * @@ -5086,121 +4533,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface // Tenant Restrictions (called from onAfterRoute) // ------------------------------------------------------------------ - /** - * Check admin routes against restriction rules and redirect if blocked. - * - * @return void - * - * @since 02.01.08 - */ - protected function enforceAdminRestrictions() - { - // Master user bypasses ALL restrictions - if ($this->isMasterUser()) - { - return; - } - - $input = $this->app->input; - $option = $input->get('option', ''); - $view = $input->get('view', ''); - $task = $input->get('task', ''); - - // Disable install-from-URL for non-master users - if ($this->params->get('disable_install_url', 1) - && $option === 'com_installer' - && stripos($task, 'install') !== false - && $input->get('installtype') === 'url') - { - $this->blockAccess('Install from URL is disabled.'); - - return; - } - - $blocked = []; - - if ($this->params->get('restrict_installer', 1)) - { - // Allow the update view by default so tenants can update extensions - $allowUpdates = (int) $this->params->get('allow_extension_updates', 1); - - if ($allowUpdates && $option === 'com_installer' - && \in_array($view, ['update', 'updatesites'], true)) - { - // Do not block — update views are permitted - } - elseif ($option === 'com_installer') - { - $this->blockAccess('Access restricted.'); - - return; - } - } - - if ($this->params->get('hide_sysinfo', 1)) - { - $blocked[] = [ - 'option' => 'com_admin', - 'view' => 'sysinfo', - ]; - } - - if ($this->params->get('restrict_global_config', 1)) - { - $blocked[] = [ - 'option' => 'com_config', - 'view' => 'application', - ]; - // Also block empty view (default landing = global config) - if ($option === 'com_config' && $view === '') - { - $this->blockAccess('Access restricted.'); - - return; - } - } - - if ($this->params->get('restrict_template_editing', 1)) - { - $blocked[] = [ - 'option' => 'com_templates', - 'view' => 'template', - ]; - } - - foreach ($blocked as $rule) - { - if ($option !== $rule['option']) - { - continue; - } - - if (isset($rule['view']) && $view !== $rule['view']) - { - continue; - } - - $this->blockAccess('Access restricted.'); - - return; - } - } - - /** - * Redirect to admin dashboard with an error message. - * - * @param string $message Error message to display - * - * @return void - * - * @since 02.01.08 - */ - protected function blockAccess($message) - { - $this->app->enqueueMessage($message, 'error'); - $this->app->redirect(Route::_('index.php', false)); - } - /** * Check whether the current user is the master WaaS user. * @@ -5252,38 +4584,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface return $this->masterNames; } - /** - * Build the list of components to hide from admin menu. - * - * Combines explicit hidden_menu_items config with components that - * are implicitly blocked by other restriction toggles. - * - * @return array Component option strings - * - * @since 02.01.08 - */ - protected function getHiddenMenuComponents() - { - $hidden = array_filter(array_map( - 'trim', - explode("\n", $this->params->get('hidden_menu_items', '')) - )); - - // Auto-hide components that are restricted (keep visible when updates are allowed) - if ($this->params->get('restrict_installer', 1) - && !$this->params->get('allow_extension_updates', 1)) - { - $hidden[] = 'com_installer'; - } - - if ($this->params->get('hide_sysinfo', 1)) - { - $hidden[] = 'com_admin'; - } - - return array_unique($hidden); - } - // ------------------------------------------------------------------ // Atum Template Branding (called from onAfterInitialise) // ------------------------------------------------------------------ diff --git a/src/packages/plg_system_mokowaas/Field/AllowedIpsField.php b/src/packages/plg_system_mokowaas/Field/AllowedIpsField.php index 432a8a74..be2468ca 100644 --- a/src/packages/plg_system_mokowaas/Field/AllowedIpsField.php +++ b/src/packages/plg_system_mokowaas/Field/AllowedIpsField.php @@ -7,7 +7,7 @@ * FILE INFORMATION * DEFGROUP: Joomla.Plugin * INGROUP: MokoWaaS - * VERSION: 02.33.00 + * VERSION: 02.32.52 * PATH: /src/Field/AllowedIpsField.php * BRIEF: Custom form field that displays the current IP whitelist */ diff --git a/src/packages/plg_system_mokowaas/Field/CopyableTokenField.php b/src/packages/plg_system_mokowaas/Field/CopyableTokenField.php index 99d551bb..1cd755cc 100644 --- a/src/packages/plg_system_mokowaas/Field/CopyableTokenField.php +++ b/src/packages/plg_system_mokowaas/Field/CopyableTokenField.php @@ -8,7 +8,7 @@ * FILE INFORMATION * DEFGROUP: Joomla.Plugin * INGROUP: MokoWaaS - * VERSION: 02.33.00 + * VERSION: 02.32.52 * PATH: /src/Field/CopyableTokenField.php * BRIEF: Read-only token field with a copy-to-clipboard button */ diff --git a/src/packages/plg_system_mokowaas/Field/CurrentIpField.php b/src/packages/plg_system_mokowaas/Field/CurrentIpField.php index 3d4bf6f6..96a8ac90 100644 --- a/src/packages/plg_system_mokowaas/Field/CurrentIpField.php +++ b/src/packages/plg_system_mokowaas/Field/CurrentIpField.php @@ -7,7 +7,7 @@ * FILE INFORMATION * DEFGROUP: Joomla.Plugin * INGROUP: MokoWaaS - * VERSION: 02.33.00 + * VERSION: 02.32.52 * PATH: /src/Field/CurrentIpField.php * BRIEF: Read-only field that displays the current user's IP address */ diff --git a/src/packages/plg_system_mokowaas/Field/DemoTaskInfoField.php b/src/packages/plg_system_mokowaas/Field/DemoTaskInfoField.php index 97316c0a..83b822c4 100644 --- a/src/packages/plg_system_mokowaas/Field/DemoTaskInfoField.php +++ b/src/packages/plg_system_mokowaas/Field/DemoTaskInfoField.php @@ -8,7 +8,7 @@ * FILE INFORMATION * DEFGROUP: Joomla.Plugin * INGROUP: MokoWaaS - * VERSION: 02.33.00 + * VERSION: 02.32.52 * PATH: /src/Field/DemoTaskInfoField.php * BRIEF: Read-only field showing scheduled task info with link to manage it */ diff --git a/src/packages/plg_system_mokowaas/Field/NextResetField.php b/src/packages/plg_system_mokowaas/Field/NextResetField.php index ca4d64ef..6a8810a9 100644 --- a/src/packages/plg_system_mokowaas/Field/NextResetField.php +++ b/src/packages/plg_system_mokowaas/Field/NextResetField.php @@ -8,7 +8,7 @@ * FILE INFORMATION * DEFGROUP: Joomla.Plugin * INGROUP: MokoWaaS - * VERSION: 02.33.00 + * VERSION: 02.32.52 * PATH: /src/Field/NextResetField.php * BRIEF: Read-only field showing next reset time from Joomla scheduled task */ diff --git a/src/packages/plg_system_mokowaas/Field/SnapshotTablesField.php b/src/packages/plg_system_mokowaas/Field/SnapshotTablesField.php index 5618bbf6..1d10115d 100644 --- a/src/packages/plg_system_mokowaas/Field/SnapshotTablesField.php +++ b/src/packages/plg_system_mokowaas/Field/SnapshotTablesField.php @@ -8,7 +8,7 @@ * FILE INFORMATION * DEFGROUP: Joomla.Plugin * INGROUP: MokoWaaS - * VERSION: 02.33.00 + * VERSION: 02.32.52 * PATH: /src/Field/SnapshotTablesField.php * BRIEF: Multi-select list field that loads DB tables with sensible defaults */ diff --git a/src/packages/plg_system_mokowaas/Helper/MokoWaaSHelper.php b/src/packages/plg_system_mokowaas/Helper/MokoWaaSHelper.php index 0cd42177..3e533f84 100644 --- a/src/packages/plg_system_mokowaas/Helper/MokoWaaSHelper.php +++ b/src/packages/plg_system_mokowaas/Helper/MokoWaaSHelper.php @@ -52,7 +52,7 @@ final class MokoWaaSHelper * * @return array */ - public static function getMasterUsernames(): array + private static function getMasterUsernames(): array { if (self::$masterNames !== null) { diff --git a/src/packages/plg_system_mokowaas/Service/ContentSyncReceiver.php b/src/packages/plg_system_mokowaas/Service/ContentSyncReceiver.php index e6c69177..515b1e3f 100644 --- a/src/packages/plg_system_mokowaas/Service/ContentSyncReceiver.php +++ b/src/packages/plg_system_mokowaas/Service/ContentSyncReceiver.php @@ -10,7 +10,7 @@ * INGROUP: MokoWaaS * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS * PATH: /src/packages/plg_system_mokowaas/Service/ContentSyncReceiver.php - * VERSION: 02.33.00 + * VERSION: 02.32.52 * BRIEF: Receiver-side content sync — applies incoming payload to local DB */ diff --git a/src/packages/plg_system_mokowaas/Service/ContentSyncService.php b/src/packages/plg_system_mokowaas/Service/ContentSyncService.php index d27bf9a8..6b2a9c39 100644 --- a/src/packages/plg_system_mokowaas/Service/ContentSyncService.php +++ b/src/packages/plg_system_mokowaas/Service/ContentSyncService.php @@ -10,7 +10,7 @@ * INGROUP: MokoWaaS * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS * PATH: /src/packages/plg_system_mokowaas/Service/ContentSyncService.php - * VERSION: 02.33.00 + * VERSION: 02.32.52 * BRIEF: Sender-side content sync — builds payload and pushes to remote sites */ diff --git a/src/packages/plg_system_mokowaas/Service/DemoResetService.php b/src/packages/plg_system_mokowaas/Service/DemoResetService.php index ce458abd..1012b58f 100644 --- a/src/packages/plg_system_mokowaas/Service/DemoResetService.php +++ b/src/packages/plg_system_mokowaas/Service/DemoResetService.php @@ -10,7 +10,7 @@ * INGROUP: MokoWaaS * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS * PATH: /src/packages/plg_system_mokowaas/Service/DemoResetService.php - * VERSION: 02.33.00 + * VERSION: 02.32.52 * BRIEF: Content-only snapshot/restore for demo site reset */ diff --git a/src/packages/plg_system_mokowaas/mokowaas.xml b/src/packages/plg_system_mokowaas/mokowaas.xml index b7ec28ca..86ce69a6 100644 --- a/src/packages/plg_system_mokowaas/mokowaas.xml +++ b/src/packages/plg_system_mokowaas/mokowaas.xml @@ -30,7 +30,7 @@ GNU General Public License version 3 or later; see LICENSE.md hello@mokoconsulting.tech https://mokoconsulting.tech - 02.33.00 + 02.32.52 This plugin rebrands the Joomla system interface with MokoWaaS identity. It applies language overrides and ensures consistent branding across the platform. Moko\Plugin\System\MokoWaaS script.php @@ -76,7 +76,9 @@ -
+
- - - - - - - - - - - - -
-
- - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - -
diff --git a/src/packages/plg_system_mokowaas/script.php b/src/packages/plg_system_mokowaas/script.php index dffd284d..82d35a6b 100644 --- a/src/packages/plg_system_mokowaas/script.php +++ b/src/packages/plg_system_mokowaas/script.php @@ -22,7 +22,7 @@ * DEFGROUP: Joomla.Plugin * INGROUP: MokoWaaS * REPO: https://github.com/mokoconsulting-tech/mokowaas - * VERSION: 02.33.00 + * VERSION: 02.32.52 * PATH: /src/script.php * BRIEF: Installation script for MokoWaaS plugin * NOTE: Handles installation, update, and uninstallation tasks including language override deployment diff --git a/src/packages/plg_system_mokowaas/services/provider.php b/src/packages/plg_system_mokowaas/services/provider.php index 8770e8aa..05ba826c 100644 --- a/src/packages/plg_system_mokowaas/services/provider.php +++ b/src/packages/plg_system_mokowaas/services/provider.php @@ -22,7 +22,7 @@ * DEFGROUP: Joomla.Plugin * INGROUP: MokoWaaS * REPO: https://github.com/mokoconsulting-tech/mokowaas - * VERSION: 02.33.00 + * VERSION: 02.32.52 * 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/src/packages/plg_system_mokowaas_devtools/mokowaas_devtools.xml b/src/packages/plg_system_mokowaas_devtools/mokowaas_devtools.xml index 1f432dd3..efd36b1c 100644 --- a/src/packages/plg_system_mokowaas_devtools/mokowaas_devtools.xml +++ b/src/packages/plg_system_mokowaas_devtools/mokowaas_devtools.xml @@ -8,7 +8,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.33.00 + 02.32.52 PLG_SYSTEM_MOKOWAAS_DEVTOOLS_DESC Moko\Plugin\System\MokoWaaSDevTools diff --git a/src/packages/plg_system_mokowaas_firewall/mokowaas_firewall.xml b/src/packages/plg_system_mokowaas_firewall/mokowaas_firewall.xml index e01bdbea..d345023d 100644 --- a/src/packages/plg_system_mokowaas_firewall/mokowaas_firewall.xml +++ b/src/packages/plg_system_mokowaas_firewall/mokowaas_firewall.xml @@ -8,7 +8,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.33.00 + 02.32.52 PLG_SYSTEM_MOKOWAAS_FIREWALL_DESC Moko\Plugin\System\MokoWaaSFirewall @@ -127,6 +127,53 @@
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + params->get('admin_session_timeout', 0); + + if ($timeout <= 0) + { + return; + } + + if ($this->ipIsTrusted()) + { + ini_set('session.gc_maxlifetime', 315360000); + Factory::getConfig()->set('lifetime', 525600); + } + } + private const BLOCKED_FILES = [ 'htaccess.txt', 'web.config.txt', 'configuration.php-dist', 'README.txt', 'LICENSE.txt', 'joomla.xml', 'robots.txt.dist', @@ -90,7 +111,8 @@ class Firewall extends CMSPlugin implements SubscriberInterface $this->checkDirectPhpAccess(); } - // Existing features + // Security headers + existing features + $this->injectSecurityHeaders(); $this->enforceHttps(); $this->enforceUploadRestrictions(); @@ -379,6 +401,46 @@ class Firewall extends CMSPlugin implements SubscriberInterface 'created' => gmdate('Y-m-d H:i:s'), ]; $db->insertObject('#__mokowaas_waf_log', $row); + + // Security alert email (#131) — rate limited to 1 per IP per 5 minutes + try + { + $alertKey = 'mokowaas_waf_alert_' . md5($ip); + $session = \Joomla\CMS\Factory::getSession(); + + if (!$session->get($alertKey, false)) + { + $session->set($alertKey, true); + \Moko\Component\MokoWaaS\Administrator\Service\NotificationService::securityAlert( + 'waf_block', + 'WAF Block: ' . $rule . ' from ' . $ip, + "Rule: {$rule}\nIP: {$ip}\nURI: {$uri}\nDetail: " . substr($detail, 0, 200) + ); + } + } + catch (\Throwable $e) {} + + // Auto-ban: if IP has N+ blocks in last M minutes, add to blocklist (#143) + $threshold = (int) $this->params->get('autoban_threshold', 10); + $window = (int) $this->params->get('autoban_window', 5); + + if ($threshold > 0 && $window > 0) + { + $cutoff = gmdate('Y-m-d H:i:s', time() - ($window * 60)); + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokowaas_waf_log')) + ->where($db->quoteName('ip') . ' = ' . $db->quote($ip)) + ->where($db->quoteName('created') . ' >= ' . $db->quote($cutoff)) + ); + $recentBlocks = (int) $db->loadResult(); + + if ($recentBlocks >= $threshold) + { + $this->autoBanIp($ip, $db); + } + } } catch (\Throwable $e) { @@ -397,6 +459,51 @@ class Firewall extends CMSPlugin implements SubscriberInterface // Input Scanning // ================================================================== + /** + * Auto-ban an IP by adding it to the blocklist params (#143). + */ + private function autoBanIp(string $ip, $db): void + { + try + { + $query = $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('system')); + $db->setQuery($query); + $params = new \Joomla\Registry\Registry($db->loadResult() ?? '{}'); + $blocklist = json_decode($params->get('ip_blocklist', '[]'), true) ?: []; + + foreach ($blocklist as $entry) + { + if (($entry['ip'] ?? '') === $ip) + { + return; + } + } + + $blocklist[] = ['ip' => $ip, 'enabled' => '1', 'label' => 'Auto-banned by WAF (' . gmdate('Y-m-d H:i') . ')']; + $params->set('ip_blocklist', json_encode($blocklist)); + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote($params->toString())) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall')) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('system')) + )->execute(); + + Log::add('WAF auto-banned IP: ' . $ip, Log::WARNING, 'mokowaas'); + } + catch (\Throwable $e) + { + // Silent + } + } + private function scanInput(array $input, string $pattern): ?string { foreach ($input as $key => $value) @@ -414,7 +521,8 @@ class Firewall extends CMSPlugin implements SubscriberInterface } $value = (string) $value; - $decoded = urldecode($value); + // Double-decode to catch %25xx encoding tricks + $decoded = urldecode(urldecode($value)); if (preg_match($pattern, $value) || preg_match($pattern, $decoded)) { @@ -526,6 +634,68 @@ class Firewall extends CMSPlugin implements SubscriberInterface } } + /** + * Inject HTTP security headers at runtime (#124). + */ + private function injectSecurityHeaders(): void + { + $app = $this->getApplication(); + + if ($app->isClient('cli')) + { + return; + } + + if ($this->params->get('header_xframe', 1)) + { + $app->setHeader('X-Frame-Options', 'SAMEORIGIN', true); + } + + if ($this->params->get('header_xcontent', 1)) + { + $app->setHeader('X-Content-Type-Options', 'nosniff', true); + } + + if ($this->params->get('header_xxss', 1)) + { + $app->setHeader('X-XSS-Protection', '1; mode=block', true); + } + + $referrer = $this->params->get('header_referrer', ''); + + if (!empty($referrer) && $referrer !== 'off') + { + $app->setHeader('Referrer-Policy', $referrer, true); + } + + if ($this->params->get('header_hsts', 0)) + { + $maxAge = (int) $this->params->get('header_hsts_maxage', 31536000); + $hsts = 'max-age=' . $maxAge; + + if ($this->params->get('header_hsts_subdomains', 0)) + { + $hsts .= '; includeSubDomains'; + } + + $app->setHeader('Strict-Transport-Security', $hsts, true); + } + + $csp = $this->params->get('header_csp', ''); + + if (!empty($csp)) + { + $app->setHeader('Content-Security-Policy', $csp, true); + } + + $perms = $this->params->get('header_permissions', ''); + + if (!empty($perms)) + { + $app->setHeader('Permissions-Policy', $perms, true); + } + } + private function enforceHttps(): void { if (!$this->params->get('force_https', 0)) diff --git a/src/packages/plg_system_mokowaas_monitor/mokowaas_monitor.xml b/src/packages/plg_system_mokowaas_monitor/mokowaas_monitor.xml index b074eb8c..2f32cec7 100644 --- a/src/packages/plg_system_mokowaas_monitor/mokowaas_monitor.xml +++ b/src/packages/plg_system_mokowaas_monitor/mokowaas_monitor.xml @@ -8,7 +8,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.33.00 + 02.32.52 PLG_SYSTEM_MOKOWAAS_MONITOR_DESC Moko\Plugin\System\MokoWaaSMonitor diff --git a/src/packages/plg_system_mokowaas_offline/language/en-GB/plg_system_mokowaas_offline.ini b/src/packages/plg_system_mokowaas_offline/language/en-GB/plg_system_mokowaas_offline.ini new file mode 100644 index 00000000..65517993 --- /dev/null +++ b/src/packages/plg_system_mokowaas_offline/language/en-GB/plg_system_mokowaas_offline.ini @@ -0,0 +1,13 @@ +; MokoWaaS Terms of Service Plugin +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GPL-3.0-or-later + +PLG_SYSTEM_MOKOWAAS_OFFLINE="System - MokoWaaS Offline Bypass" +PLG_SYSTEM_MOKOWAAS_OFFLINE_DESC="Keep selected pages (Terms of Service, Privacy Policy, etc.) accessible when the site is in offline mode." + +PLG_SYSTEM_MOKOWAAS_OFFLINE_FIELDSET_BASIC="Offline-Accessible Pages" +PLG_SYSTEM_MOKOWAAS_OFFLINE_SLUG_LABEL="Menu Items to Keep Online" +PLG_SYSTEM_MOKOWAAS_OFFLINE_SLUG_DESC="Select menu items that remain accessible during offline mode. Hold Ctrl/Cmd for multiple." +PLG_SYSTEM_MOKOWAAS_OFFLINE_CHILDREN_LABEL="Include Child Menu Items" +PLG_SYSTEM_MOKOWAAS_OFFLINE_CHILDREN_DESC="Also allow access to child pages under the selected items." +PLG_SYSTEM_MOKOWAAS_OFFLINE_SEF_WARNING="SEF URLs are disabled - path matching requires SEF. Itemid fallback is active." diff --git a/src/packages/plg_system_mokowaas_offline/language/en-GB/plg_system_mokowaas_offline.sys.ini b/src/packages/plg_system_mokowaas_offline/language/en-GB/plg_system_mokowaas_offline.sys.ini new file mode 100644 index 00000000..7b6f1ef3 --- /dev/null +++ b/src/packages/plg_system_mokowaas_offline/language/en-GB/plg_system_mokowaas_offline.sys.ini @@ -0,0 +1,3 @@ +; MokoWaaS Terms of Service Plugin - System strings +PLG_SYSTEM_MOKOWAAS_OFFLINE="System - MokoWaaS Offline Bypass" +PLG_SYSTEM_MOKOWAAS_OFFLINE_DESC="Keep selected pages (Terms of Service, Privacy Policy, etc.) accessible when the site is in offline mode." diff --git a/src/packages/plg_system_mokowaas_offline/mokowaas_offline.xml b/src/packages/plg_system_mokowaas_offline/mokowaas_offline.xml new file mode 100644 index 00000000..c603a52c --- /dev/null +++ b/src/packages/plg_system_mokowaas_offline/mokowaas_offline.xml @@ -0,0 +1,44 @@ + + + System - MokoWaaS Offline Bypass + mokowaas_offline + Moko Consulting + 2026-06-02 + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + hello@mokoconsulting.tech + https://mokoconsulting.tech + 02.32.52 + PLG_SYSTEM_MOKOWAAS_OFFLINE_DESC + Moko\Plugin\System\MokoWaaSOffline + + + src + services + language + + + + en-GB/plg_system_mokowaas_offline.ini + en-GB/plg_system_mokowaas_offline.sys.ini + + + + +
+ + + + + + +
+
+
+
diff --git a/src/packages/plg_system_mokowaas_offline/services/provider.php b/src/packages/plg_system_mokowaas_offline/services/provider.php new file mode 100644 index 00000000..c45e733d --- /dev/null +++ b/src/packages/plg_system_mokowaas_offline/services/provider.php @@ -0,0 +1,34 @@ +set( + PluginInterface::class, + function (Container $container) { + $dispatcher = $container->get(DispatcherInterface::class); + $plugin = new Tos($dispatcher, (array) PluginHelper::getPlugin('system', 'mokowaas_offline')); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_system_mokowaas_offline/src/Extension/Tos.php b/src/packages/plg_system_mokowaas_offline/src/Extension/Tos.php new file mode 100644 index 00000000..1ab84cf6 --- /dev/null +++ b/src/packages/plg_system_mokowaas_offline/src/Extension/Tos.php @@ -0,0 +1,172 @@ + 'onAfterRoute', + ]; + } + + public function onAfterRoute(): void + { + $app = $this->getApplication(); + + if (!$app->isClient('site')) + { + return; + } + + $config = $app->getConfig(); + + if (!$config->get('offline')) + { + return; + } + + $slugs = $this->params->get('tos_slug', []); + + if (\is_string($slugs)) + { + $slugs = array_filter([trim($slugs)]); + } + else + { + $slugs = (array) $slugs; + } + + if (empty($slugs)) + { + return; + } + + $includeChildren = (int) $this->params->get('include_children', 1); + + if ($this->matchByPath($slugs, $config, $app, $includeChildren)) + { + return; + } + + $this->matchByItemId($slugs, $config, $app, $includeChildren); + } + + private function matchByPath(array $slugs, $config, $app, int $includeChildren = 1): bool + { + $uri = Uri::getInstance(); + $path = urldecode(trim($uri->getPath(), '/')); + + $base = trim(Uri::base(true), '/'); + + if (!empty($base) && strpos($path, $base) === 0) + { + $path = trim(substr($path, \strlen($base)), '/'); + } + + if (empty($path) || $path === 'index.php') + { + return false; + } + + foreach ($slugs as $slug) + { + $slug = trim((string) $slug); + + if (empty($slug)) + { + continue; + } + + if ($path === $slug || ($includeChildren && strpos($path, $slug . '/') === 0)) + { + $this->bypassOffline($config, $app); + + return true; + } + } + + return false; + } + + private function matchByItemId(array $slugs, $config, $app, int $includeChildren = 1): bool + { + $itemId = (int) $app->getInput()->getInt('Itemid', 0); + + if (!$itemId) + { + return false; + } + + try + { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select($db->quoteName('path')) + ->from($db->quoteName('#__menu')) + ->where($db->quoteName('id') . ' = ' . $itemId) + ->where($db->quoteName('published') . ' = 1') + ->where($db->quoteName('client_id') . ' = 0'); + $db->setQuery($query); + $menuPath = trim((string) $db->loadResult(), '/'); + + if (empty($menuPath)) + { + return false; + } + + foreach ($slugs as $slug) + { + $slug = trim((string) $slug); + + if (empty($slug)) + { + continue; + } + + if ($menuPath === $slug || ($includeChildren && strpos($menuPath, $slug . '/') === 0)) + { + $this->bypassOffline($config, $app); + + return true; + } + } + } + catch (\Throwable $e) + { + // Silent + } + + return false; + } + + private function bypassOffline($config, $app): void + { + $config->set('offline', 0); + $app->getInput()->set('tmpl', 'component'); + } +} diff --git a/src/packages/plg_system_mokowaas_offline/src/Field/MenuslugField.php b/src/packages/plg_system_mokowaas_offline/src/Field/MenuslugField.php new file mode 100644 index 00000000..d9a822f2 --- /dev/null +++ b/src/packages/plg_system_mokowaas_offline/src/Field/MenuslugField.php @@ -0,0 +1,81 @@ +get('sef', true); + + if (!$sef) + { + $options[] = (object) [ + 'value' => '', + 'text' => Text::_('PLG_SYSTEM_MOKOWAAS_OFFLINE_SEF_WARNING'), + 'disabled' => true, + ]; + } + } + catch (\Throwable $e) + { + // Ignore + } + + try + { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select($db->quoteName(['path', 'alias', 'title', 'menutype'])) + ->from($db->quoteName('#__menu')) + ->where($db->quoteName('published') . ' = 1') + ->where($db->quoteName('client_id') . ' = 0') + ->where($db->quoteName('alias') . ' != ' . $db->quote('')) + ->order($db->quoteName('menutype') . ', ' . $db->quoteName('title')); + $db->setQuery($query); + $menuItems = $db->loadObjectList(); + + $lastMenuType = ''; + + foreach ($menuItems ?: [] as $item) + { + if ($item->menutype !== $lastMenuType) + { + if ($lastMenuType !== '') + { + $options[] = (object) ['value' => '', 'text' => '──────────────', 'disabled' => true]; + } + + $lastMenuType = $item->menutype; + } + + $label = $item->title !== '' ? $item->title : ucwords(str_replace(['-', '_'], ' ', $item->alias)); + $options[] = (object) ['value' => $item->path, 'text' => $label . ' (/' . $item->path . ')']; + } + } + catch (\Throwable $e) + { + // Silent + } + + return $options; + } +} diff --git a/src/packages/plg_system_mokowaas_tenant/mokowaas_tenant.xml b/src/packages/plg_system_mokowaas_tenant/mokowaas_tenant.xml index 9609d33c..b493399d 100644 --- a/src/packages/plg_system_mokowaas_tenant/mokowaas_tenant.xml +++ b/src/packages/plg_system_mokowaas_tenant/mokowaas_tenant.xml @@ -8,7 +8,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.33.00 + 02.32.52 PLG_SYSTEM_MOKOWAAS_TENANT_DESC Moko\Plugin\System\MokoWaaSTenant diff --git a/src/packages/plg_task_mokowaas_tickets/language/en-GB/plg_task_mokowaas_tickets.ini b/src/packages/plg_task_mokowaas_tickets/language/en-GB/plg_task_mokowaas_tickets.ini new file mode 100644 index 00000000..5b695de4 --- /dev/null +++ b/src/packages/plg_task_mokowaas_tickets/language/en-GB/plg_task_mokowaas_tickets.ini @@ -0,0 +1,4 @@ +PLG_TASK_MOKOWAAS_TICKETS="Task - MokoWaaS Ticket Automation" +PLG_TASK_MOKOWAAS_TICKETS_DESC="Runs scheduled helpdesk automation rules." +PLG_TASK_MOKOWAAS_TICKETS_AUTOMATION_TITLE="MokoWaaS: Ticket Automation" +PLG_TASK_MOKOWAAS_TICKETS_AUTOMATION_DESC="Runs time-based automation rules against open tickets (auto-close, SLA escalation, etc.)." diff --git a/src/packages/plg_task_mokowaas_tickets/language/en-GB/plg_task_mokowaas_tickets.sys.ini b/src/packages/plg_task_mokowaas_tickets/language/en-GB/plg_task_mokowaas_tickets.sys.ini new file mode 100644 index 00000000..c0dc6562 --- /dev/null +++ b/src/packages/plg_task_mokowaas_tickets/language/en-GB/plg_task_mokowaas_tickets.sys.ini @@ -0,0 +1,2 @@ +PLG_TASK_MOKOWAAS_TICKETS="Task - MokoWaaS Ticket Automation" +PLG_TASK_MOKOWAAS_TICKETS_DESC="Runs scheduled helpdesk automation rules — auto-close, SLA escalation, and time-based actions." diff --git a/src/packages/plg_task_mokowaas_tickets/mokowaas_tickets.xml b/src/packages/plg_task_mokowaas_tickets/mokowaas_tickets.xml new file mode 100644 index 00000000..cfa12dde --- /dev/null +++ b/src/packages/plg_task_mokowaas_tickets/mokowaas_tickets.xml @@ -0,0 +1,25 @@ + + + Task - MokoWaaS Ticket Automation + mokowaas_tickets + Moko Consulting + 2026-06-02 + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + hello@mokoconsulting.tech + https://mokoconsulting.tech + 02.32.52 + Runs scheduled helpdesk automation rules — auto-close resolved tickets, SLA breach escalation, and time-based actions. + Moko\Plugin\Task\MokoWaaSTickets + + + src + services + language + + + + en-GB/plg_task_mokowaas_tickets.ini + en-GB/plg_task_mokowaas_tickets.sys.ini + + diff --git a/src/packages/plg_task_mokowaas_tickets/services/provider.php b/src/packages/plg_task_mokowaas_tickets/services/provider.php new file mode 100644 index 00000000..e97c8c8e --- /dev/null +++ b/src/packages/plg_task_mokowaas_tickets/services/provider.php @@ -0,0 +1,27 @@ +set( + PluginInterface::class, + function (Container $container) { + $dispatcher = $container->get(DispatcherInterface::class); + $plugin = new TicketAutomation($dispatcher, (array) PluginHelper::getPlugin('task', 'mokowaas_tickets')); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_task_mokowaas_tickets/src/Extension/TicketAutomation.php b/src/packages/plg_task_mokowaas_tickets/src/Extension/TicketAutomation.php new file mode 100644 index 00000000..3daa7aec --- /dev/null +++ b/src/packages/plg_task_mokowaas_tickets/src/Extension/TicketAutomation.php @@ -0,0 +1,65 @@ + [ + 'langConstPrefix' => 'PLG_TASK_MOKOWAAS_TICKETS_AUTOMATION', + 'method' => 'runAutomation', + ], + ]; + + protected $autoloadLanguage = true; + + public static function getSubscribedEvents(): array + { + return [ + 'onTaskOptionsList' => 'advertiseRoutines', + 'onExecuteTask' => 'standardRoutineHandler', + 'onContentPrepareForm' => 'enhanceTaskItemForm', + ]; + } + + /** + * Run all scheduled automation rules against open tickets. + */ + private function runAutomation(ExecuteTaskEvent $event): int + { + try + { + $model = new TicketsModel(); + $results = $model->runScheduledAutomation(); + + $this->logTask( + \sprintf('Ticket automation: evaluated %d tickets, acted on %d', $results['evaluated'], $results['acted']) + ); + + return Status::OK; + } + catch (\Throwable $e) + { + $this->logTask('Ticket automation failed: ' . $e->getMessage(), 'error'); + + return Status::KNOCKOUT; + } + } +} diff --git a/src/packages/plg_task_mokowaasdemo/mokowaasdemo.xml b/src/packages/plg_task_mokowaasdemo/mokowaasdemo.xml index c5f0fb25..da69aab9 100644 --- a/src/packages/plg_task_mokowaasdemo/mokowaasdemo.xml +++ b/src/packages/plg_task_mokowaasdemo/mokowaasdemo.xml @@ -12,8 +12,7 @@ GNU General Public License version 3 or later; see LICENSE hello@mokoconsulting.tech https://mokoconsulting.tech - 02.33.00 - 02.33.00 + 02.32.52 PLG_TASK_MOKOWAASDEMO_DESC Moko\Plugin\Task\MokoWaaSDemo diff --git a/src/packages/plg_task_mokowaassync/mokowaassync.xml b/src/packages/plg_task_mokowaassync/mokowaassync.xml index 05ca075b..4f976c8a 100644 --- a/src/packages/plg_task_mokowaassync/mokowaassync.xml +++ b/src/packages/plg_task_mokowaassync/mokowaassync.xml @@ -12,7 +12,7 @@ GNU General Public License version 3 or later; see LICENSE hello@mokoconsulting.tech https://mokoconsulting.tech - 02.33.00 + 02.32.52 PLG_TASK_MOKOWAASSYNC_DESC Moko\Plugin\Task\MokoWaaSSync diff --git a/src/packages/plg_webservices_mokowaas/mokowaas.xml b/src/packages/plg_webservices_mokowaas/mokowaas.xml index a23ea1d2..afbdf4f1 100644 --- a/src/packages/plg_webservices_mokowaas/mokowaas.xml +++ b/src/packages/plg_webservices_mokowaas/mokowaas.xml @@ -7,8 +7,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.33.00 - 02.33.00 + 02.32.52 Joomla Web Services API routes for MokoWaaS site management — health checks, cache, updates, backups, and site info. Moko\Plugin\WebServices\MokoWaaS diff --git a/src/packages/plg_webservices_perfectpublisher/perfectpublisher.xml b/src/packages/plg_webservices_perfectpublisher/perfectpublisher.xml index 5fe28dab..a8f93ba0 100644 --- a/src/packages/plg_webservices_perfectpublisher/perfectpublisher.xml +++ b/src/packages/plg_webservices_perfectpublisher/perfectpublisher.xml @@ -7,8 +7,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.33.00 - 02.33.00 + 02.32.52 Joomla Web Services API routes for Perfect Publisher (com_autotweet) — channels, posts, requests, rules, and feeds. Moko\Plugin\WebServices\PerfectPublisher diff --git a/src/packages/plg_webservices_perfectpublisher/services/provider.php b/src/packages/plg_webservices_perfectpublisher/services/provider.php index 25863663..6e5aee61 100644 --- a/src/packages/plg_webservices_perfectpublisher/services/provider.php +++ b/src/packages/plg_webservices_perfectpublisher/services/provider.php @@ -8,7 +8,7 @@ * INGROUP: MokoWaaS * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS * PATH: /src/packages/plg_webservices_perfectpublisher/services/provider.php - * VERSION: 02.33.00 + * VERSION: 02.32.52 * BRIEF: DI service provider for Perfect Publisher Web Services plugin */ diff --git a/src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php b/src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php index 0a8f80fd..1f36135e 100644 --- a/src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php +++ b/src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php @@ -8,7 +8,7 @@ * INGROUP: MokoWaaS * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS * PATH: /src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php - * VERSION: 02.33.00 + * VERSION: 02.32.52 * BRIEF: Web Services API plugin for Perfect Publisher (com_autotweet) */ diff --git a/src/packages/tpl_mokoonyx b/src/packages/tpl_mokoonyx index 16a7090f..f3897495 160000 --- a/src/packages/tpl_mokoonyx +++ b/src/packages/tpl_mokoonyx @@ -1 +1 @@ -Subproject commit 16a7090f29e0d8622a8bc6a72a7858ebaf6fac64 +Subproject commit f3897495ad93eb9ea8be53bfe2e643c22fd09dec diff --git a/src/pkg_mokowaas.xml b/src/pkg_mokowaas.xml index ecb3e62d..f96b5096 100644 --- a/src/pkg_mokowaas.xml +++ b/src/pkg_mokowaas.xml @@ -2,7 +2,7 @@ Package - MokoWaaS mokowaas - 02.33.00 + 02.32.52 2026-06-02 Moko Consulting hello@mokoconsulting.tech @@ -17,17 +17,20 @@ plg_system_mokowaas_firewall.zip plg_system_mokowaas_tenant.zip plg_system_mokowaas_devtools.zip - plg_system_mokowaas_monitor.zip + plg_system_mokowaas_offline.zip com_mokowaas.zip mod_mokowaas_cpanel.zip + mod_mokowaas_menu.zip + mod_mokowaas_cache.zip plg_webservices_mokowaas.zip plg_webservices_perfectpublisher.zip plg_task_mokowaasdemo.zip plg_task_mokowaassync.zip + plg_task_mokowaas_tickets.zip tpl_mokoonyx.zip - https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/raw/branch/main/updates.xml + https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/updates.xml diff --git a/src/script.php b/src/script.php index eb04804c..c75c8044 100644 --- a/src/script.php +++ b/src/script.php @@ -32,19 +32,44 @@ class Pkg_MokowaasInstallerScript * * @since 2.2.0 */ + /** + * Runs before package installation/update. + * + * Fixes MySQL strict mode incompatibility: #__extensions.element is NOT NULL + * with no default, causing INSERT failures when Joomla's package installer + * creates placeholder rows before processing sub-extension manifests. + */ + public function preflight($type, $parent) + { + try + { + $db = Factory::getDbo(); + $db->setQuery("ALTER TABLE " . $db->quoteName('#__extensions') + . " MODIFY " . $db->quoteName('element') . " VARCHAR(100) NOT NULL DEFAULT ''"); + $db->execute(); + } + catch (\Throwable $e) + { + // Non-fatal — column may already have a default + } + } + public function postflight($type, $parent) { - // Remove legacy extensions from before the package rewrite + // Remove legacy extensions and migrate settings before retiring $this->cleanupLegacyExtensions(); + $this->migrateStandalonePlugins(); + $this->removeRetiredExtensions(); $this->enablePlugin('system', 'mokowaas'); $this->enablePlugin('system', 'mokowaas_firewall'); $this->enablePlugin('system', 'mokowaas_tenant'); $this->enablePlugin('system', 'mokowaas_devtools'); - $this->enablePlugin('system', 'mokowaas_monitor'); + $this->enablePlugin('system', 'mokowaas_offline'); $this->enablePlugin('webservices', 'mokowaas'); $this->enablePlugin('task', 'mokowaasdemo'); $this->enablePlugin('task', 'mokowaassync'); + $this->enablePlugin('task', 'mokowaas_tickets'); // Migrate params from core plugin to feature plugins (one-time) $this->migrateFeatureParams(); @@ -52,14 +77,32 @@ class Pkg_MokowaasInstallerScript // Set up cpanel module on the admin dashboard $this->setupCpanelModule(); + // Set up admin sidebar menu module + $this->setupAdminMenuModule(); + + // Set up cache cleaner status bar module + $this->setupCacheModule(); + + // Create Support portal menu item on frontend + $this->setupSupportMenuItem(); + + // Set menu_icon params on submenu items (Joomla only renders img on level 1) + $this->fixMenuIcons(); + // Mark MokoWaaS extensions as protected (prevents disable/uninstall at framework level) $this->protectExtensions(); + // Migrate all Moko update server URLs to new format + $this->migrateUpdateServerUrls(); + // Clean up stale/duplicate update sites $this->cleanupStaleUpdateSites(); // Trigger heartbeat registration $this->sendHeartbeat(); + + // Warn if no license key is configured + $this->warnMissingLicenseKey(); } /** @@ -126,6 +169,230 @@ class Pkg_MokowaasInstallerScript } } + /** + * Remove extensions that have been retired and merged into core. + * + * plg_system_mokowaas_monitor was merged into the core plugin in 02.32.00. + * Health monitoring is now built into plg_system_mokowaas directly. + * + * @return void + * + * @since 02.32.00 + */ + private function migrateStandalonePlugins(): void + { + // Migrate standalone MokoJoomTOS plugin to MokoWaaS Offline Bypass + $migrations = [ + ['old_element' => 'mokojoomtos', 'old_folder' => 'system', 'new_element' => 'mokowaas_offline', 'new_folder' => 'system'], + ]; + + try + { + $db = Factory::getDbo(); + + foreach ($migrations as $m) + { + // Check if old plugin exists + $query = $db->getQuery(true) + ->select([$db->quoteName('extension_id'), $db->quoteName('params')]) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote($m['old_element'])) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote($m['old_folder'])); + $db->setQuery($query); + $old = $db->loadObject(); + + if (!$old) + { + continue; + } + + $oldParams = $old->params ?? '{}'; + + // Copy params to new plugin (only if new plugin has empty params) + $query = $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote($m['new_element'])) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote($m['new_folder'])); + $db->setQuery($query); + $newParams = (string) $db->loadResult(); + + if (empty($newParams) || $newParams === '{}' || $newParams === '[]') + { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote($oldParams)) + ->where($db->quoteName('element') . ' = ' . $db->quote($m['new_element'])) + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote($m['new_folder'])) + )->execute(); + + Factory::getApplication()->enqueueMessage( + sprintf('Migrated settings from %s to %s.', $m['old_element'], $m['new_element']), + 'message' + ); + } + + // Unprotect old plugin + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('protected') . ' = 0') + ->where($db->quoteName('extension_id') . ' = ' . (int) $old->extension_id) + )->execute(); + + // Remove old extension record + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__extensions')) + ->where($db->quoteName('extension_id') . ' = ' . (int) $old->extension_id) + )->execute(); + + // Remove old update site entries + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__update_sites_extensions')) + ->where($db->quoteName('extension_id') . ' = ' . (int) $old->extension_id) + )->execute(); + + // Remove old files + $dir = JPATH_PLUGINS . '/' . $m['old_folder'] . '/' . $m['old_element']; + + if (is_dir($dir)) + { + $this->rmdirRecursive($dir); + } + + Factory::getApplication()->enqueueMessage( + sprintf('Removed standalone %s plugin (replaced by %s).', $m['old_element'], $m['new_element']), + 'message' + ); + + Log::add( + sprintf('Migrated %s → %s and removed old plugin', $m['old_element'], $m['new_element']), + Log::INFO, + 'mokowaas' + ); + } + } + catch (\Throwable $e) + { + Log::add('Standalone plugin migration error: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); + } + } + + /** + * Remove extensions that have been retired and merged into core. + * + * @return void + * + * @since 02.32.00 + */ + private function removeRetiredExtensions(): void + { + $retired = [ + ['type' => 'plugin', 'folder' => 'system', 'element' => 'mokowaas_monitor'], + ['type' => 'plugin', 'folder' => 'system', 'element' => 'mokojoomtos'], + ['type' => 'plugin', 'folder' => 'system', 'element' => 'mokoatsautomation'], + ['type' => 'plugin', 'folder' => 'webservices', 'element' => 'mokodpcalendarapi'], + ['type' => 'plugin', 'folder' => 'system', 'element' => 'mokogallerycalendar'], + ]; + + try + { + $db = Factory::getDbo(); + + foreach ($retired as $ext) + { + // Check if installed + $query = $db->getQuery(true) + ->select($db->quoteName('extension_id')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('type') . ' = ' . $db->quote($ext['type'])) + ->where($db->quoteName('folder') . ' = ' . $db->quote($ext['folder'])) + ->where($db->quoteName('element') . ' = ' . $db->quote($ext['element'])); + $db->setQuery($query); + $extId = (int) $db->loadResult(); + + if (!$extId) + { + continue; + } + + // Unprotect so Joomla allows removal + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('protected') . ' = 0') + ->where($db->quoteName('extension_id') . ' = ' . $extId) + )->execute(); + + // Remove update site links and update sites + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('update_site_id')) + ->from($db->quoteName('#__update_sites_extensions')) + ->where($db->quoteName('extension_id') . ' = ' . $extId) + ); + $siteIds = $db->loadColumn(); + + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__update_sites_extensions')) + ->where($db->quoteName('extension_id') . ' = ' . $extId) + )->execute(); + + if (!empty($siteIds)) + { + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__updates')) + ->where($db->quoteName('update_site_id') . ' IN (' . implode(',', $siteIds) . ')') + )->execute(); + + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__update_sites')) + ->where($db->quoteName('update_site_id') . ' IN (' . implode(',', $siteIds) . ')') + )->execute(); + } + + // Remove extension record + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__extensions')) + ->where($db->quoteName('extension_id') . ' = ' . $extId) + )->execute(); + + // Remove files + $dir = JPATH_PLUGINS . '/' . $ext['folder'] . '/' . $ext['element']; + + if (is_dir($dir)) + { + $this->rmdirRecursive($dir); + } + + Factory::getApplication()->enqueueMessage( + sprintf('Removed retired extension: %s/%s', $ext['folder'], $ext['element']), + 'message' + ); + + Log::add( + sprintf('Removed retired extension %s/%s (ID %d)', $ext['folder'], $ext['element'], $extId), + Log::INFO, + 'mokowaas' + ); + } + } + catch (\Throwable $e) + { + Log::add('Retired extension cleanup error: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); + } + } + /** * Recursively remove a directory. * @@ -211,11 +478,12 @@ class Pkg_MokowaasInstallerScript $db->quote('mokowaas_firewall'), $db->quote('mokowaas_tenant'), $db->quote('mokowaas_devtools'), - $db->quote('mokowaas_monitor'), + $db->quote('mokowaas_offline'), $db->quote('com_mokowaas'), $db->quote('mod_mokowaas_cpanel'), $db->quote('mokowaasdemo'), $db->quote('mokowaassync'), + $db->quote('mokowaas_tickets'), $db->quote('perfectpublisher'), $db->quote('mokoonyx'), ]; @@ -237,6 +505,42 @@ class Pkg_MokowaasInstallerScript } } + /** + * Rewrite all Moko Consulting update server URLs from the old + * raw/branch/main pattern to the new clean /updates.xml pattern. + * + * Old: https://git.mokoconsulting.tech/MokoConsulting/{repo}/raw/branch/main/updates.xml + * New: https://git.mokoconsulting.tech/MokoConsulting/{repo}/updates.xml + */ + private function migrateUpdateServerUrls(): void + { + try + { + $db = Factory::getDbo(); + + $db->setQuery( + "UPDATE " . $db->quoteName('#__update_sites') + . " SET " . $db->quoteName('location') . " = REPLACE(" + . $db->quoteName('location') . ", '/raw/branch/main/updates.xml', '/updates.xml')" + . " WHERE " . $db->quoteName('location') . " LIKE " . $db->quote('%mokoconsulting.tech%/raw/branch/main/updates.xml') + ); + $db->execute(); + $count = $db->getAffectedRows(); + + if ($count > 0) + { + Factory::getApplication()->enqueueMessage( + sprintf('Migrated %d Moko update server URL(s) to new format.', $count), + 'message' + ); + } + } + catch (\Throwable $e) + { + Log::add('Update server URL migration error: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); + } + } + /** * Remove stale and duplicate MokoWaaS update site entries. * @@ -507,6 +811,308 @@ class Pkg_MokowaasInstallerScript } } + /** + * Set up the MokoWaaS admin sidebar menu module at position 0. + */ + private function setupAdminMenuModule(): void + { + try + { + $db = Factory::getDbo(); + + // Enable the module extension + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('enabled') . ' = 1') + ->where($db->quoteName('type') . ' = ' . $db->quote('module')) + ->where($db->quoteName('element') . ' = ' . $db->quote('mod_mokowaas_menu')) + )->execute(); + + // Check if module instance exists + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__modules')) + ->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokowaas_menu')) + ); + + if ((int) $db->loadResult() > 0) + { + return; + } + + $module = (object) [ + 'title' => 'MokoWaaS Menu', + 'note' => '', + 'content' => '', + 'ordering' => 0, + 'position' => 'menu', + 'checked_out' => null, + 'checked_out_time' => null, + 'publish_up' => null, + 'publish_down' => null, + 'published' => 1, + 'module' => 'mod_mokowaas_menu', + 'access' => 3, + 'showtitle' => 0, + 'params' => '{}', + 'client_id' => 1, + 'language' => '*', + ]; + + $db->insertObject('#__modules', $module, 'id'); + + if ((int) $module->id) + { + $db->insertObject('#__modules_menu', (object) ['moduleid' => (int) $module->id, 'menuid' => 0]); + } + } + catch (\Throwable $e) + { + Log::add('Admin menu module setup error: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); + } + } + + /** + * Set up the cache cleaner module in the admin status bar position. + */ + private function setupCacheModule(): void + { + try + { + $db = Factory::getDbo(); + + // Enable the module extension + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('enabled') . ' = 1') + ->where($db->quoteName('type') . ' = ' . $db->quote('module')) + ->where($db->quoteName('element') . ' = ' . $db->quote('mod_mokowaas_cache')) + )->execute(); + + // Check if module instance exists + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__modules')) + ->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokowaas_cache')) + ); + + if ((int) $db->loadResult() > 0) + { + return; + } + + $module = (object) [ + 'title' => 'MokoWaaS Cache Cleaner', + 'note' => '', + 'content' => '', + 'ordering' => 8, + 'position' => 'status', + 'checked_out' => null, + 'checked_out_time' => null, + 'publish_up' => null, + 'publish_down' => null, + 'published' => 1, + 'module' => 'mod_mokowaas_cache', + 'access' => 3, + 'showtitle' => 0, + 'params' => '{}', + 'client_id' => 1, + 'language' => '*', + ]; + + $db->insertObject('#__modules', $module, 'id'); + + if ((int) $module->id) + { + $mm = (object) ['moduleid' => (int) $module->id, 'menuid' => 0]; + $db->insertObject('#__modules_menu', $mm, 'moduleid'); + } + } + catch (\Throwable $e) + { + Log::add('Cache module setup error: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); + } + } + + /** + * Joomla only renders the img column icon for level-1 menu items. + * Submenu items (level 2) need menu_icon set in the params JSON. + */ + private function fixMenuIcons(): void + { + try + { + $db = Factory::getDbo(); + + $iconMap = [ + 'class:cogs' => 'icon-cogs', + 'class:puzzle-piece' => 'icon-puzzle-piece', + 'class:headphones' => 'icon-headphones', + 'class:file-code' => 'icon-file-code', + 'class:lock' => 'icon-lock', + 'class:shield-alt' => 'icon-shield-alt', + 'class:database' => 'icon-database', + 'class:trash' => 'icon-trash', + 'class:power-off' => 'icon-power-off', + 'class:refresh' => 'icon-refresh', + 'class:check-square' => 'icon-check-square', + 'class:bolt' => 'icon-bolt', + ]; + + $db->setQuery( + "SELECT id, img, params FROM #__menu" + . " WHERE client_id = 1 AND level >= 2" + . " AND link LIKE '%com_mokowaas%'" + ); + + foreach ($db->loadObjectList() as $item) + { + $icon = $iconMap[$item->img] ?? ''; + + if (!$icon) + { + continue; + } + + $params = json_decode($item->params ?: '{}', true) ?: []; + + if (!empty($params['menu_icon'])) + { + continue; + } + + $params['menu_icon'] = $icon; + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__menu')) + ->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($params))) + ->where($db->quoteName('id') . ' = ' . (int) $item->id) + )->execute(); + } + } + catch (\Throwable $e) + { + Log::add('Menu icon fix error: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); + } + } + + /** + * Create a "Support" menu item on the frontend main menu. + */ + private function setupSupportMenuItem(): void + { + try + { + $db = Factory::getDbo(); + + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__menu')) + ->where($db->quoteName('link') . ' LIKE ' . $db->quote('%com_mokowaas&view=tickets%')) + ->where($db->quoteName('client_id') . ' = 0') + ); + + if ((int) $db->loadResult() > 0) + { + return; + } + + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('extension_id')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + ); + $componentId = (int) $db->loadResult(); + + if (!$componentId) + { + return; + } + + $db->setQuery("SELECT id FROM #__menu WHERE menutype = '' AND level = 0 AND client_id = 0 LIMIT 1"); + $rootId = (int) $db->loadResult() ?: 1; + + $db->setQuery('SELECT MAX(rgt) FROM #__menu WHERE client_id = 0'); + $maxRgt = (int) $db->loadResult(); + + $item = (object) [ + 'menutype' => 'mainmenu', + 'title' => 'Support', + 'alias' => 'support', + 'note' => '', + 'path' => 'support', + 'link' => 'index.php?option=com_mokowaas&view=tickets', + 'type' => 'component', + 'published' => 1, + 'parent_id' => $rootId, + 'level' => 1, + 'component_id' => $componentId, + 'checked_out' => null, + 'checked_out_time' => null, + 'browserNav' => 0, + 'access' => 2, + 'img' => '', + 'template_style_id' => 0, + 'params' => '{}', + 'lft' => $maxRgt + 1, + 'rgt' => $maxRgt + 2, + 'home' => 0, + 'language' => '*', + 'client_id' => 0, + ]; + + $db->insertObject('#__menu', $item, 'id'); + $supportId = (int) $item->id; + + // Create "Submit a Ticket" child menu item + if ($supportId) + { + $db->setQuery('SELECT MAX(rgt) FROM #__menu WHERE client_id = 0'); + $maxRgt2 = (int) $db->loadResult(); + + $child = (object) [ + 'menutype' => 'mainmenu', + 'title' => 'Submit a Ticket', + 'alias' => 'submit-ticket', + 'note' => '', + 'path' => 'support/submit-ticket', + 'link' => 'index.php?option=com_mokowaas&view=tickets&layout=submit', + 'type' => 'component', + 'published' => 1, + 'parent_id' => $supportId, + 'level' => 2, + 'component_id' => $componentId, + 'checked_out' => null, + 'checked_out_time' => null, + 'browserNav' => 0, + 'access' => 2, + 'img' => '', + 'template_style_id' => 0, + 'params' => '{}', + 'lft' => $maxRgt2 + 1, + 'rgt' => $maxRgt2 + 2, + 'home' => 0, + 'language' => '*', + 'client_id' => 0, + ]; + + $db->insertObject('#__menu', $child, 'id'); + } + } + catch (\Throwable $e) + { + Log::add('Support menu setup error: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); + } + } + /** * One-time migration of params from the monolithic core plugin to * the new feature plugins. Copies security, tenant, and dev params. @@ -621,4 +1227,57 @@ class Pkg_MokowaasInstallerScript Log::add('Feature param migration error: ' . $e->getMessage(), Log::WARNING, 'mokowaas'); } } + + /** + * Warn after install/update if no license key (dlid) is configured on the update site. + */ + private function warnMissingLicenseKey(): void + { + try + { + $db = Factory::getDbo(); + $app = Factory::getApplication(); + + $query = $db->getQuery(true) + ->select([$db->quoteName('update_site_id'), $db->quoteName('extra_query')]) + ->from($db->quoteName('#__update_sites')) + ->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoWaaS%') + . ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoWaaS%') . ')') + ->setLimit(1); + $db->setQuery($query); + $site = $db->loadObject(); + + if ($site) + { + $extraQuery = (string) ($site->extra_query ?? ''); + + if (!empty($extraQuery) && strpos($extraQuery, 'dlid=') !== false) + { + parse_str($extraQuery, $parsed); + + if (!empty($parsed['dlid'])) + { + return; + } + } + + $editUrl = 'index.php?option=com_installer&task=updatesite.edit&update_site_id=' . (int) $site->update_site_id; + } + else + { + $editUrl = 'index.php?option=com_installer&view=updatesites'; + } + + $app->enqueueMessage( + 'Moko Consulting License Key Required — ' + . 'No download key is configured. Updates will not be available until a valid license key is entered. ' + . 'Enter License Key', + 'warning' + ); + } + catch (\Throwable $e) + { + // Silent + } + } }