Release 02.33.00 - admin tools suite expansion #152
@@ -122,6 +122,7 @@ build/
|
||||
dist/
|
||||
out/
|
||||
site/
|
||||
!src/packages/*/site/
|
||||
*.map
|
||||
*.css.map
|
||||
*.js.map
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<display-name>Package - MokoWaaS</display-name>
|
||||
<org>MokoConsulting</org>
|
||||
<description>White-label identity, security hardening, and tenant restriction layer for WaaS-managed Joomla environments</description>
|
||||
<version>02.33.00</version>
|
||||
<version>02.32.52</version>
|
||||
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
|
||||
</identity>
|
||||
<governance>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# 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] <gitea-actions[bot]@mokoconsulting.tech>"
|
||||
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
|
||||
+44
-2
@@ -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 `<languages>` 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 `<em>Unassigned</em>` 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).
|
||||
|
||||
+1
-1
@@ -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
|
||||
-->
|
||||
|
||||
+1
-1
@@ -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
|
||||
-->
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
-->
|
||||
|
||||
+1
-1
@@ -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
|
||||
-->
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
-->
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<access component="com_mokowaas">
|
||||
<section name="component">
|
||||
<action name="core.admin" title="JACTION_ADMIN" description="JACTION_ADMIN_COMPONENT_DESC" />
|
||||
<action name="core.manage" title="JACTION_MANAGE" description="JACTION_MANAGE_COMPONENT_DESC" />
|
||||
<action name="mokowaas.dashboard" title="COM_MOKOWAAS_ACL_DASHBOARD" description="COM_MOKOWAAS_ACL_DASHBOARD_DESC" />
|
||||
<action name="mokowaas.extensions" title="COM_MOKOWAAS_ACL_EXTENSIONS" description="COM_MOKOWAAS_ACL_EXTENSIONS_DESC" />
|
||||
<action name="mokowaas.htaccess" title="COM_MOKOWAAS_ACL_HTACCESS" description="COM_MOKOWAAS_ACL_HTACCESS_DESC" />
|
||||
<action name="mokowaas.tickets" title="COM_MOKOWAAS_ACL_TICKETS" description="COM_MOKOWAAS_ACL_TICKETS_DESC" />
|
||||
<action name="mokowaas.tickets.create" title="COM_MOKOWAAS_ACL_TICKETS_CREATE" description="COM_MOKOWAAS_ACL_TICKETS_CREATE_DESC" />
|
||||
<action name="mokowaas.tickets.assign" title="COM_MOKOWAAS_ACL_TICKETS_ASSIGN" description="COM_MOKOWAAS_ACL_TICKETS_ASSIGN_DESC" />
|
||||
<action name="mokowaas.plugins.toggle" title="COM_MOKOWAAS_ACL_PLUGINS_TOGGLE" description="COM_MOKOWAAS_ACL_PLUGINS_TOGGLE_DESC" />
|
||||
<action name="mokowaas.cache" title="COM_MOKOWAAS_ACL_CACHE" description="COM_MOKOWAAS_ACL_CACHE_DESC" />
|
||||
</section>
|
||||
</access>
|
||||
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<config>
|
||||
<fieldset name="notifications" label="Email Notifications" description="Configure email recipients for ticket and security notifications.">
|
||||
<field name="admin_emails" type="text" default=""
|
||||
label="Admin Email Addresses"
|
||||
description="Comma-separated email addresses to receive all notifications."
|
||||
hint="admin@example.com, support@example.com" />
|
||||
<field name="admin_user_ids" type="text" default=""
|
||||
label="Admin User IDs"
|
||||
description="Comma-separated Joomla user IDs to receive notifications."
|
||||
hint="320, 321" />
|
||||
<field name="security_alerts" type="radio" default="1"
|
||||
label="Security Alerts"
|
||||
description="Send email alerts for WAF blocks and admin logins."
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="helpdesk" label="Helpdesk Settings" description="Default helpdesk behavior.">
|
||||
<field name="default_category" type="sql" default=""
|
||||
label="Default Ticket Category"
|
||||
description="Category assigned to tickets without a selection."
|
||||
query="SELECT id AS value, title AS text FROM #__mokowaas_ticket_categories WHERE published = 1 ORDER BY ordering" />
|
||||
<field name="autoclose_days" type="number" default="7"
|
||||
label="Auto-Close After (days)"
|
||||
description="Resolved tickets are auto-closed after this many days. 0 = disabled." />
|
||||
<field name="kb_search_enabled" type="radio" default="1"
|
||||
label="KB Search on Ticket Forms"
|
||||
description="Show knowledge base search before ticket submission."
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="permissions" label="COM_MOKOWAAS_ACL_TITLE"
|
||||
description="COM_MOKOWAAS_ACL_DESC">
|
||||
<field name="rules" type="rules"
|
||||
label="COM_MOKOWAAS_ACL_TITLE"
|
||||
validate="rules"
|
||||
filter="rules"
|
||||
component="com_mokowaas"
|
||||
section="component" />
|
||||
</fieldset>
|
||||
</config>
|
||||
@@ -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."
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)');
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
use Joomla\Registry\Registry;
|
||||
|
||||
/**
|
||||
* .htaccess / NginX configuration generator.
|
||||
*
|
||||
* @since 02.32.00
|
||||
*/
|
||||
class HtaccessModel extends BaseDatabaseModel
|
||||
{
|
||||
private const DEFAULTS = [
|
||||
// Security
|
||||
'disable_directory_listing' => 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[] = '<IfModule mod_headers.c>';
|
||||
$lines[] = ' Header unset X-Powered-By';
|
||||
$lines[] = ' Header unset Server';
|
||||
$lines[] = '</IfModule>';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
if (!empty($opts['block_sensitive_files']))
|
||||
{
|
||||
$lines[] = '## Block access to sensitive files';
|
||||
$lines[] = '<FilesMatch "^(htaccess\.txt|web\.config\.txt|configuration\.php-dist|README\.txt|LICENSE\.txt|joomla\.xml|robots\.txt\.dist)$">';
|
||||
$lines[] = ' <IfModule mod_authz_core.c>';
|
||||
$lines[] = ' Require all denied';
|
||||
$lines[] = ' </IfModule>';
|
||||
$lines[] = '</FilesMatch>';
|
||||
$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[] = '<Directory "' . $dir . '">';
|
||||
$lines[] = ' <FilesMatch "\.php$">';
|
||||
$lines[] = ' <IfModule mod_authz_core.c>';
|
||||
$lines[] = ' Require all denied';
|
||||
$lines[] = ' </IfModule>';
|
||||
$lines[] = ' </FilesMatch>';
|
||||
$lines[] = '</Directory>';
|
||||
}
|
||||
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
if (!empty($opts['disable_trace_track']))
|
||||
{
|
||||
$lines[] = '## Disable TRACE and TRACK methods';
|
||||
$lines[] = '<IfModule mod_rewrite.c>';
|
||||
$lines[] = ' RewriteEngine On';
|
||||
$lines[] = ' RewriteCond %{REQUEST_METHOD} ^(TRACE|TRACK)';
|
||||
$lines[] = ' RewriteRule .* - [F]';
|
||||
$lines[] = '</IfModule>';
|
||||
$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[] = '<IfModule mod_headers.c>';
|
||||
$lines = array_merge($lines, $headers);
|
||||
$lines[] = '</IfModule>';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
// --- Performance ---
|
||||
if (!empty($opts['enable_gzip']))
|
||||
{
|
||||
$lines[] = '## GZip compression';
|
||||
$lines[] = '<IfModule mod_deflate.c>';
|
||||
$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[] = '</IfModule>';
|
||||
$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[] = '<IfModule mod_expires.c>';
|
||||
$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[] = '</IfModule>';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
if (!empty($opts['etag_control']))
|
||||
{
|
||||
$lines[] = '## Disable ETags (for load-balanced environments)';
|
||||
$lines[] = '<IfModule mod_headers.c>';
|
||||
$lines[] = ' Header unset ETag';
|
||||
$lines[] = '</IfModule>';
|
||||
$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[] = '<IfModule mod_rewrite.c>';
|
||||
$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[] = '</IfModule>';
|
||||
$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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
use Joomla\Registry\Registry;
|
||||
|
||||
/**
|
||||
* Importer for migrating from Akeeba Admin Tools to MokoWaaS.
|
||||
*
|
||||
* Reads Admin Tools WAF config, htaccess settings, IP blocklists,
|
||||
* and security headers — maps them to MokoWaaS firewall plugin params
|
||||
* and htaccess maker options.
|
||||
*
|
||||
* @since 02.32.00
|
||||
*/
|
||||
class ImportModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* Check if Admin Tools data is available for import.
|
||||
* Returns null if already imported or no data found.
|
||||
*/
|
||||
public function checkAdminToolsAvailable(): ?object
|
||||
{
|
||||
if ($this->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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoWaaS\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
|
||||
class MaintenanceModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* Get database table status (size, rows, engine, overhead).
|
||||
*/
|
||||
public function getTableStatus(): array
|
||||
{
|
||||
$db = $this->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()];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
|
||||
class PrivacyModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* Get all pending data requests.
|
||||
*/
|
||||
public function getDataRequests(string $filterStatus = ''): array
|
||||
{
|
||||
$db = $this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,945 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
use Moko\Component\MokoWaaS\Administrator\Service\NotificationService;
|
||||
|
||||
class TicketsModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* Get ticket list with filters.
|
||||
*/
|
||||
public function getTickets(array $filters = []): array
|
||||
{
|
||||
$db = $this->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()];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
|
||||
class WaflogModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* Get WAF log entries with filters and pagination.
|
||||
*/
|
||||
public function getLogs(array $filters = [], int $limit = 50, int $offset = 0): array
|
||||
{
|
||||
$db = $this->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()];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Administrator\Service;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
|
||||
/**
|
||||
* Helpdesk email notification service.
|
||||
*
|
||||
* Sends emails for ticket events to Joomla users (by ID) and/or
|
||||
* raw email addresses. Uses Joomla's configured mailer.
|
||||
*
|
||||
* @since 02.32.00
|
||||
*/
|
||||
class NotificationService
|
||||
{
|
||||
/**
|
||||
* Send a ticket notification email.
|
||||
*
|
||||
* @param string $event Event name (ticket_created, ticket_replied, status_changed, ticket_assigned)
|
||||
* @param object $ticket Ticket object with id, subject, status, priority, created_by, assigned_to
|
||||
* @param array $extra Extra context (reply body, old status, etc.)
|
||||
*/
|
||||
public static function notify(string $event, object $ticket, array $extra = []): void
|
||||
{
|
||||
try
|
||||
{
|
||||
$recipients = self::getRecipients($event, $ticket);
|
||||
|
||||
if (empty($recipients))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$subject = self::buildSubject($event, $ticket);
|
||||
$body = self::buildBody($event, $ticket, $extra);
|
||||
|
||||
$mailer = Factory::getMailer();
|
||||
$mailer->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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoWaaS\Administrator\View\Automation;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $rules = [];
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$model = new \Moko\Component\MokoWaaS\Administrator\Model\TicketsModel();
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoWaaS\Administrator\View\Canned;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $responses = [];
|
||||
protected $categories = [];
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$db = Factory::getContainer()->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoWaaS\Administrator\View\Categories;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $categories = [];
|
||||
protected $users = [];
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$db = Factory::getContainer()->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoWaaS\Administrator\View\Cleanup;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $dirs = [];
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$model = new \Moko\Component\MokoWaaS\Administrator\Model\MaintenanceModel();
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoWaaS\Administrator\View\Database;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $tableData = [];
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$model = new \Moko\Component\MokoWaaS\Administrator\Model\MaintenanceModel();
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Administrator\View\Htaccess;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $options = [];
|
||||
protected $preview = '';
|
||||
protected $nginxPreview = '';
|
||||
protected $currentHtaccess = '';
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$model = $this->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoWaaS\Administrator\View\Privacy;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $requests = [];
|
||||
protected $policies = [];
|
||||
protected $summary;
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$model = new \Moko\Component\MokoWaaS\Administrator\Model\PrivacyModel();
|
||||
|
||||
$filterStatus = Factory::getApplication()->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Administrator\View\Ticket;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $ticket;
|
||||
protected $cannedResponses = [];
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$model = $this->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Administrator\View\Tickets;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $tickets = [];
|
||||
protected $categories = [];
|
||||
protected $statusCounts;
|
||||
protected $overdue = [];
|
||||
protected $atsAvailable = null;
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$model = $this->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoWaaS\Administrator\View\Waflog;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $logs = [];
|
||||
protected $ruleCounts = [];
|
||||
protected $topIps = [];
|
||||
protected $ruleNames = [];
|
||||
protected $total = 0;
|
||||
protected $filters = [];
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$model = new \Moko\Component\MokoWaaS\Administrator\Model\WaflogModel();
|
||||
$input = Factory::getApplication()->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$rules = $this->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)'];
|
||||
?>
|
||||
|
||||
<div id="mokowaas-automation">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4><?php echo count($rules); ?> Automation Rules</h4>
|
||||
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newRuleModal">
|
||||
<span class="icon-plus"></span> Add Rule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<?php foreach ($rules as $r): ?>
|
||||
<?php $conditions = json_decode($r->conditions, true) ?: []; $actions = json_decode($r->actions, true) ?: []; ?>
|
||||
<div class="card mb-2 <?php echo !$r->enabled ? 'opacity-50' : ''; ?>" data-id="<?php echo $r->id; ?>">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" class="form-check-input rule-toggle" data-id="<?php echo $r->id; ?>" <?php echo $r->enabled ? 'checked' : ''; ?>>
|
||||
</div>
|
||||
<strong><?php echo htmlspecialchars($r->title); ?></strong>
|
||||
<span class="badge bg-secondary"><?php echo $triggerLabels[$r->trigger_event] ?? $r->trigger_event; ?></span>
|
||||
</div>
|
||||
<div class="small text-muted mt-1">
|
||||
<span class="text-primary">IF</span>
|
||||
<?php foreach ($conditions as $i => $c): ?>
|
||||
<?php echo $i > 0 ? ' AND ' : ''; ?><?php echo htmlspecialchars($c['field'] ?? ''); ?> <?php echo htmlspecialchars($c['op'] ?? ''); ?> <?php echo htmlspecialchars($c['value'] ?? ''); ?>
|
||||
<?php endforeach; ?>
|
||||
<span class="text-success ms-2">THEN</span>
|
||||
<?php foreach ($actions as $a): ?>
|
||||
<?php echo htmlspecialchars($a['type'] ?? ''); ?>=<?php echo htmlspecialchars(mb_substr($a['value'] ?? '', 0, 30)); ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-rule" data-id="<?php echo $r->id; ?>">
|
||||
<span class="icon-trash"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (empty($rules)): ?>
|
||||
<div class="alert alert-info">No automation rules. Click "Add Rule" to create one.</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- New Rule Modal -->
|
||||
<div class="modal fade" id="newRuleModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header"><h5>Add Automation Rule</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Title</label>
|
||||
<input type="text" id="rule-title" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Trigger</label>
|
||||
<select id="rule-trigger" class="form-select">
|
||||
<?php foreach ($triggerLabels as $k => $v): ?><option value="<?php echo $k; ?>"><?php echo $v; ?></option><?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Conditions (JSON)</label>
|
||||
<textarea id="rule-conditions" class="form-control font-monospace" rows="3" placeholder='[{"field":"status","op":"eq","value":"resolved"}]'></textarea>
|
||||
<small class="text-muted">Fields: status, priority, category_id, assigned_to, sla_responded, age_hours. Ops: eq, neq, gt, lt, in, not_in</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Actions (JSON)</label>
|
||||
<textarea id="rule-actions" class="form-control font-monospace" rows="3" placeholder='[{"type":"set_status","value":"closed"}]'></textarea>
|
||||
<small class="text-muted">Types: set_status, set_priority, assign, add_note, send_email</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="btn-save-rule"><span class="icon-save"></span> Save Rule</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var token = '<?php echo $token; ?>';
|
||||
|
||||
// Save new rule
|
||||
document.getElementById('btn-save-rule').addEventListener('click', function() {
|
||||
var fd = new FormData();
|
||||
fd.append('id', '0');
|
||||
fd.append('title', document.getElementById('rule-title').value);
|
||||
fd.append('trigger_event', document.getElementById('rule-trigger').value);
|
||||
fd.append('conditions', document.getElementById('rule-conditions').value || '[]');
|
||||
fd.append('actions', document.getElementById('rule-actions').value || '[]');
|
||||
fd.append(token, '1');
|
||||
fetch('<?php echo $saveUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){ if (d.success) location.reload(); else Joomla.renderMessages({error:[d.message]}); });
|
||||
});
|
||||
|
||||
// Toggle rule
|
||||
document.querySelectorAll('.rule-toggle').forEach(function(cb) {
|
||||
cb.addEventListener('change', function() {
|
||||
var fd = new FormData();
|
||||
fd.append('id', this.dataset.id);
|
||||
fd.append('enabled', this.checked ? '1' : '0');
|
||||
fd.append(token, '1');
|
||||
fetch('<?php echo $toggleUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){ if (!d.success) Joomla.renderMessages({error:[d.message]}); });
|
||||
});
|
||||
});
|
||||
|
||||
// Delete rule
|
||||
document.querySelectorAll('.btn-delete-rule').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
if (!confirm('Delete this rule?')) return;
|
||||
var card = this.closest('.card');
|
||||
var fd = new FormData();
|
||||
fd.append('id', this.dataset.id);
|
||||
fd.append(token, '1');
|
||||
fetch('<?php echo $deleteUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){ if (d.success) card.remove(); else Joomla.renderMessages({error:[d.message]}); });
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$responses = $this->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');
|
||||
?>
|
||||
|
||||
<div id="mokowaas-canned">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4><?php echo count($responses); ?> Canned Responses</h4>
|
||||
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newCannedModal">
|
||||
<span class="icon-plus"></span> Add Response
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<?php foreach ($responses as $r): ?>
|
||||
<div class="card mb-2" data-id="<?php echo $r->id; ?>">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<strong><?php echo htmlspecialchars($r->title); ?></strong>
|
||||
<p class="text-muted small mb-0 mt-1"><?php echo htmlspecialchars(mb_substr($r->body, 0, 150)); ?></p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-canned" data-id="<?php echo $r->id; ?>">
|
||||
<span class="icon-trash"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (empty($responses)): ?>
|
||||
<div class="alert alert-info">No canned responses yet. Click "Add Response" to create one.</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- New Canned Modal -->
|
||||
<div class="modal fade" id="newCannedModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header"><h5>Add Canned Response</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Title</label>
|
||||
<input type="text" id="canned-title" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Category (optional)</label>
|
||||
<select id="canned-category" class="form-select">
|
||||
<option value="">All categories</option>
|
||||
<?php foreach ($categories as $cat): ?>
|
||||
<option value="<?php echo $cat->id; ?>"><?php echo htmlspecialchars($cat->title); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Response Text</label>
|
||||
<textarea id="canned-body" class="form-control" rows="6" required></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="btn-save-canned"><span class="icon-save"></span> Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var token = '<?php echo $token; ?>';
|
||||
|
||||
document.getElementById('btn-save-canned').addEventListener('click', function() {
|
||||
var fd = new FormData();
|
||||
fd.append('id', '0');
|
||||
fd.append('title', document.getElementById('canned-title').value);
|
||||
fd.append('body', document.getElementById('canned-body').value);
|
||||
fd.append('category_id', document.getElementById('canned-category').value);
|
||||
fd.append(token, '1');
|
||||
fetch('<?php echo $saveUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){
|
||||
if (d.success) location.reload();
|
||||
else Joomla.renderMessages({error:[d.message]});
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.btn-delete-canned').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
if (!confirm('Delete this canned response?')) return;
|
||||
var card = this.closest('.card');
|
||||
var fd = new FormData();
|
||||
fd.append('id', this.dataset.id);
|
||||
fd.append(token, '1');
|
||||
fetch('<?php echo $deleteUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){ if (d.success) card.remove(); else Joomla.renderMessages({error:[d.message]}); });
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$categories = $this->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');
|
||||
?>
|
||||
|
||||
<div id="mokowaas-categories">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4><?php echo count($categories); ?> Categories</h4>
|
||||
<button type="button" class="btn btn-primary btn-sm" id="btn-add-cat">
|
||||
<span class="icon-plus"></span> Add Category
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped mb-0" id="cat-table">
|
||||
<thead><tr><th>Title</th><th>SLA Response</th><th>SLA Resolution</th><th>Auto-Assign</th><th>Active</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($categories as $c): ?>
|
||||
<tr data-id="<?php echo $c->id; ?>">
|
||||
<td><input type="text" class="form-control form-control-sm cat-field" data-field="title" value="<?php echo htmlspecialchars($c->title); ?>"></td>
|
||||
<td><input type="number" class="form-control form-control-sm cat-field" data-field="sla_response_minutes" value="<?php echo $c->sla_response_minutes; ?>" style="width:80px"> min</td>
|
||||
<td><input type="number" class="form-control form-control-sm cat-field" data-field="sla_resolution_minutes" value="<?php echo $c->sla_resolution_minutes; ?>" style="width:80px"> min</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm cat-field" data-field="auto_assign_user">
|
||||
<option value="">None</option>
|
||||
<?php foreach ($users as $u): ?>
|
||||
<option value="<?php echo $u->id; ?>" <?php echo (int)$c->auto_assign_user === (int)$u->id ? 'selected' : ''; ?>><?php echo htmlspecialchars($u->name); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" class="form-check-input cat-field" data-field="published" <?php echo $c->published ? 'checked' : ''; ?>>
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-sm btn-outline-success btn-save-cat" title="Save"><span class="icon-save"></span></button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-cat" title="Delete"><span class="icon-trash"></span></button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var token = '<?php echo $token; ?>';
|
||||
|
||||
// Save category
|
||||
document.querySelectorAll('.btn-save-cat').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var row = this.closest('tr');
|
||||
var id = row.dataset.id || '0';
|
||||
var fd = new FormData();
|
||||
fd.append('id', id);
|
||||
fd.append(token, '1');
|
||||
row.querySelectorAll('.cat-field').forEach(function(f) {
|
||||
fd.append(f.dataset.field, f.type === 'checkbox' ? (f.checked ? '1' : '0') : f.value);
|
||||
});
|
||||
fetch('<?php echo $saveUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){
|
||||
if (d.success) { Joomla.renderMessages({message:[d.message]}); if (d.id && id === '0') row.dataset.id = d.id; }
|
||||
else Joomla.renderMessages({error:[d.message]});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Delete category
|
||||
document.querySelectorAll('.btn-delete-cat').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
if (!confirm('Delete this category?')) return;
|
||||
var row = this.closest('tr');
|
||||
var fd = new FormData();
|
||||
fd.append('id', row.dataset.id);
|
||||
fd.append(token, '1');
|
||||
fetch('<?php echo $deleteUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){
|
||||
if (d.success) row.remove();
|
||||
else Joomla.renderMessages({error:[d.message]});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Add new row
|
||||
document.getElementById('btn-add-cat').addEventListener('click', function() {
|
||||
var tbody = document.querySelector('#cat-table tbody');
|
||||
var tr = document.createElement('tr');
|
||||
tr.dataset.id = '0';
|
||||
tr.innerHTML = '<td><input type="text" class="form-control form-control-sm cat-field" data-field="title" value=""></td>'
|
||||
+ '<td><input type="number" class="form-control form-control-sm cat-field" data-field="sla_response_minutes" value="480" style="width:80px"> min</td>'
|
||||
+ '<td><input type="number" class="form-control form-control-sm cat-field" data-field="sla_resolution_minutes" value="2880" style="width:80px"> min</td>'
|
||||
+ '<td><select class="form-select form-select-sm cat-field" data-field="auto_assign_user"><option value="">None</option><?php foreach ($users as $u): ?><option value="<?php echo $u->id; ?>"><?php echo htmlspecialchars($u->name); ?></option><?php endforeach; ?></select></td>'
|
||||
+ '<td><input type="checkbox" class="form-check-input cat-field" data-field="published" checked></td>'
|
||||
+ '<td><button type="button" class="btn btn-sm btn-outline-success btn-save-cat"><span class="icon-save"></span></button></td>';
|
||||
tbody.appendChild(tr);
|
||||
tr.querySelector('.btn-save-cat').addEventListener('click', function() {
|
||||
var row = this.closest('tr');
|
||||
var fd = new FormData();
|
||||
fd.append('id', '0');
|
||||
fd.append(token, '1');
|
||||
row.querySelectorAll('.cat-field').forEach(function(f) {
|
||||
fd.append(f.dataset.field, f.type === 'checkbox' ? (f.checked ? '1' : '0') : f.value);
|
||||
});
|
||||
fetch('<?php echo $saveUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){
|
||||
if (d.success) { Joomla.renderMessages({message:[d.message]}); location.reload(); }
|
||||
else Joomla.renderMessages({error:[d.message]});
|
||||
});
|
||||
});
|
||||
tr.querySelector('input').focus();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$dirs = $this->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; }
|
||||
?>
|
||||
|
||||
<div id="mokowaas-cleanup">
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-3"><div class="card text-center p-3"><span class="fw-bold fs-3"><?php echo number_format($totalMb, 1); ?> MB</span><small class="text-muted">Total Size</small></div></div>
|
||||
<div class="col-6 col-md-3"><div class="card text-center p-3"><span class="fw-bold fs-3"><?php echo number_format($totalFiles); ?></span><small class="text-muted">Total Files</small></div></div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<?php foreach ($dirs as $i => $d): ?>
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<h5><?php echo htmlspecialchars($d->label); ?></h5>
|
||||
<p class="fs-3 fw-bold mb-1 <?php echo $d->size_mb > 50 ? 'text-warning' : ''; ?>"><?php echo number_format($d->size_mb, 1); ?> MB</p>
|
||||
<p class="text-muted small"><?php echo number_format($d->files); ?> files</p>
|
||||
<?php if (!$d->writable): ?>
|
||||
<span class="badge bg-danger">Not writable</span>
|
||||
<?php else: ?>
|
||||
<button type="button" class="btn btn-outline-danger btn-clean" data-key="<?php echo $dirKeys[$i] ?? ''; ?>" data-label="<?php echo htmlspecialchars($d->label); ?>">
|
||||
<span class="icon-trash"></span> Clean
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.btn-clean').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
if (!confirm('Clean all files in ' + this.dataset.label + '?')) return;
|
||||
var el = this;
|
||||
el.disabled = true;
|
||||
var fd = new FormData();
|
||||
fd.append('dir_key', el.dataset.key);
|
||||
fd.append('<?php echo $token; ?>', '1');
|
||||
fetch('<?php echo $cleanUrl; ?>', {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()},1500); }
|
||||
else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; }
|
||||
})
|
||||
.catch(function(){ el.disabled = false; });
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -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'];
|
||||
<?php if ($siteInfo->offline): ?>
|
||||
<span class="badge bg-danger"><?php echo Text::_('COM_MOKOWAAS_OFFLINE'); ?></span>
|
||||
<?php endif; ?>
|
||||
<div class="mokowaas-info-item ms-auto">
|
||||
<span class="icon-globe" aria-hidden="true"></span>
|
||||
<code><?php echo $this->escape($_SERVER['REMOTE_ADDR'] ?? ''); ?></code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($adminToolsAvail || $atsAvail): ?>
|
||||
<!-- Akeeba Import Banner -->
|
||||
<div class="alert alert-info d-flex flex-wrap align-items-center gap-3 mb-4">
|
||||
<span class="icon-info-circle" style="font-size:1.25rem"></span>
|
||||
<strong>Akeeba data detected — import into MokoWaaS:</strong>
|
||||
<?php if ($adminToolsAvail): ?>
|
||||
<button type="button" class="btn btn-sm btn-info" id="btn-import-admintools"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.importAdminTools&format=json'); ?>"
|
||||
data-token="<?php echo $token; ?>">
|
||||
<span class="icon-shield-alt"></span> Import Admin Tools Settings
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<?php if ($atsAvail): ?>
|
||||
<button type="button" class="btn btn-sm btn-info" id="btn-import-ats-dash"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.importAts&format=json'); ?>"
|
||||
data-token="<?php echo $token; ?>">
|
||||
<span class="icon-headphones"></span> Import Tickets (<?php echo $atsAvail->tickets; ?> tickets)
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Quick Actions (large buttons) -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="col-6 col-md-4 col-xl-3">
|
||||
<button type="button" class="btn btn-outline-primary w-100 py-3" id="mokowaas-btn-cache"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.clearCache&format=json'); ?>"
|
||||
data-token="<?php echo $token; ?>">
|
||||
<span class="icon-trash d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
|
||||
<?php echo Text::_('COM_MOKOWAAS_CLEAR_CACHE'); ?>
|
||||
<span class="icon-bolt d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
|
||||
Clear Cache
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="col-6 col-md-4 col-xl-3">
|
||||
<a href="<?php echo Route::_('index.php?option=com_installer&view=update'); ?>" class="btn btn-outline-primary w-100 py-3">
|
||||
<span class="icon-refresh d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
|
||||
<?php echo Text::_('COM_MOKOWAAS_CHECK_UPDATES'); ?>
|
||||
Check Updates
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="col-6 col-md-4 col-xl-3">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokowaas&view=extensions'); ?>" class="btn btn-outline-primary w-100 py-3">
|
||||
<span class="icon-puzzle-piece d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
|
||||
<?php echo Text::_('COM_MOKOWAAS_EXTENSIONS_LINK'); ?>
|
||||
Moko Extensions
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-xl-3">
|
||||
<a href="<?php echo Route::_('index.php?option=com_checkin'); ?>" class="btn btn-outline-secondary w-100 py-3">
|
||||
<span class="icon-check-square d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
|
||||
Global Check-in
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-xl-3">
|
||||
<a href="<?php echo Route::_('index.php?option=com_actionlogs'); ?>" class="btn btn-outline-secondary w-100 py-3">
|
||||
<span class="icon-list d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
|
||||
View Logs
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-xl-3">
|
||||
<a href="<?php echo Route::_('index.php?option=com_scheduler'); ?>" class="btn btn-outline-secondary w-100 py-3">
|
||||
<span class="icon-clock d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
|
||||
Scheduled Tasks
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-xl-3">
|
||||
<?php
|
||||
// Use Community Builder if available, otherwise Joomla user manager
|
||||
$useCB = file_exists(JPATH_ADMINISTRATOR . '/components/com_comprofiler/comprofiler.php');
|
||||
$userUrl = $useCB
|
||||
? Route::_('index.php?option=com_comprofiler&task=showusers')
|
||||
: Route::_('index.php?option=com_users');
|
||||
$userLabel = $useCB ? 'Community Builder' : 'User Manager';
|
||||
?>
|
||||
<a href="<?php echo $userUrl; ?>" class="btn btn-outline-secondary w-100 py-3">
|
||||
<span class="icon-users d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
|
||||
<?php echo $userLabel; ?>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-xl-3">
|
||||
<a href="<?php echo Route::_('index.php?option=com_redirect'); ?>" class="btn btn-outline-secondary w-100 py-3">
|
||||
<span class="icon-arrow-right d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
|
||||
Redirects
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,6 +188,10 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
|
||||
<div class="d-flex align-items-center justify-content-between mt-auto pt-2 border-top">
|
||||
<?php if ($plugin->protected): ?>
|
||||
<span class="badge bg-dark"><?php echo Text::_('COM_MOKOWAAS_PROTECTED'); ?></span>
|
||||
<?php elseif ($plugin->configure_only): ?>
|
||||
<span class="badge bg-<?php echo $plugin->enabled ? 'success' : 'secondary'; ?>">
|
||||
<?php echo $plugin->enabled ? Text::_('COM_MOKOWAAS_ENABLED') : Text::_('COM_MOKOWAAS_DISABLED'); ?>
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" class="form-check-input mokowaas-toggle" role="switch"
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$data = $this->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');
|
||||
?>
|
||||
|
||||
<div id="mokowaas-database">
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-3"><div class="card text-center p-3"><span class="fw-bold fs-3"><?php echo $data['count']; ?></span><small class="text-muted">Tables</small></div></div>
|
||||
<div class="col-6 col-md-3"><div class="card text-center p-3"><span class="fw-bold fs-3"><?php echo $data['total_size_mb']; ?> MB</span><small class="text-muted">Total Size</small></div></div>
|
||||
<div class="col-6 col-md-3"><div class="card text-center p-3"><span class="fw-bold fs-3 <?php echo $data['total_overhead_kb'] > 100 ? 'text-warning' : 'text-success'; ?>"><?php echo $data['total_overhead_kb']; ?> KB</span><small class="text-muted">Overhead</small></div></div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card p-3 d-grid gap-2">
|
||||
<button type="button" class="btn btn-sm btn-primary btn-db-action" data-url="<?php echo $optimizeUrl; ?>" data-token="<?php echo $token; ?>" data-confirm="Optimize all tables with overhead?">
|
||||
<span class="icon-bolt"></span> Optimize All
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-warning btn-db-action" data-url="<?php echo $repairUrl; ?>" data-token="<?php echo $token; ?>" data-confirm="Repair all tables?">
|
||||
<span class="icon-wrench"></span> Repair All
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary btn-db-action" data-url="<?php echo $purgeUrl; ?>" data-token="<?php echo $token; ?>" data-confirm="Purge expired sessions?">
|
||||
<span class="icon-trash"></span> Purge Sessions
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm mb-0">
|
||||
<thead><tr><th>Table</th><th>Engine</th><th class="text-end">Rows</th><th class="text-end">Size</th><th class="text-end">Overhead</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($tables as $t): ?>
|
||||
<tr class="<?php echo $t->overhead_kb > 10 ? 'table-warning' : ''; ?> <?php echo $t->is_moko ? 'fw-bold' : ''; ?>">
|
||||
<td class="small"><?php echo htmlspecialchars($t->name); ?></td>
|
||||
<td class="small"><?php echo htmlspecialchars($t->engine); ?></td>
|
||||
<td class="text-end small"><?php echo number_format($t->rows); ?></td>
|
||||
<td class="text-end small"><?php echo $t->size_mb; ?> MB</td>
|
||||
<td class="text-end small <?php echo $t->overhead_kb > 10 ? 'text-warning fw-bold' : ''; ?>"><?php echo $t->overhead_kb > 0 ? $t->overhead_kb . ' KB' : '—'; ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.btn-db-action').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
if (!confirm(this.dataset.confirm)) return;
|
||||
var el = this;
|
||||
el.disabled = true;
|
||||
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()},1500); }
|
||||
else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; }
|
||||
})
|
||||
.catch(function(){ el.disabled = false; });
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,306 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$opts = $this->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 '<div class="d-flex justify-content-between align-items-center py-2 border-bottom">';
|
||||
echo '<div><strong>' . htmlspecialchars($label) . '</strong>';
|
||||
if ($desc) echo '<br><small class="text-muted">' . htmlspecialchars($desc) . '</small>';
|
||||
echo '</div>';
|
||||
echo '<div class="form-check form-switch">';
|
||||
echo '<input type="checkbox" class="form-check-input htaccess-opt" name="' . $name . '" id="htopt-' . $name . '" ' . $checked . '>';
|
||||
echo '</div></div>';
|
||||
};
|
||||
?>
|
||||
|
||||
<div id="mokowaas-htaccess">
|
||||
<ul class="nav nav-tabs mb-3" role="tablist">
|
||||
<li class="nav-item"><a class="nav-link active" data-bs-toggle="tab" href="#tab-htaccess" role="tab">.htaccess</a></li>
|
||||
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-nginx" role="tab">NginX</a></li>
|
||||
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-current" role="tab">Current File</a></li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
<!-- .htaccess Tab -->
|
||||
<div class="tab-pane fade show active" id="tab-htaccess" role="tabpanel">
|
||||
<div class="row">
|
||||
<!-- Left: Options -->
|
||||
<div class="col-12 col-xl-6">
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong><span class="icon-shield-alt"></span> Security</strong></div>
|
||||
<div class="card-body">
|
||||
<?php $sw('disable_directory_listing', 'Disable Directory Listing', 'Options -Indexes'); ?>
|
||||
<?php $sw('block_sensitive_files', 'Block Sensitive Files', 'htaccess.txt, configuration.php-dist, etc.'); ?>
|
||||
<?php $sw('block_php_in_uploads', 'Block PHP in Uploads', 'Prevent .php in images/, media/, tmp/'); ?>
|
||||
<?php $sw('disable_server_signature', 'Hide Server Signature', 'ServerSignature Off, remove X-Powered-By'); ?>
|
||||
<?php $sw('prevent_clickjacking', 'Clickjacking Protection', 'X-Frame-Options: SAMEORIGIN'); ?>
|
||||
<?php $sw('prevent_mime_sniffing', 'MIME Sniffing Prevention', 'X-Content-Type-Options: nosniff'); ?>
|
||||
<?php $sw('xss_protection', 'XSS Protection Header', 'X-XSS-Protection: 1; mode=block'); ?>
|
||||
<?php $sw('disable_trace_track', 'Disable TRACE/TRACK', 'Block HTTP TRACE and TRACK methods'); ?>
|
||||
|
||||
<div class="py-2 border-bottom">
|
||||
<label class="form-label fw-bold" for="htopt-referrer_policy">Referrer Policy</label>
|
||||
<select class="form-select form-select-sm htaccess-opt" name="referrer_policy" id="htopt-referrer_policy">
|
||||
<option value="off" <?php echo ($opts['referrer_policy'] ?? '') === 'off' ? 'selected' : ''; ?>>Off</option>
|
||||
<option value="no-referrer" <?php echo ($opts['referrer_policy'] ?? '') === 'no-referrer' ? 'selected' : ''; ?>>no-referrer</option>
|
||||
<option value="same-origin" <?php echo ($opts['referrer_policy'] ?? '') === 'same-origin' ? 'selected' : ''; ?>>same-origin</option>
|
||||
<option value="strict-origin-when-cross-origin" <?php echo ($opts['referrer_policy'] ?? '') === 'strict-origin-when-cross-origin' ? 'selected' : ''; ?>>strict-origin-when-cross-origin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<?php $sw('hsts_enabled', 'HSTS (Force HTTPS)', 'Strict-Transport-Security header'); ?>
|
||||
<div class="ps-4 <?php echo empty($opts['hsts_enabled']) ? 'd-none' : ''; ?>" id="hsts-options">
|
||||
<div class="row g-2 py-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label small">Max Age (seconds)</label>
|
||||
<input type="number" class="form-control form-control-sm htaccess-opt" name="hsts_max_age" value="<?php echo (int) ($opts['hsts_max_age'] ?? 31536000); ?>">
|
||||
</div>
|
||||
<div class="col-6 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input htaccess-opt" name="hsts_subdomains" id="htopt-hsts_sub" <?php echo !empty($opts['hsts_subdomains']) ? 'checked' : ''; ?>>
|
||||
<label class="form-check-label small" for="htopt-hsts_sub">Include Subdomains</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php $sw('csp_enabled', 'Content Security Policy', 'CSP header'); ?>
|
||||
<div class="ps-4 <?php echo empty($opts['csp_enabled']) ? 'd-none' : ''; ?>" id="csp-options">
|
||||
<textarea class="form-control form-control-sm htaccess-opt mt-1" name="csp_value" rows="2" placeholder="default-src 'self'; script-src 'self' 'unsafe-inline'"><?php echo htmlspecialchars($opts['csp_value'] ?? ''); ?></textarea>
|
||||
</div>
|
||||
|
||||
<?php $sw('permissions_policy', 'Permissions Policy', 'Camera, microphone, geolocation controls'); ?>
|
||||
<div class="ps-4 <?php echo empty($opts['permissions_policy']) ? 'd-none' : ''; ?>" id="perms-options">
|
||||
<textarea class="form-control form-control-sm htaccess-opt mt-1" name="permissions_value" rows="2" placeholder="camera=(), microphone=(), geolocation=()"><?php echo htmlspecialchars($opts['permissions_value'] ?? ''); ?></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong><span class="icon-bolt"></span> Performance</strong></div>
|
||||
<div class="card-body">
|
||||
<?php $sw('enable_gzip', 'GZip Compression', 'Compress CSS, JS, HTML, XML, JSON'); ?>
|
||||
<?php $sw('enable_expires', 'Browser Caching', 'Set expiration headers for static files'); ?>
|
||||
<div class="ps-4 <?php echo empty($opts['enable_expires']) ? 'd-none' : ''; ?>" id="expires-options">
|
||||
<div class="row g-2 py-2">
|
||||
<div class="col-4">
|
||||
<label class="form-label small">HTML (sec)</label>
|
||||
<input type="number" class="form-control form-control-sm htaccess-opt" name="expires_html" value="<?php echo (int) ($opts['expires_html'] ?? 3600); ?>">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label small">CSS/JS (sec)</label>
|
||||
<input type="number" class="form-control form-control-sm htaccess-opt" name="expires_css_js" value="<?php echo (int) ($opts['expires_css_js'] ?? 2592000); ?>">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label small">Images (sec)</label>
|
||||
<input type="number" class="form-control form-control-sm htaccess-opt" name="expires_images" value="<?php echo (int) ($opts['expires_images'] ?? 31536000); ?>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php $sw('etag_control', 'Disable ETags', 'For load-balanced environments'); ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong><span class="icon-search"></span> SEO / Redirects</strong></div>
|
||||
<div class="card-body">
|
||||
<div class="py-2 border-bottom">
|
||||
<label class="form-label fw-bold">WWW Redirect</label>
|
||||
<select class="form-select form-select-sm htaccess-opt" name="www_redirect">
|
||||
<option value="off" <?php echo ($opts['www_redirect'] ?? 'off') === 'off' ? 'selected' : ''; ?>>Off</option>
|
||||
<option value="www" <?php echo ($opts['www_redirect'] ?? '') === 'www' ? 'selected' : ''; ?>>Force www</option>
|
||||
<option value="non-www" <?php echo ($opts['www_redirect'] ?? '') === 'non-www' ? 'selected' : ''; ?>>Force non-www</option>
|
||||
</select>
|
||||
</div>
|
||||
<?php $sw('redirect_index_php', 'Redirect /index.php to /', 'SEO-friendly root redirect'); ?>
|
||||
<?php $sw('force_trailing_slash', 'Force Trailing Slash', 'Append / to URLs without file extension'); ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong><span class="icon-code"></span> Custom Rules</strong></div>
|
||||
<div class="card-body">
|
||||
<textarea class="form-control htaccess-opt" name="custom_rules" rows="4" placeholder="# Add custom Apache directives here"><?php echo htmlspecialchars($opts['custom_rules'] ?? ''); ?></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Preview -->
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card mb-3 sticky-top" style="top:1rem">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>Preview</strong>
|
||||
<span class="badge bg-secondary" id="htaccess-line-count"><?php echo substr_count($preview, "\n"); ?> lines</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<textarea id="htaccess-preview" class="form-control font-monospace border-0" rows="30" readonly style="font-size:0.8rem;resize:none"><?php echo htmlspecialchars($preview); ?></textarea>
|
||||
</div>
|
||||
<div class="card-footer d-flex gap-2">
|
||||
<button type="button" class="btn btn-primary" id="htaccess-save"
|
||||
data-url="<?php echo $saveUrl; ?>" data-token="<?php echo $token; ?>">
|
||||
<span class="icon-save"></span> Save to .htaccess
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" id="htaccess-download">
|
||||
<span class="icon-download"></span> Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NginX Tab -->
|
||||
<div class="tab-pane fade" id="tab-nginx" role="tabpanel">
|
||||
<div class="card">
|
||||
<div class="card-header"><strong>NginX Configuration Snippet</strong></div>
|
||||
<div class="card-body p-0">
|
||||
<textarea id="nginx-preview" class="form-control font-monospace border-0" rows="25" readonly style="font-size:0.8rem;resize:none"><?php echo htmlspecialchars($nginx); ?></textarea>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" id="nginx-download">
|
||||
<span class="icon-download"></span> Download NginX Config
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current File Tab -->
|
||||
<div class="tab-pane fade" id="tab-current" role="tabpanel">
|
||||
<div class="card">
|
||||
<div class="card-header"><strong>Current .htaccess on Disk</strong></div>
|
||||
<div class="card-body p-0">
|
||||
<textarea class="form-control font-monospace border-0" rows="25" readonly style="font-size:0.8rem;resize:none"><?php echo htmlspecialchars($current); ?></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var saveBtn = document.getElementById('htaccess-save');
|
||||
var preview = document.getElementById('htaccess-preview');
|
||||
var lineCount = document.getElementById('htaccess-line-count');
|
||||
|
||||
// Toggle sub-option visibility
|
||||
document.getElementById('htopt-hsts_enabled').addEventListener('change', function() {
|
||||
document.getElementById('hsts-options').classList.toggle('d-none', !this.checked);
|
||||
});
|
||||
document.getElementById('htopt-csp_enabled').addEventListener('change', function() {
|
||||
document.getElementById('csp-options').classList.toggle('d-none', !this.checked);
|
||||
});
|
||||
document.getElementById('htopt-permissions_policy').addEventListener('change', function() {
|
||||
document.getElementById('perms-options').classList.toggle('d-none', !this.checked);
|
||||
});
|
||||
document.getElementById('htopt-enable_expires') && document.getElementById('htopt-enable_expires').addEventListener('change', function() {
|
||||
document.getElementById('expires-options').classList.toggle('d-none', !this.checked);
|
||||
});
|
||||
|
||||
// Regenerate preview on any option change
|
||||
document.querySelectorAll('.htaccess-opt').forEach(function(el) {
|
||||
el.addEventListener('change', regeneratePreview);
|
||||
el.addEventListener('input', regeneratePreview);
|
||||
});
|
||||
|
||||
function collectOptions() {
|
||||
var opts = {};
|
||||
document.querySelectorAll('.htaccess-opt').forEach(function(el) {
|
||||
if (el.type === 'checkbox') {
|
||||
opts[el.name] = el.checked ? 1 : 0;
|
||||
} else {
|
||||
opts[el.name] = el.value;
|
||||
}
|
||||
});
|
||||
return opts;
|
||||
}
|
||||
|
||||
var debounceTimer;
|
||||
function regeneratePreview() {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(function() {
|
||||
var fd = new FormData();
|
||||
var opts = collectOptions();
|
||||
for (var k in opts) fd.append(k, opts[k]);
|
||||
fd.append('<?php echo $token; ?>', '1');
|
||||
|
||||
fetch('<?php echo $genUrl; ?>', {
|
||||
method: 'POST', body: fd,
|
||||
headers: {'X-Requested-With': 'XMLHttpRequest'}
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.htaccess) {
|
||||
preview.value = d.htaccess;
|
||||
lineCount.textContent = d.htaccess.split('\n').length + ' lines';
|
||||
}
|
||||
if (d.nginx) {
|
||||
document.getElementById('nginx-preview').value = d.nginx;
|
||||
}
|
||||
});
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Save to disk
|
||||
saveBtn.addEventListener('click', function() {
|
||||
if (!confirm('This will overwrite your current .htaccess file. A backup will be created at .htaccess.mokowaas.bak. Continue?')) return;
|
||||
var btn = this;
|
||||
btn.disabled = true;
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('content', preview.value);
|
||||
var opts = collectOptions();
|
||||
for (var k in opts) fd.append('opt_' + k, opts[k]);
|
||||
fd.append('<?php echo $token; ?>', '1');
|
||||
|
||||
fetch(btn.dataset.url, {
|
||||
method: 'POST', body: fd,
|
||||
headers: {'X-Requested-With': 'XMLHttpRequest'}
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.success) Joomla.renderMessages({message: [d.message]});
|
||||
else Joomla.renderMessages({error: [d.message]});
|
||||
})
|
||||
.catch(function() { Joomla.renderMessages({error: ['Network error']}); })
|
||||
.finally(function() { btn.disabled = false; });
|
||||
});
|
||||
|
||||
// Download buttons
|
||||
function downloadText(content, filename) {
|
||||
var blob = new Blob([content], {type: 'text/plain'});
|
||||
var a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
|
||||
document.getElementById('htaccess-download').addEventListener('click', function() {
|
||||
downloadText(preview.value, '.htaccess');
|
||||
});
|
||||
document.getElementById('nginx-download').addEventListener('click', function() {
|
||||
downloadText(document.getElementById('nginx-preview').value, 'mokowaas-nginx.conf');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$requests = $this->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',
|
||||
];
|
||||
?>
|
||||
|
||||
<div id="mokowaas-privacy">
|
||||
<!-- Summary cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card text-center p-3">
|
||||
<span class="fw-bold fs-3 <?php echo $summary->pending_requests > 0 ? 'text-warning' : 'text-success'; ?>"><?php echo $summary->pending_requests; ?></span>
|
||||
<small class="text-muted">Pending Requests</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card text-center p-3">
|
||||
<span class="fw-bold fs-3"><?php echo $summary->total_requests; ?></span>
|
||||
<small class="text-muted">Total Requests</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card text-center p-3">
|
||||
<span class="fw-bold fs-3"><?php echo $summary->consent_entries; ?></span>
|
||||
<small class="text-muted">Consent Entries</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card text-center p-3">
|
||||
<span class="fw-bold fs-3"><?php echo $summary->policies_active; ?></span>
|
||||
<small class="text-muted">Active Policies</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Data Requests -->
|
||||
<div class="col-12 col-xl-8">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong><span class="icon-user-shield"></span> Data Subject Requests</strong>
|
||||
<form method="get" class="d-inline">
|
||||
<input type="hidden" name="option" value="com_mokowaas">
|
||||
<input type="hidden" name="view" value="privacy">
|
||||
<select name="filter_status" class="form-select form-select-sm" style="width:auto" onchange="this.form.submit()">
|
||||
<option value="">All</option>
|
||||
<?php foreach (['pending','processing','completed','denied'] as $s): ?>
|
||||
<option value="<?php echo $s; ?>" <?php echo Factory::getApplication()->getInput()->getString('filter_status') === $s ? 'selected' : ''; ?>><?php echo ucfirst($s); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
<?php if (empty($requests)): ?>
|
||||
<div class="card-body text-center text-muted py-4">No data requests found.</div>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover mb-0">
|
||||
<thead><tr><th>#</th><th>User</th><th>Type</th><th>Status</th><th>Created</th><th>Processed</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($requests as $r): ?>
|
||||
<tr>
|
||||
<td><?php echo $r->id; ?></td>
|
||||
<td><?php echo $this->escape($r->user_name ?? ''); ?><br><small class="text-muted"><?php echo $this->escape($r->user_email ?? ''); ?></small></td>
|
||||
<td><span class="badge <?php echo $typeBadge[$r->type] ?? 'bg-secondary'; ?>"><?php echo ucfirst($r->type); ?></span></td>
|
||||
<td><span class="badge <?php echo $statusBadge[$r->status] ?? 'bg-secondary'; ?>"><?php echo ucfirst($r->status); ?></span></td>
|
||||
<td class="text-nowrap small"><?php echo HTMLHelper::_('date', $r->created, 'M d, Y H:i'); ?></td>
|
||||
<td class="text-nowrap small"><?php echo $r->processed ? HTMLHelper::_('date', $r->processed, 'M d, Y H:i') : '—'; ?></td>
|
||||
<td>
|
||||
<?php if ($r->status === 'pending'): ?>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-success btn-privacy-action" data-id="<?php echo $r->id; ?>" data-action="approve"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.processDataRequest&format=json'); ?>"
|
||||
data-token="<?php echo $token; ?>">Approve</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-privacy-action" data-id="<?php echo $r->id; ?>" data-action="deny"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.processDataRequest&format=json'); ?>"
|
||||
data-token="<?php echo $token; ?>">Deny</button>
|
||||
</div>
|
||||
<?php elseif ($r->status === 'completed' && $r->type === 'export'): ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary btn-export-download" data-user="<?php echo $r->user_id; ?>"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.exportUserData&format=json'); ?>"
|
||||
data-token="<?php echo $token; ?>">
|
||||
<span class="icon-download"></span> Download
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Retention Policies -->
|
||||
<div class="col-12 col-xl-4">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><strong><span class="icon-clock"></span> Retention Policies</strong></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr><th>Type</th><th>Days</th><th>Action</th><th>Active</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($policies as $p): ?>
|
||||
<tr>
|
||||
<td class="small"><?php echo $this->escape($p->content_type); ?></td>
|
||||
<td><?php echo $p->retention_days; ?></td>
|
||||
<td><span class="badge bg-secondary"><?php echo $p->action; ?></span></td>
|
||||
<td><?php echo (int) $p->enabled ? '<span class="text-success">Yes</span>' : '<span class="text-muted">No</span>'; ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Process request buttons
|
||||
document.querySelectorAll('.btn-privacy-action').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var el = this;
|
||||
var action = el.dataset.action;
|
||||
if (!confirm(action === 'approve' ? 'Approve and process this data request?' : 'Deny this request?')) return;
|
||||
el.disabled = true;
|
||||
var fd = new FormData();
|
||||
fd.append('request_id', el.dataset.id);
|
||||
fd.append('action', action);
|
||||
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]}); location.reload(); }
|
||||
else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Export download
|
||||
document.querySelectorAll('.btn-export-download').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var el = this;
|
||||
var fd = new FormData();
|
||||
fd.append('user_id', el.dataset.user);
|
||||
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 && d.data) {
|
||||
var blob = new Blob([JSON.stringify(d.data, null, 2)], {type:'application/json'});
|
||||
var a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'user-data-export-' + el.dataset.user + '.json';
|
||||
a.click();
|
||||
} else {
|
||||
Joomla.renderMessages({error:[d.message || 'Export failed']});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$t = $this->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',
|
||||
];
|
||||
?>
|
||||
|
||||
<div id="mokowaas-ticket" class="row">
|
||||
<!-- Left: conversation thread -->
|
||||
<div class="col-12 col-xl-8">
|
||||
<!-- Original ticket -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong><?php echo $this->escape($t->created_by_name); ?></strong>
|
||||
<small class="text-muted ms-2"><?php echo HTMLHelper::_('date', $t->created, 'M d, Y H:i'); ?></small>
|
||||
</div>
|
||||
<span class="badge bg-dark">Original</span>
|
||||
</div>
|
||||
<div class="card-body"><?php echo nl2br($this->escape($t->body)); ?></div>
|
||||
</div>
|
||||
|
||||
<!-- Replies -->
|
||||
<?php foreach ($t->replies as $reply): ?>
|
||||
<div class="card mb-3 <?php echo $reply->is_internal ? 'border-warning' : ''; ?>">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong><?php echo $this->escape($reply->user_name ?? 'System'); ?></strong>
|
||||
<small class="text-muted ms-2"><?php echo HTMLHelper::_('date', $reply->created, 'M d, Y H:i'); ?></small>
|
||||
</div>
|
||||
<?php if ($reply->is_internal): ?>
|
||||
<span class="badge bg-warning text-dark">Internal Note</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="card-body"><?php echo nl2br($this->escape($reply->body)); ?></div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<!-- Reply form -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>Reply</strong></div>
|
||||
<div class="card-body">
|
||||
<?php if (!empty($canned)): ?>
|
||||
<div class="mb-2">
|
||||
<select class="form-select form-select-sm" id="canned-select">
|
||||
<option value="">Insert canned response...</option>
|
||||
<?php foreach ($canned as $c): ?>
|
||||
<option value="<?php echo $this->escape($c->body); ?>"><?php echo $this->escape($c->title); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<textarea id="reply-body" class="form-control mb-2" rows="5" placeholder="Type your reply..."></textarea>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-primary" id="btn-reply"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.addTicketReply&format=json'); ?>"
|
||||
data-ticket="<?php echo $t->id; ?>" data-token="<?php echo $token; ?>">
|
||||
<span class="icon-reply"></span> Send Reply
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-warning" id="btn-internal"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.addTicketReply&format=json'); ?>"
|
||||
data-ticket="<?php echo $t->id; ?>" data-token="<?php echo $token; ?>" data-internal="1">
|
||||
<span class="icon-eye-slash"></span> Internal Note
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: ticket metadata -->
|
||||
<div class="col-12 col-xl-4">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>Details</strong></div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm mb-0">
|
||||
<tr><td class="text-muted">Status</td><td><span class="badge <?php echo $statusBadge[$t->status] ?? ''; ?>"><?php echo ucwords(str_replace('_', ' ', $t->status)); ?></span></td></tr>
|
||||
<tr><td class="text-muted">Priority</td><td><span class="badge <?php echo $priorityBadge[$t->priority] ?? ''; ?>"><?php echo ucfirst($t->priority); ?></span></td></tr>
|
||||
<tr><td class="text-muted">Category</td><td><?php echo $this->escape($t->category_title ?? '—'); ?></td></tr>
|
||||
<tr><td class="text-muted">Created By</td><td><?php echo $this->escape($t->created_by_name); ?><br><small><?php echo $this->escape($t->created_by_email ?? ''); ?></small></td></tr>
|
||||
<tr><td class="text-muted">Assigned To</td><td><?php echo $this->escape($t->assigned_to_name ?? 'Unassigned'); ?></td></tr>
|
||||
<tr><td class="text-muted">Created</td><td><?php echo HTMLHelper::_('date', $t->created, 'M d, Y H:i'); ?></td></tr>
|
||||
<?php if ($t->resolved): ?><tr><td class="text-muted">Resolved</td><td><?php echo HTMLHelper::_('date', $t->resolved, 'M d, Y H:i'); ?></td></tr><?php endif; ?>
|
||||
<?php if ($t->closed): ?><tr><td class="text-muted">Closed</td><td><?php echo HTMLHelper::_('date', $t->closed, 'M d, Y H:i'); ?></td></tr><?php endif; ?>
|
||||
<tr><td class="text-muted">Replies</td><td><?php echo $t->reply_count; ?></td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SLA -->
|
||||
<?php if ($t->sla_response_due || $t->sla_resolution_due): ?>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>SLA</strong></div>
|
||||
<div class="card-body">
|
||||
<?php if ($t->sla_response_due): ?>
|
||||
<div class="mb-2">
|
||||
<small class="text-muted">Response Due</small><br>
|
||||
<?php
|
||||
$responseOverdue = !$t->sla_responded && strtotime($t->sla_response_due) < time();
|
||||
?>
|
||||
<span class="<?php echo $t->sla_responded ? 'text-success' : ($responseOverdue ? 'text-danger fw-bold' : ''); ?>">
|
||||
<?php echo $t->sla_responded ? 'Responded' : HTMLHelper::_('date', $t->sla_response_due, 'M d H:i'); ?>
|
||||
<?php echo $responseOverdue ? ' OVERDUE' : ''; ?>
|
||||
</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($t->sla_resolution_due): ?>
|
||||
<div>
|
||||
<small class="text-muted">Resolution Due</small><br>
|
||||
<?php
|
||||
$resolutionOverdue = !\in_array($t->status, ['resolved','closed']) && strtotime($t->sla_resolution_due) < time();
|
||||
?>
|
||||
<span class="<?php echo \in_array($t->status, ['resolved','closed']) ? 'text-success' : ($resolutionOverdue ? 'text-danger fw-bold' : ''); ?>">
|
||||
<?php echo \in_array($t->status, ['resolved','closed']) ? 'Met' : HTMLHelper::_('date', $t->sla_resolution_due, 'M d H:i'); ?>
|
||||
<?php echo $resolutionOverdue ? ' OVERDUE' : ''; ?>
|
||||
</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Status actions -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>Actions</strong></div>
|
||||
<div class="card-body d-grid gap-2">
|
||||
<?php foreach (['open' => 'Reopen', 'in_progress' => 'In Progress', 'waiting' => 'Waiting', 'resolved' => 'Resolve', 'closed' => 'Close'] as $s => $label): ?>
|
||||
<?php if ($s !== $t->status): ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-<?php echo $s === 'closed' ? 'danger' : ($s === 'resolved' ? 'success' : 'secondary'); ?> btn-status"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.updateTicketStatus&format=json'); ?>"
|
||||
data-ticket="<?php echo $t->id; ?>" data-status="<?php echo $s; ?>" data-token="<?php echo $token; ?>">
|
||||
<?php echo $label; ?>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Canned response insert
|
||||
var cannedSel = document.getElementById('canned-select');
|
||||
if (cannedSel) {
|
||||
cannedSel.addEventListener('change', function() {
|
||||
if (this.value) { document.getElementById('reply-body').value = this.value; this.selectedIndex = 0; }
|
||||
});
|
||||
}
|
||||
|
||||
// Reply buttons
|
||||
document.querySelectorAll('#btn-reply, #btn-internal').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var body = document.getElementById('reply-body').value.trim();
|
||||
if (!body) return;
|
||||
var el = this;
|
||||
el.disabled = true;
|
||||
var fd = new FormData();
|
||||
fd.append('ticket_id', el.dataset.ticket);
|
||||
fd.append('body', body);
|
||||
fd.append('is_internal', el.dataset.internal || '0');
|
||||
fd.append(el.dataset.token, '1');
|
||||
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){ if(d.success) location.reload(); else Joomla.renderMessages({error:[d.message]}); })
|
||||
.finally(function(){ el.disabled = false; });
|
||||
});
|
||||
});
|
||||
|
||||
// Status buttons
|
||||
document.querySelectorAll('.btn-status').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var el = this;
|
||||
el.disabled = true;
|
||||
var fd = new FormData();
|
||||
fd.append('ticket_id', el.dataset.ticket);
|
||||
fd.append('status', el.dataset.status);
|
||||
fd.append(el.dataset.token, '1');
|
||||
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){ if(d.success) location.reload(); else Joomla.renderMessages({error:[d.message]}); })
|
||||
.finally(function(){ el.disabled = false; });
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$tickets = $this->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',
|
||||
];
|
||||
?>
|
||||
|
||||
<div id="mokowaas-tickets">
|
||||
<!-- Status summary cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo $counts->open; ?></span><small class="text-muted">Open</small></div></div>
|
||||
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo $counts->in_progress; ?></span><small class="text-muted">In Progress</small></div></div>
|
||||
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo $counts->waiting; ?></span><small class="text-muted">Waiting</small></div></div>
|
||||
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo $counts->resolved; ?></span><small class="text-muted">Resolved</small></div></div>
|
||||
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo $counts->closed; ?></span><small class="text-muted">Closed</small></div></div>
|
||||
<?php if (\count($overdue) > 0): ?>
|
||||
<div class="col"><div class="card text-center p-2 border-danger"><span class="fw-bold fs-4 text-danger"><?php echo \count($overdue); ?></span><small class="text-danger">SLA Overdue</small></div></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- New ticket + filters -->
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3">
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newTicketModal">
|
||||
<span class="icon-plus"></span> New Ticket
|
||||
</button>
|
||||
<?php if ($atsAvailable): ?>
|
||||
<button type="button" class="btn btn-outline-info" id="btn-import-ats"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.importAts&format=json'); ?>"
|
||||
data-token="<?php echo $token; ?>"
|
||||
data-tickets="<?php echo $atsAvailable->tickets; ?>"
|
||||
data-posts="<?php echo $atsAvailable->posts; ?>">
|
||||
<span class="icon-upload"></span> Import from Akeeba (<?php echo $atsAvailable->tickets; ?> tickets, <?php echo $atsAvailable->posts; ?> posts)
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<form method="get" class="d-flex gap-2">
|
||||
<input type="hidden" name="option" value="com_mokowaas">
|
||||
<input type="hidden" name="view" value="tickets">
|
||||
<select name="filter_status" class="form-select form-select-sm" style="width:auto" onchange="this.form.submit()">
|
||||
<option value="">All Statuses</option>
|
||||
<?php foreach (['open','in_progress','waiting','resolved','closed'] as $s): ?>
|
||||
<option value="<?php echo $s; ?>" <?php echo Factory::getApplication()->getInput()->getString('filter_status') === $s ? 'selected' : ''; ?>><?php echo ucwords(str_replace('_', ' ', $s)); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<select name="filter_priority" class="form-select form-select-sm" style="width:auto" onchange="this.form.submit()">
|
||||
<option value="">All Priorities</option>
|
||||
<?php foreach (['low','normal','high','urgent'] as $p): ?>
|
||||
<option value="<?php echo $p; ?>" <?php echo Factory::getApplication()->getInput()->getString('filter_priority') === $p ? 'selected' : ''; ?>><?php echo ucfirst($p); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Ticket table -->
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Subject</th>
|
||||
<th>Status</th>
|
||||
<th>Priority</th>
|
||||
<th>Category</th>
|
||||
<th>Created By</th>
|
||||
<th>Assigned To</th>
|
||||
<th>Created</th>
|
||||
<th>SLA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($tickets)): ?>
|
||||
<tr><td colspan="9" class="text-center text-muted py-4">No tickets found.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($tickets as $t): ?>
|
||||
<?php
|
||||
$slaClass = '';
|
||||
$now = time();
|
||||
if ($t->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';
|
||||
?>
|
||||
<tr class="<?php echo $slaClass; ?>">
|
||||
<td><a href="<?php echo Route::_('index.php?option=com_mokowaas&view=ticket&id=' . $t->id); ?>"><?php echo $t->id; ?></a></td>
|
||||
<td><a href="<?php echo Route::_('index.php?option=com_mokowaas&view=ticket&id=' . $t->id); ?>"><?php echo $this->escape(mb_substr($t->subject, 0, 60)); ?></a></td>
|
||||
<td><span class="badge <?php echo $statusBadge[$t->status] ?? 'bg-secondary'; ?>"><?php echo ucwords(str_replace('_', ' ', $t->status)); ?></span></td>
|
||||
<td><span class="badge <?php echo $priorityBadge[$t->priority] ?? 'bg-secondary'; ?>"><?php echo ucfirst($t->priority); ?></span></td>
|
||||
<td><?php echo $this->escape($t->category_title ?? '—'); ?></td>
|
||||
<td><?php echo $this->escape($t->created_by_name ?? ''); ?></td>
|
||||
<td><?php echo $t->assigned_to_name ? $this->escape($t->assigned_to_name) : '<em>Unassigned</em>'; ?></td>
|
||||
<td class="small"><?php echo HTMLHelper::_('date', $t->created, 'M d H:i'); ?></td>
|
||||
<td class="small">
|
||||
<?php if ($t->sla_response_due && !$t->sla_responded): ?>
|
||||
<span title="Response due"><?php echo HTMLHelper::_('date', $t->sla_response_due, 'M d H:i'); ?></span>
|
||||
<?php elseif ($t->sla_resolution_due): ?>
|
||||
<span title="Resolution due"><?php echo HTMLHelper::_('date', $t->sla_resolution_due, 'M d H:i'); ?></span>
|
||||
<?php else: ?>—<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Ticket Modal -->
|
||||
<div class="modal fade" id="newTicketModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header"><h5 class="modal-title">New Ticket</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||||
<div class="modal-body">
|
||||
<!-- KB Search step -->
|
||||
<div id="modal-kb-step">
|
||||
<label class="form-label fw-bold">What's the issue?</label>
|
||||
<div class="input-group mb-3">
|
||||
<input type="text" id="modal-kb-search" class="form-control" placeholder="Describe your issue to search for existing answers...">
|
||||
<button type="button" class="btn btn-outline-primary" id="modal-kb-btn"><span class="icon-search"></span></button>
|
||||
</div>
|
||||
<div id="modal-kb-results" class="list-group mb-3 d-none"></div>
|
||||
<button type="button" class="btn btn-primary" id="modal-show-form">
|
||||
<span class="icon-plus"></span> Create Ticket
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Ticket form step (hidden initially) -->
|
||||
<form id="modal-ticket-form" class="d-none" method="post" action="<?php echo Route::_('index.php?option=com_mokowaas&task=display.createTicket&format=json'); ?>">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Subject</label>
|
||||
<input type="text" name="subject" id="modal-subject" class="form-control" required>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Category</label>
|
||||
<select name="category_id" class="form-select">
|
||||
<option value="">— Select —</option>
|
||||
<?php foreach ($categories as $cat): ?>
|
||||
<option value="<?php echo $cat->id; ?>"><?php echo $this->escape($cat->title); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Priority</label>
|
||||
<select name="priority" class="form-select">
|
||||
<option value="normal">Normal</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="high">High</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea name="body" class="form-control" rows="6" required></textarea>
|
||||
</div>
|
||||
<input type="hidden" name="<?php echo $token; ?>" value="1">
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary"><span class="icon-plus"></span> Create Ticket</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Modal KB search
|
||||
var modalSearch = document.getElementById('modal-kb-search');
|
||||
var modalSearchBtn = document.getElementById('modal-kb-btn');
|
||||
var modalResults = document.getElementById('modal-kb-results');
|
||||
var modalShowForm = document.getElementById('modal-show-form');
|
||||
var modalKbStep = document.getElementById('modal-kb-step');
|
||||
var modalForm = document.getElementById('modal-ticket-form');
|
||||
var modalSubject = document.getElementById('modal-subject');
|
||||
|
||||
function modalDoSearch() {
|
||||
var q = modalSearch.value.trim();
|
||||
if (q.length < 3) return;
|
||||
fetch('<?php echo Route::_('index.php?option=com_mokowaas&task=display.searchKb&format=json'); ?>&q=' + encodeURIComponent(q), {
|
||||
headers: {'X-Requested-With': 'XMLHttpRequest'}
|
||||
}).then(function(r){return r.json()}).then(function(d) {
|
||||
modalResults.textContent = '';
|
||||
if (d.results && d.results.length > 0) {
|
||||
d.results.forEach(function(item) {
|
||||
var a = document.createElement('a');
|
||||
a.href = item.url;
|
||||
a.target = '_blank';
|
||||
a.className = 'list-group-item list-group-item-action';
|
||||
var strong = document.createElement('strong');
|
||||
strong.textContent = item.title;
|
||||
a.appendChild(strong);
|
||||
if (item.description) {
|
||||
a.appendChild(document.createElement('br'));
|
||||
var small = document.createElement('small');
|
||||
small.className = 'text-muted';
|
||||
small.textContent = item.description;
|
||||
a.appendChild(small);
|
||||
}
|
||||
modalResults.appendChild(a);
|
||||
});
|
||||
modalResults.classList.remove('d-none');
|
||||
} else {
|
||||
modalResults.classList.add('d-none');
|
||||
}
|
||||
});
|
||||
}
|
||||
if (modalSearchBtn) modalSearchBtn.addEventListener('click', modalDoSearch);
|
||||
if (modalSearch) modalSearch.addEventListener('keydown', function(e) { if (e.key === 'Enter') { e.preventDefault(); modalDoSearch(); } });
|
||||
|
||||
// Show ticket form
|
||||
if (modalShowForm) {
|
||||
modalShowForm.addEventListener('click', function() {
|
||||
modalKbStep.classList.add('d-none');
|
||||
modalForm.classList.remove('d-none');
|
||||
if (modalSearch.value && !modalSubject.value) modalSubject.value = modalSearch.value;
|
||||
modalSubject.focus();
|
||||
});
|
||||
}
|
||||
|
||||
// Submit ticket from modal
|
||||
if (modalForm) {
|
||||
modalForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
var form = this;
|
||||
var fd = new FormData(form);
|
||||
fetch(form.action, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){
|
||||
if (d.success) { location.href = 'index.php?option=com_mokowaas&view=ticket&id=' + d.id; }
|
||||
else { Joomla.renderMessages({error:[d.message]}); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Reset modal on close
|
||||
document.getElementById('newTicketModal').addEventListener('hidden.bs.modal', function() {
|
||||
modalKbStep.classList.remove('d-none');
|
||||
modalForm.classList.add('d-none');
|
||||
modalResults.classList.add('d-none');
|
||||
modalSearch.value = '';
|
||||
modalForm.reset();
|
||||
});
|
||||
|
||||
// ATS Import
|
||||
var atsBtn = document.getElementById('btn-import-ats');
|
||||
if (atsBtn) {
|
||||
atsBtn.addEventListener('click', function() {
|
||||
var el = this;
|
||||
if (!confirm('Import ' + el.dataset.tickets + ' tickets and ' + el.dataset.posts + ' posts from Akeeba Ticket System? Duplicates will be skipped.')) return;
|
||||
el.disabled = true;
|
||||
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]}); location.reload(); }
|
||||
else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; el.textContent = 'Import Failed - Retry'; }
|
||||
})
|
||||
.catch(function(){ Joomla.renderMessages({error:['Network error']}); el.disabled = false; });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$logs = $this->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',
|
||||
];
|
||||
?>
|
||||
|
||||
<div id="mokowaas-waflog">
|
||||
<!-- Rule distribution cards -->
|
||||
<div class="d-flex flex-wrap gap-2 mb-4">
|
||||
<?php foreach ($ruleCounts as $rc): ?>
|
||||
<div class="card p-2 text-center" style="min-width:100px">
|
||||
<span class="badge <?php echo $ruleBadge[$rc->rule] ?? 'bg-secondary'; ?> mb-1"><?php echo htmlspecialchars($rc->rule); ?></span>
|
||||
<span class="fw-bold"><?php echo number_format($rc->cnt); ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<div class="card p-2 text-center" style="min-width:100px">
|
||||
<span class="badge bg-primary mb-1">Total</span>
|
||||
<span class="fw-bold"><?php echo number_format($total); ?></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Main: Log table -->
|
||||
<div class="col-12 col-xl-9">
|
||||
<!-- Filters -->
|
||||
<form method="get" class="card mb-3">
|
||||
<div class="card-body">
|
||||
<input type="hidden" name="option" value="com_mokowaas">
|
||||
<input type="hidden" name="view" value="waflog">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-2">
|
||||
<select name="filter_rule" class="form-select form-select-sm">
|
||||
<option value="">All Rules</option>
|
||||
<?php foreach ($ruleNames as $r): ?>
|
||||
<option value="<?php echo htmlspecialchars($r); ?>" <?php echo $filters['rule'] === $r ? 'selected' : ''; ?>><?php echo htmlspecialchars($r); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="text" name="filter_ip" class="form-control form-control-sm" placeholder="IP address" value="<?php echo htmlspecialchars($filters['ip']); ?>">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="text" name="filter_search" class="form-control form-control-sm" placeholder="Search URI/detail" value="<?php echo htmlspecialchars($filters['search']); ?>">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="date" name="filter_date_from" class="form-control form-control-sm" value="<?php echo htmlspecialchars($filters['date_from']); ?>">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="date" name="filter_date_to" class="form-control form-control-sm" value="<?php echo htmlspecialchars($filters['date_to']); ?>">
|
||||
</div>
|
||||
<div class="col-md-2 d-flex gap-1">
|
||||
<button type="submit" class="btn btn-sm btn-primary"><span class="icon-search"></span> Filter</button>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokowaas&view=waflog'); ?>" class="btn btn-sm btn-outline-secondary">Reset</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Log table -->
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong><?php echo number_format($total); ?> blocked requests</strong>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" id="btn-purge"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.purgeWafLog&format=json'); ?>"
|
||||
data-token="<?php echo $token; ?>">
|
||||
<span class="icon-trash"></span> Purge Old Logs
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover table-sm mb-0">
|
||||
<thead>
|
||||
<tr><th>Time</th><th>IP</th><th>Rule</th><th>URI</th><th>Detail</th><th>User Agent</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($logs)): ?>
|
||||
<tr><td colspan="7" class="text-center text-muted py-4">No blocked requests found.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($logs as $log): ?>
|
||||
<tr>
|
||||
<td class="text-nowrap small"><?php echo HTMLHelper::_('date', $log->created, 'M d H:i:s'); ?></td>
|
||||
<td><code><?php echo htmlspecialchars($log->ip); ?></code></td>
|
||||
<td><span class="badge <?php echo $ruleBadge[$log->rule] ?? 'bg-secondary'; ?>"><?php echo htmlspecialchars($log->rule); ?></span></td>
|
||||
<td class="small" style="max-width:250px;overflow:hidden;text-overflow:ellipsis" title="<?php echo htmlspecialchars($log->uri); ?>"><?php echo htmlspecialchars(mb_substr($log->uri, 0, 60)); ?></td>
|
||||
<td class="small" style="max-width:200px;overflow:hidden;text-overflow:ellipsis"><?php echo htmlspecialchars(mb_substr($log->detail, 0, 50)); ?></td>
|
||||
<td class="small" style="max-width:200px;overflow:hidden;text-overflow:ellipsis"><?php echo htmlspecialchars(mb_substr($log->user_agent, 0, 40)); ?></td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger btn-ban-ip" data-ip="<?php echo htmlspecialchars($log->ip); ?>"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.banIpFromLog&format=json'); ?>"
|
||||
data-token="<?php echo $token; ?>" title="Ban this IP">
|
||||
<span class="icon-ban"></span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<?php if ($totalPages > 1): ?>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted">Page <?php echo $page; ?> of <?php echo $totalPages; ?></small>
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
<?php if ($page > 1): ?>
|
||||
<li class="page-item"><a class="page-link" href="<?php echo Route::_('index.php?option=com_mokowaas&view=waflog&page=' . ($page - 1)); ?>">Prev</a></li>
|
||||
<?php endif; ?>
|
||||
<?php if ($page < $totalPages): ?>
|
||||
<li class="page-item"><a class="page-link" href="<?php echo Route::_('index.php?option=com_mokowaas&view=waflog&page=' . ($page + 1)); ?>">Next</a></li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar: Top IPs -->
|
||||
<div class="col-12 col-xl-3">
|
||||
<div class="card">
|
||||
<div class="card-header"><strong>Top Blocked IPs</strong></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr><th>IP</th><th>Blocks</th><th>Last</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($topIps as $tip): ?>
|
||||
<tr>
|
||||
<td><code class="small"><?php echo htmlspecialchars($tip->ip); ?></code></td>
|
||||
<td class="fw-bold"><?php echo $tip->cnt; ?></td>
|
||||
<td class="small text-nowrap"><?php echo HTMLHelper::_('date', $tip->last_seen, 'M d'); ?></td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger btn-ban-ip" data-ip="<?php echo htmlspecialchars($tip->ip); ?>"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.banIpFromLog&format=json'); ?>"
|
||||
data-token="<?php echo $token; ?>" title="Ban">
|
||||
<span class="icon-ban"></span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var token = '<?php echo $token; ?>';
|
||||
|
||||
// Ban IP buttons
|
||||
document.querySelectorAll('.btn-ban-ip').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var el = this;
|
||||
var ip = el.dataset.ip;
|
||||
if (!confirm('Add ' + ip + ' to the firewall IP blocklist?')) return;
|
||||
el.disabled = true;
|
||||
var fd = new FormData();
|
||||
fd.append('ip', ip);
|
||||
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]}); el.textContent = 'Banned'; }
|
||||
else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Purge button
|
||||
var purgeBtn = document.getElementById('btn-purge');
|
||||
if (purgeBtn) {
|
||||
purgeBtn.addEventListener('click', function() {
|
||||
var days = prompt('Delete WAF logs older than how many days?', '30');
|
||||
if (!days || isNaN(days)) return;
|
||||
this.disabled = true;
|
||||
var fd = new FormData();
|
||||
fd.append('days', days);
|
||||
fd.append(this.dataset.token, '1');
|
||||
fetch(this.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]}); location.reload(); }
|
||||
else { Joomla.renderMessages({error:[d.message]}); }
|
||||
})
|
||||
.finally(function(){ purgeBtn.disabled = false; });
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -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; });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,21 +20,52 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.33.00</version>
|
||||
<version>02.32.52</version>
|
||||
<description>MokoWaaS admin dashboard and REST API. Provides a control panel for managing MokoWaaS feature plugins, site health monitoring, and remote management endpoints.</description>
|
||||
|
||||
<namespace path="src">Moko\Component\MokoWaaS</namespace>
|
||||
|
||||
<administration>
|
||||
<menu img="class:cogs">MokoWaaS</menu>
|
||||
<submenu>
|
||||
<menu link="option=com_mokowaas" img="class:cogs">COM_MOKOWAAS_MENU_DASHBOARD</menu>
|
||||
<menu link="option=com_mokowaas&view=extensions" img="class:puzzle-piece">COM_MOKOWAAS_MENU_EXTENSIONS</menu>
|
||||
<menu link="option=com_mokowaas&view=tickets" img="class:headphones">COM_MOKOWAAS_MENU_TICKETS</menu>
|
||||
<menu link="option=com_mokowaas&view=htaccess" img="class:file-code">COM_MOKOWAAS_MENU_HTACCESS</menu>
|
||||
<menu link="option=com_mokowaas&view=privacy" img="class:lock">COM_MOKOWAAS_MENU_PRIVACY</menu>
|
||||
<menu link="option=com_mokowaas&view=waflog" img="class:shield-alt">COM_MOKOWAAS_MENU_WAFLOG</menu>
|
||||
<menu link="option=com_mokowaas&view=database" img="class:database">COM_MOKOWAAS_MENU_DATABASE</menu>
|
||||
<menu link="option=com_mokowaas&view=cleanup" img="class:trash">COM_MOKOWAAS_MENU_CLEANUP</menu>
|
||||
<menu link="option=com_plugins&filter[folder]=system&filter[search]=mokowaas" img="class:power-off">COM_MOKOWAAS_MENU_PLUGINS</menu>
|
||||
<menu link="option=com_installer&view=update" img="class:refresh">COM_MOKOWAAS_MENU_UPDATES</menu>
|
||||
<menu link="option=com_checkin" img="class:check-square">COM_MOKOWAAS_MENU_CHECKIN</menu>
|
||||
<menu link="option=com_cache" img="class:bolt">COM_MOKOWAAS_MENU_CACHE</menu>
|
||||
</submenu>
|
||||
<files folder="admin">
|
||||
<filename>access.xml</filename>
|
||||
<filename>config.xml</filename>
|
||||
<folder>language</folder>
|
||||
<folder>services</folder>
|
||||
<folder>sql</folder>
|
||||
<folder>src</folder>
|
||||
<folder>tmpl</folder>
|
||||
</files>
|
||||
<languages folder="admin/language">
|
||||
<language tag="en-GB">en-GB/com_mokowaas.sys.ini</language>
|
||||
</languages>
|
||||
</administration>
|
||||
|
||||
<files folder="site">
|
||||
<folder>language</folder>
|
||||
<folder>services</folder>
|
||||
<folder>src</folder>
|
||||
<folder>tmpl</folder>
|
||||
</files>
|
||||
|
||||
<install>
|
||||
<sql><file driver="mysql" charset="utf8">admin/sql/install.mysql.sql</file></sql>
|
||||
</install>
|
||||
|
||||
<api>
|
||||
<files folder="api">
|
||||
<folder>src</folder>
|
||||
|
||||
@@ -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."
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas.site
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Dispatcher\ComponentDispatcherFactoryInterface;
|
||||
use Joomla\CMS\Extension\ComponentInterface;
|
||||
use Joomla\CMS\Extension\Service\Provider\ComponentDispatcherFactory;
|
||||
use Joomla\CMS\Extension\Service\Provider\MVCFactory;
|
||||
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
|
||||
use Joomla\DI\Container;
|
||||
use Joomla\DI\ServiceProviderInterface;
|
||||
|
||||
return new class implements ServiceProviderInterface
|
||||
{
|
||||
public function register(Container $container): void
|
||||
{
|
||||
$container->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;
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas.site
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Site\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
class DisplayController extends BaseController
|
||||
{
|
||||
protected $default_view = 'tickets';
|
||||
|
||||
public function display($cachable = false, $urlparams = [])
|
||||
{
|
||||
$user = Factory::getApplication()->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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoWaaS\Site\View\Privacy;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $requests = [];
|
||||
protected $consent = [];
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$user = Factory::getApplication()->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas.site
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Site\View\Ticket;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $ticket;
|
||||
protected $isStaff = false;
|
||||
protected $canAssign = false;
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$db = Factory::getContainer()->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas.site
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Site\View\Tickets;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $tickets = [];
|
||||
protected $categories = [];
|
||||
protected $isStaff = false;
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$db = Factory::getContainer()->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$user = Factory::getApplication()->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'];
|
||||
?>
|
||||
|
||||
<div class="mokowaas-portal">
|
||||
<h2>My Privacy & Data</h2>
|
||||
<p class="text-muted">Manage your personal data, download your information, or request account deletion.</p>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-4">
|
||||
<button type="button" class="btn btn-primary w-100 py-3 btn-data-request" data-type="export">
|
||||
<span class="icon-download d-block mb-1" style="font-size:1.5rem"></span>
|
||||
Download My Data
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<button type="button" class="btn btn-outline-warning w-100 py-3 btn-data-request" data-type="anonymize">
|
||||
<span class="icon-user-shield d-block mb-1" style="font-size:1.5rem"></span>
|
||||
Anonymize My Account
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<button type="button" class="btn btn-outline-danger w-100 py-3 btn-data-request" data-type="delete">
|
||||
<span class="icon-trash d-block mb-1" style="font-size:1.5rem"></span>
|
||||
Delete My Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- My requests -->
|
||||
<?php if (!empty($requests)): ?>
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><strong>My Data Requests</strong></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped mb-0">
|
||||
<thead><tr><th>Type</th><th>Status</th><th>Submitted</th><th>Processed</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($requests as $r): ?>
|
||||
<tr>
|
||||
<td><?php echo ucfirst($r->type); ?></td>
|
||||
<td><span class="badge bg-<?php echo $statusClass[$r->status] ?? 'secondary'; ?>"><?php echo $statusLabel[$r->status] ?? $r->status; ?></span></td>
|
||||
<td><?php echo HTMLHelper::_('date', $r->created, 'M d, Y H:i'); ?></td>
|
||||
<td><?php echo $r->processed ? HTMLHelper::_('date', $r->processed, 'M d, Y H:i') : '—'; ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Consent history -->
|
||||
<?php if (!empty($consent)): ?>
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><strong>Consent History</strong></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr><th>Category</th><th>Action</th><th>Date</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($consent as $c): ?>
|
||||
<tr>
|
||||
<td><?php echo htmlspecialchars(ucwords(str_replace('_', ' ', $c->category))); ?></td>
|
||||
<td><span class="badge bg-<?php echo $c->action === 'granted' ? 'success' : 'secondary'; ?>"><?php echo ucfirst($c->action); ?></span></td>
|
||||
<td><?php echo HTMLHelper::_('date', $c->created, 'M d, Y H:i'); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.btn-data-request').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var type = this.dataset.type;
|
||||
var messages = {
|
||||
'export': 'Request a download of all your personal data?',
|
||||
'anonymize': 'Request your account to be anonymized? Your name, email, and personal details will be replaced. This cannot be undone.',
|
||||
'delete': 'Request permanent deletion of your account and all data? This cannot be undone.'
|
||||
};
|
||||
if (!confirm(messages[type] || 'Submit this request?')) return;
|
||||
this.disabled = true;
|
||||
var fd = new FormData();
|
||||
fd.append('type', type);
|
||||
fd.append('<?php echo $token; ?>', '1');
|
||||
fetch('<?php echo Route::_("index.php?option=com_mokowaas&task=display.submitDataRequest&format=json"); ?>', {
|
||||
method: 'POST', body: fd, headers: {'X-Requested-With': 'XMLHttpRequest'}
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.success) { alert(d.message); location.reload(); }
|
||||
else { alert(d.message || 'Failed.'); }
|
||||
})
|
||||
.catch(function() { alert('Network error.'); })
|
||||
.finally(function() { btn.disabled = false; });
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
use Joomla\CMS\Factory;
|
||||
|
||||
$t = $this->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',
|
||||
];
|
||||
?>
|
||||
|
||||
<div class="mokowaas-portal-ticket">
|
||||
<div class="mb-3">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokowaas&view=tickets'); ?>" class="btn btn-sm btn-outline-secondary">
|
||||
<span class="icon-arrow-left"></span> Back to Tickets
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Main column: conversation -->
|
||||
<div class="col-12 <?php echo $isStaff ? 'col-lg-8' : ''; ?>">
|
||||
|
||||
<!-- Ticket header -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h3 class="mb-1">#<?php echo $t->id; ?> — <?php echo htmlspecialchars($t->subject); ?></h3>
|
||||
<small class="text-muted">
|
||||
<?php echo htmlspecialchars($t->category_title ?? 'General'); ?>
|
||||
· <?php echo HTMLHelper::_('date', $t->created, 'M d, Y H:i'); ?>
|
||||
· <?php echo ucfirst($t->priority); ?>
|
||||
<?php if ($isStaff): ?>
|
||||
· By: <?php echo htmlspecialchars($t->created_by_name); ?>
|
||||
<?php endif; ?>
|
||||
</small>
|
||||
</div>
|
||||
<span class="badge bg-<?php echo $statusClass[$t->status] ?? 'secondary'; ?> fs-6">
|
||||
<?php echo $statusLabel[$t->status] ?? $t->status; ?>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Original message -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<strong><?php echo htmlspecialchars($t->created_by_name); ?></strong>
|
||||
<small class="text-muted ms-2"><?php echo HTMLHelper::_('date', $t->created, 'M d, Y H:i'); ?></small>
|
||||
</div>
|
||||
<div class="card-body"><?php echo nl2br(htmlspecialchars($t->body)); ?></div>
|
||||
</div>
|
||||
|
||||
<!-- Replies -->
|
||||
<?php foreach ($t->replies as $reply): ?>
|
||||
<?php
|
||||
$replyIsStaffUser = ((int) $reply->user_id !== (int) $t->created_by);
|
||||
$isInternal = (int) $reply->is_internal;
|
||||
?>
|
||||
<div class="card mb-3 <?php echo $isInternal ? 'border-warning bg-warning bg-opacity-10' : ($replyIsStaffUser ? 'border-primary' : ''); ?>">
|
||||
<div class="card-header d-flex justify-content-between">
|
||||
<div>
|
||||
<strong><?php echo htmlspecialchars($reply->user_name ?? 'Support'); ?></strong>
|
||||
<?php if ($replyIsStaffUser): ?><span class="badge bg-primary ms-1">Staff</span><?php endif; ?>
|
||||
<?php if ($isInternal): ?><span class="badge bg-warning text-dark ms-1">Internal Note</span><?php endif; ?>
|
||||
<small class="text-muted ms-2"><?php echo HTMLHelper::_('date', $reply->created, 'M d, Y H:i'); ?></small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body"><?php echo nl2br(htmlspecialchars($reply->body)); ?></div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<!-- Reply form -->
|
||||
<?php if (!\in_array($t->status, ['closed'])): ?>
|
||||
<div class="card mt-4">
|
||||
<div class="card-body">
|
||||
<h5>Reply</h5>
|
||||
<form id="portalReply">
|
||||
<textarea name="body" class="form-control mb-3" rows="5" required placeholder="Type your reply..."></textarea>
|
||||
<input type="hidden" name="ticket_id" value="<?php echo $t->id; ?>">
|
||||
<input type="hidden" name="<?php echo $token; ?>" value="1">
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="icon-paper-plane"></span> Send Reply
|
||||
</button>
|
||||
<?php if ($isStaff): ?>
|
||||
<button type="button" class="btn btn-outline-warning" id="btn-internal-note">
|
||||
<span class="icon-eye-slash"></span> Internal Note
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php elseif ($t->status === 'closed'): ?>
|
||||
<div class="alert alert-secondary mt-4">
|
||||
This ticket is closed. <a href="<?php echo Route::_('index.php?option=com_mokowaas&view=tickets&layout=submit'); ?>">Open a new ticket</a> if you need further help.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Staff sidebar -->
|
||||
<?php if ($isStaff): ?>
|
||||
<div class="col-12 col-lg-4">
|
||||
<!-- Ticket info -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>Details</strong></div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-5 text-muted">Status</dt>
|
||||
<dd class="col-7"><span class="badge bg-<?php echo $statusClass[$t->status] ?? 'secondary'; ?>"><?php echo $statusLabel[$t->status] ?? $t->status; ?></span></dd>
|
||||
<dt class="col-5 text-muted">Priority</dt>
|
||||
<dd class="col-7"><?php echo ucfirst($t->priority); ?></dd>
|
||||
<dt class="col-5 text-muted">Category</dt>
|
||||
<dd class="col-7"><?php echo htmlspecialchars($t->category_title ?? '—'); ?></dd>
|
||||
<dt class="col-5 text-muted">Submitted By</dt>
|
||||
<dd class="col-7"><?php echo htmlspecialchars($t->created_by_name); ?><br><small class="text-muted"><?php echo htmlspecialchars($t->created_by_email ?? ''); ?></small></dd>
|
||||
<dt class="col-5 text-muted">Assigned To</dt>
|
||||
<dd class="col-7"><?php echo htmlspecialchars($t->assigned_to_name ?? 'Unassigned'); ?></dd>
|
||||
<dt class="col-5 text-muted">Created</dt>
|
||||
<dd class="col-7"><?php echo HTMLHelper::_('date', $t->created, 'M d H:i'); ?></dd>
|
||||
<dt class="col-5 text-muted">Replies</dt>
|
||||
<dd class="col-7"><?php echo \count($t->replies); ?></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status actions -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>Change Status</strong></div>
|
||||
<div class="card-body d-grid gap-2">
|
||||
<?php foreach (['open' => 'Reopen', 'in_progress' => 'In Progress', 'waiting' => 'Waiting on Customer', 'resolved' => 'Resolve', 'closed' => 'Close'] as $s => $label): ?>
|
||||
<?php if ($s !== $t->status): ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-<?php echo $s === 'closed' ? 'danger' : ($s === 'resolved' ? 'success' : 'secondary'); ?> btn-status"
|
||||
data-status="<?php echo $s; ?>">
|
||||
<?php echo $label; ?>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($canAssign): ?>
|
||||
<!-- Quick assign -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>Assign</strong></div>
|
||||
<div class="card-body">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary w-100" id="btn-assign-me">
|
||||
Assign to Me
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var token = '<?php echo $token; ?>';
|
||||
var ticketId = <?php echo $t->id; ?>;
|
||||
|
||||
// Reply
|
||||
var replyForm = document.getElementById('portalReply');
|
||||
if (replyForm) {
|
||||
replyForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
sendReply(false);
|
||||
});
|
||||
}
|
||||
|
||||
// Internal note
|
||||
var internalBtn = document.getElementById('btn-internal-note');
|
||||
if (internalBtn) {
|
||||
internalBtn.addEventListener('click', function() { sendReply(true); });
|
||||
}
|
||||
|
||||
function sendReply(isInternal) {
|
||||
var body = replyForm.querySelector('textarea[name=body]').value.trim();
|
||||
if (!body) return;
|
||||
var fd = new FormData();
|
||||
fd.append('ticket_id', ticketId);
|
||||
fd.append('body', body);
|
||||
fd.append('is_internal', isInternal ? '1' : '0');
|
||||
fd.append(token, '1');
|
||||
fetch('<?php echo Route::_("index.php?option=com_mokowaas&task=display.submitReply&format=json"); ?>', {
|
||||
method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}
|
||||
}).then(function(r){return r.json()}).then(function(d){
|
||||
if (d.success) location.reload();
|
||||
else alert(d.message);
|
||||
});
|
||||
}
|
||||
|
||||
// Status buttons
|
||||
document.querySelectorAll('.btn-status').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var fd = new FormData();
|
||||
fd.append('ticket_id', ticketId);
|
||||
fd.append('status', this.dataset.status);
|
||||
fd.append(token, '1');
|
||||
fetch('<?php echo Route::_("index.php?option=com_mokowaas&task=display.updateStatus&format=json"); ?>', {
|
||||
method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}
|
||||
}).then(function(r){return r.json()}).then(function(d){
|
||||
if (d.success) location.reload();
|
||||
else alert(d.message);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Assign to me
|
||||
var assignBtn = document.getElementById('btn-assign-me');
|
||||
if (assignBtn) {
|
||||
assignBtn.addEventListener('click', function() {
|
||||
var fd = new FormData();
|
||||
fd.append('ticket_id', ticketId);
|
||||
fd.append('assigned_to', <?php echo $userId; ?>);
|
||||
fd.append(token, '1');
|
||||
fetch('<?php echo Route::_("index.php?option=com_mokowaas&task=display.assignTicket&format=json"); ?>', {
|
||||
method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}
|
||||
}).then(function(r){return r.json()}).then(function(d){
|
||||
if (d.success) location.reload();
|
||||
else alert(d.message);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$tickets = $this->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',
|
||||
];
|
||||
?>
|
||||
|
||||
<div class="mokowaas-portal">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><?php echo $isStaff ? 'All Support Tickets' : 'My Support Tickets'; ?></h2>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokowaas&view=tickets&layout=submit'); ?>" class="btn btn-primary">
|
||||
<span class="icon-plus"></span> New Ticket
|
||||
</a>
|
||||
<?php if ($isStaff): ?>
|
||||
<form method="get" class="d-inline">
|
||||
<input type="hidden" name="option" value="com_mokowaas">
|
||||
<input type="hidden" name="view" value="tickets">
|
||||
<select name="filter_status" class="form-select form-select-sm" style="width:auto" onchange="this.form.submit()">
|
||||
<option value="">All Statuses</option>
|
||||
<?php foreach ($statusLabel as $k => $v): ?>
|
||||
<option value="<?php echo $k; ?>" <?php echo Factory::getApplication()->getInput()->getString('filter_status') === $k ? 'selected' : ''; ?>><?php echo $v; ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (empty($tickets)): ?>
|
||||
<div class="alert alert-info">
|
||||
<span class="icon-info-circle"></span>
|
||||
<?php echo $isStaff ? 'No tickets found.' : 'You haven\'t submitted any support tickets yet.'; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Subject</th>
|
||||
<th>Status</th>
|
||||
<th>Priority</th>
|
||||
<th>Category</th>
|
||||
<?php if ($isStaff): ?><th>Submitted By</th><th>Assigned To</th><?php endif; ?>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($tickets as $t): ?>
|
||||
<tr>
|
||||
<td><a href="<?php echo Route::_('index.php?option=com_mokowaas&view=ticket&id=' . $t->id); ?>"><?php echo $t->id; ?></a></td>
|
||||
<td><a href="<?php echo Route::_('index.php?option=com_mokowaas&view=ticket&id=' . $t->id); ?>"><?php echo htmlspecialchars(mb_substr($t->subject, 0, 60)); ?></a></td>
|
||||
<td><span class="badge bg-<?php echo $statusClass[$t->status] ?? 'secondary'; ?>"><?php echo $statusLabel[$t->status] ?? $t->status; ?></span></td>
|
||||
<td><?php echo ucfirst($t->priority); ?></td>
|
||||
<td><?php echo htmlspecialchars($t->category_title ?? '—'); ?></td>
|
||||
<?php if ($isStaff): ?>
|
||||
<td><?php echo htmlspecialchars($t->created_by_name ?? ''); ?></td>
|
||||
<td><?php echo htmlspecialchars($t->assigned_to_name ?? '<em>Unassigned</em>'); ?></td>
|
||||
<?php endif; ?>
|
||||
<td class="text-nowrap"><?php echo HTMLHelper::_('date', $t->created, 'M d, Y'); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
/**
|
||||
* Submit a Ticket layout — search KB first, then submit form.
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$categories = $this->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) {}
|
||||
?>
|
||||
|
||||
<div class="mokowaas-portal">
|
||||
<h2>Submit a Support Request</h2>
|
||||
|
||||
<?php if ($finderEnabled): ?>
|
||||
<!-- Step 1: Search -->
|
||||
<div id="step-search" class="mb-4">
|
||||
<p class="text-muted">Before submitting, let's see if we already have an answer for you.</p>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<label class="form-label fw-bold" for="kb-search">Describe your issue</label>
|
||||
<div class="input-group input-group-lg">
|
||||
<input type="text" id="kb-search" class="form-control" placeholder="e.g. how do I reset my password?" autofocus>
|
||||
<button type="button" class="btn btn-primary" id="kb-search-btn">
|
||||
<span class="icon-search"></span> Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search results -->
|
||||
<div id="kb-results" class="mt-3 d-none">
|
||||
<h5>Related Articles</h5>
|
||||
<div id="kb-results-list" class="list-group mb-3"></div>
|
||||
<p class="text-muted">Didn't find what you need?</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="button" class="btn btn-outline-primary" id="btn-show-form">
|
||||
<span class="icon-plus"></span> Submit a Ticket Anyway
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Step 2: Ticket Form -->
|
||||
<div id="step-form" class="<?php echo $finderEnabled ? 'd-none' : ''; ?>">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-3">Ticket Details</h5>
|
||||
<form id="submitTicketForm">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="ticket-subject">Subject <span class="text-danger">*</span></label>
|
||||
<input type="text" id="ticket-subject" name="subject" class="form-control" required placeholder="Brief description of your issue">
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="ticket-category">Category</label>
|
||||
<select id="ticket-category" name="category_id" class="form-select">
|
||||
<option value="">Select a category</option>
|
||||
<?php foreach ($categories as $cat): ?>
|
||||
<option value="<?php echo $cat->id; ?>"><?php echo htmlspecialchars($cat->title); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="ticket-priority">Priority</label>
|
||||
<select id="ticket-priority" name="priority" class="form-select">
|
||||
<option value="normal">Normal</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="high">High</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="ticket-body">Description <span class="text-danger">*</span></label>
|
||||
<textarea id="ticket-body" name="body" class="form-control" rows="8" required placeholder="Please describe your issue in detail."></textarea>
|
||||
</div>
|
||||
<input type="hidden" name="<?php echo $token; ?>" value="1">
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<span class="icon-paper-plane"></span> Submit Ticket
|
||||
</button>
|
||||
<a href="<?php echo $ticketsUrl; ?>" class="btn btn-outline-secondary btn-lg">
|
||||
My Tickets
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var searchInput = document.getElementById('kb-search');
|
||||
var searchBtn = document.getElementById('kb-search-btn');
|
||||
var resultBox = document.getElementById('kb-results');
|
||||
var resultList = document.getElementById('kb-results-list');
|
||||
var showFormBtn = document.getElementById('btn-show-form');
|
||||
var stepSearch = document.getElementById('step-search');
|
||||
var stepForm = document.getElementById('step-form');
|
||||
var subjectField = document.getElementById('ticket-subject');
|
||||
|
||||
// Search
|
||||
function doSearch() {
|
||||
var q = (searchInput ? searchInput.value.trim() : '');
|
||||
if (q.length < 3) return;
|
||||
|
||||
fetch('<?php echo $searchUrl; ?>&q=' + encodeURIComponent(q), {
|
||||
headers: {'X-Requested-With': 'XMLHttpRequest'}
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
resultList.textContent = '';
|
||||
if (d.results && d.results.length > 0) {
|
||||
d.results.forEach(function(item) {
|
||||
var a = document.createElement('a');
|
||||
a.href = item.url;
|
||||
a.target = '_blank';
|
||||
a.className = 'list-group-item list-group-item-action';
|
||||
var strong = document.createElement('strong');
|
||||
strong.textContent = item.title;
|
||||
a.appendChild(strong);
|
||||
if (item.description) {
|
||||
a.appendChild(document.createElement('br'));
|
||||
var small = document.createElement('small');
|
||||
small.className = 'text-muted';
|
||||
small.textContent = item.description;
|
||||
a.appendChild(small);
|
||||
}
|
||||
resultList.appendChild(a);
|
||||
});
|
||||
resultBox.classList.remove('d-none');
|
||||
} else {
|
||||
resultBox.classList.add('d-none');
|
||||
}
|
||||
// Always show the "submit anyway" button after search
|
||||
if (showFormBtn) showFormBtn.classList.remove('d-none');
|
||||
});
|
||||
}
|
||||
|
||||
if (searchBtn) searchBtn.addEventListener('click', doSearch);
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); doSearch(); }
|
||||
});
|
||||
}
|
||||
|
||||
// Show form and prefill subject from search query
|
||||
if (showFormBtn) {
|
||||
showFormBtn.addEventListener('click', function() {
|
||||
if (stepSearch) stepSearch.classList.add('d-none');
|
||||
if (stepForm) stepForm.classList.remove('d-none');
|
||||
if (searchInput && subjectField && !subjectField.value) {
|
||||
subjectField.value = searchInput.value;
|
||||
}
|
||||
subjectField.focus();
|
||||
});
|
||||
}
|
||||
|
||||
// Submit ticket
|
||||
var form = document.getElementById('submitTicketForm');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
var btn = form.querySelector('button[type=submit]');
|
||||
btn.disabled = true;
|
||||
btn.textContent = ' Submitting...';
|
||||
var fd = new FormData(form);
|
||||
fetch('<?php echo $submitUrl; ?>', {
|
||||
method: 'POST', body: fd, headers: {'X-Requested-With': 'XMLHttpRequest'}
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.success && d.id) {
|
||||
window.location.href = '<?php echo $ticketUrl; ?>' + d.id;
|
||||
} else {
|
||||
alert(d.message || 'Failed.');
|
||||
btn.disabled = false;
|
||||
btn.textContent = ' Submit Ticket';
|
||||
}
|
||||
})
|
||||
.catch(function() { alert('Network error.'); btn.disabled = false; });
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -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"
|
||||
@@ -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)."
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<extension type="module" client="administrator" method="upgrade">
|
||||
<name>mod_mokowaas_cache</name>
|
||||
<author>Moko Consulting</author>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.32.52</version>
|
||||
<description>MOD_MOKOWAAS_CACHE_DESC</description>
|
||||
<namespace path="src">Moko\Module\MokoWaaSCache</namespace>
|
||||
|
||||
<files>
|
||||
<folder module="mod_mokowaas_cache">services</folder>
|
||||
<folder>src</folder>
|
||||
<folder>tmpl</folder>
|
||||
</files>
|
||||
|
||||
<languages folder="language">
|
||||
<language tag="en-GB">en-GB/mod_mokowaas_cache.ini</language>
|
||||
<language tag="en-GB">en-GB/mod_mokowaas_cache.sys.ini</language>
|
||||
</languages>
|
||||
</extension>
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage mod_mokowaas_cache
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Extension\Service\Provider\Module;
|
||||
use Joomla\CMS\Extension\Service\Provider\ModuleDispatcherFactory;
|
||||
use Joomla\DI\Container;
|
||||
use Joomla\DI\ServiceProviderInterface;
|
||||
|
||||
return new class implements ServiceProviderInterface
|
||||
{
|
||||
public function register(Container $container): void
|
||||
{
|
||||
$container->registerServiceProvider(new ModuleDispatcherFactory('\\Moko\\Module\\MokoWaaSCache'));
|
||||
$container->registerServiceProvider(new Module());
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
namespace Moko\Module\MokoWaaSCache\Administrator\Dispatcher;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Dispatcher\AbstractModuleDispatcher;
|
||||
|
||||
class Dispatcher extends AbstractModuleDispatcher
|
||||
{
|
||||
protected function getLayoutData()
|
||||
{
|
||||
return parent::getLayoutData();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
/**
|
||||
* MokoWaaS Cache Cleaner — status bar module
|
||||
*
|
||||
* One-click button in the admin status bar that clears all Joomla cache.
|
||||
* Uses native Atum header-item markup.
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Session\Session;
|
||||
use Joomla\CMS\Language\Text;
|
||||
|
||||
$token = Session::getFormToken();
|
||||
$ajaxUrl = 'index.php?option=com_mokowaas&task=clearCache&format=json';
|
||||
?>
|
||||
|
||||
<a href="#" class="header-item-content" title="<?php echo Text::_('MOD_MOKOWAAS_CACHE_CLEAR_ALL'); ?>" id="mokowaas-clear-cache">
|
||||
<div class="header-item-icon">
|
||||
<span class="icon-bolt" aria-hidden="true" id="mokowaas-cache-icon"></span>
|
||||
</div>
|
||||
<div class="header-item-text">
|
||||
<?php echo Text::_('MOD_MOKOWAAS_CACHE'); ?>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var btn = document.getElementById('mokowaas-clear-cache');
|
||||
var icon = document.getElementById('mokowaas-cache-icon');
|
||||
if (!btn || !icon) return;
|
||||
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
if (btn.dataset.busy) return;
|
||||
btn.dataset.busy = '1';
|
||||
icon.className = 'icon-spinner icon-spin';
|
||||
|
||||
var formData = new FormData();
|
||||
formData.append('<?php echo $token; ?>', '1');
|
||||
|
||||
fetch('<?php echo $ajaxUrl; ?>', {
|
||||
method: 'POST',
|
||||
headers: {'X-Requested-With': 'XMLHttpRequest'},
|
||||
body: formData
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
icon.className = 'icon-check';
|
||||
icon.style.color = '#198754';
|
||||
} else {
|
||||
icon.className = 'icon-times';
|
||||
icon.style.color = '#dc3545';
|
||||
}
|
||||
setTimeout(function() {
|
||||
icon.className = 'icon-bolt';
|
||||
icon.style.color = '';
|
||||
delete btn.dataset.busy;
|
||||
}, 2000);
|
||||
})
|
||||
.catch(function() {
|
||||
icon.className = 'icon-times';
|
||||
icon.style.color = '#dc3545';
|
||||
setTimeout(function() {
|
||||
icon.className = 'icon-bolt';
|
||||
icon.style.color = '';
|
||||
delete btn.dataset.busy;
|
||||
}, 2000);
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -7,7 +7,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.33.00</version>
|
||||
<version>02.32.52</version>
|
||||
<description>MOD_MOKOWAAS_CPANEL_DESC</description>
|
||||
<namespace path="src">Moko\Module\MokoWaaSCpanel</namespace>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,16 @@ $diskColor = ($diskPct !== null && $diskPct > 90) ? 'bg-danger' : (($diskPct !==
|
||||
<?php if (!empty($siteInfo->offline)): ?>
|
||||
<span class="badge bg-danger">Offline</span>
|
||||
<?php endif; ?>
|
||||
<?php if (($counts->moko_updates ?? 0) > 0): ?>
|
||||
<a href="<?php echo Route::_('index.php?option=com_installer&view=update'); ?>" class="badge bg-info text-decoration-none" title="MokoWaaS updates available">
|
||||
<span class="icon-upload" aria-hidden="true"></span> <?php echo $counts->moko_updates; ?> MokoWaaS update<?php echo $counts->moko_updates > 1 ? 's' : ''; ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<?php if ($counts->updates > 0 && $counts->updates !== ($counts->moko_updates ?? 0)): ?>
|
||||
<a href="<?php echo Route::_('index.php?option=com_installer&view=update'); ?>" class="badge bg-warning text-dark text-decoration-none" title="Other updates available">
|
||||
<span class="icon-upload" aria-hidden="true"></span> <?php echo $counts->updates - ($counts->moko_updates ?? 0); ?> update<?php echo ($counts->updates - ($counts->moko_updates ?? 0)) > 1 ? 's' : ''; ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<span class="icon-chevron-down small text-muted" aria-hidden="true"></span>
|
||||
</a>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokowaas'); ?>" class="btn btn-sm btn-primary">
|
||||
@@ -130,6 +140,12 @@ $diskColor = ($diskPct !== null && $diskPct > 90) ? 'bg-danger' : (($diskPct !==
|
||||
<?php if ($showIp && $currentIp): ?>
|
||||
<span class="text-muted"><span class="icon-globe" aria-hidden="true"></span> <code><?php echo htmlspecialchars($currentIp); ?></code></span>
|
||||
<?php endif; ?>
|
||||
<?php $ssl = $ssl ?? null; if ($ssl): ?>
|
||||
<span class="badge bg-<?php echo $ssl->critical ? 'danger' : ($ssl->warning ? 'warning text-dark' : 'success'); ?>" title="SSL expires <?php echo $ssl->expires; ?>">
|
||||
<span class="icon-lock" aria-hidden="true"></span>
|
||||
SSL <?php echo $ssl->days_remaining; ?>d
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<?php if ($showVersions): ?>
|
||||
<span class="text-muted">J<?php echo htmlspecialchars($siteInfo->joomla_version ?? ''); ?> / PHP <?php echo htmlspecialchars($siteInfo->php_version ?? ''); ?></span>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
MOD_MOKOWAAS_MENU="MokoWaaS Admin Menu"
|
||||
@@ -0,0 +1,2 @@
|
||||
MOD_MOKOWAAS_MENU="MokoWaaS Admin Menu"
|
||||
MOD_MOKOWAAS_MENU_DESC="Dedicated MokoWaaS section in the admin sidebar menu."
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<extension type="module" client="administrator" method="upgrade">
|
||||
<name>mod_mokowaas_menu</name>
|
||||
<author>Moko Consulting</author>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.32.52</version>
|
||||
<description>MokoWaaS admin sidebar menu — renders a dedicated MokoWaaS section in the admin menu before Joomla's default menu.</description>
|
||||
<namespace path="src">Moko\Module\MokoWaaSMenu</namespace>
|
||||
|
||||
<files>
|
||||
<folder module="mod_mokowaas_menu">services</folder>
|
||||
<folder>src</folder>
|
||||
<folder>tmpl</folder>
|
||||
</files>
|
||||
|
||||
<languages folder="language">
|
||||
<language tag="en-GB">en-GB/mod_mokowaas_menu.ini</language>
|
||||
<language tag="en-GB">en-GB/mod_mokowaas_menu.sys.ini</language>
|
||||
</languages>
|
||||
</extension>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Extension\Service\Provider\HelperFactory;
|
||||
use Joomla\CMS\Extension\Service\Provider\Module;
|
||||
use Joomla\CMS\Extension\Service\Provider\ModuleDispatcherFactory;
|
||||
use Joomla\DI\Container;
|
||||
use Joomla\DI\ServiceProviderInterface;
|
||||
|
||||
return new class implements ServiceProviderInterface
|
||||
{
|
||||
public function register(Container $container): void
|
||||
{
|
||||
$container->registerServiceProvider(new ModuleDispatcherFactory('\\Moko\\Module\\MokoWaaSMenu'));
|
||||
$container->registerServiceProvider(new HelperFactory('\\Moko\\Module\\MokoWaaSMenu\\Administrator\\Helper'));
|
||||
$container->registerServiceProvider(new Module());
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
namespace Moko\Module\MokoWaaSMenu\Administrator\Dispatcher;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Dispatcher\AbstractModuleDispatcher;
|
||||
|
||||
class Dispatcher extends AbstractModuleDispatcher
|
||||
{
|
||||
protected function getLayoutData()
|
||||
{
|
||||
return parent::getLayoutData();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
/**
|
||||
* MokoWaaS Admin Sidebar Menu
|
||||
*
|
||||
* Uses native Joomla admin menu classes (MetisMenu) so it renders
|
||||
* identically to Joomla's own sidebar menu items.
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
$items = [
|
||||
['icon' => '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' : '');
|
||||
?>
|
||||
|
||||
<ul class="nav flex-column main-nav">
|
||||
<li class="<?php echo $parentClass; ?>">
|
||||
<a class="has-arrow" href="#" aria-label="MokoWaaS">
|
||||
<span class="icon-shield-alt" aria-hidden="true"></span>
|
||||
<span class="sidebar-item-title">MokoWaaS</span>
|
||||
</a>
|
||||
<ul class="<?php echo $collapseClass; ?>">
|
||||
<?php foreach ($items as $item): ?>
|
||||
<?php
|
||||
$active = false;
|
||||
$parsed = [];
|
||||
parse_str(parse_url($item['link'], PHP_URL_QUERY) ?? '', $parsed);
|
||||
if (($parsed['option'] ?? '') === $currentOption)
|
||||
{
|
||||
$active = empty($parsed['view'])
|
||||
? ($currentView === '' || $currentView === 'dashboard')
|
||||
: ($currentView === ($parsed['view'] ?? ''));
|
||||
}
|
||||
$liClass = 'item item-level-2' . ($active ? ' mm-active' : '');
|
||||
$aClass = 'no-dropdown' . ($active ? ' mm-active' : '');
|
||||
?>
|
||||
<li class="<?php echo $liClass; ?>">
|
||||
<a class="<?php echo $aClass; ?>" href="<?php echo Route::_($item['link']); ?>"<?php echo $active ? ' aria-current="page"' : ''; ?>>
|
||||
<span class="<?php echo $item['icon']; ?>" aria-hidden="true" style="display:inline-block!important;width:1.25em;text-align:center;margin-inline-end:0.4em;"></span>
|
||||
<span class="sidebar-item-title"><?php echo $item['title']; ?></span>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -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(
|
||||
'<strong>Moko Consulting License Key Required</strong> — '
|
||||
. 'No download key is configured. Updates will not be available until a valid license key is entered. '
|
||||
. 'Go to <a href="index.php?option=com_installer&view=updatesites">System → Update Sites</a> '
|
||||
. '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(
|
||||
'<strong>Moko Consulting License Key Invalid</strong> — '
|
||||
. 'Your license key could not be validated. Please verify your key in '
|
||||
. '<a href="index.php?option=com_installer&view=updatesites">System → Update Sites</a>.',
|
||||
'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 <updates></updates> or non-200 means invalid key
|
||||
$isValid = ($httpCode === 200 && $response && strpos($response, '<update>') !== false);
|
||||
|
||||
$session->set('mokowaas.license_invalid', !$isValid);
|
||||
|
||||
if (!$isValid)
|
||||
{
|
||||
$this->app->enqueueMessage(
|
||||
'<strong>Moko Consulting License Key Invalid</strong> — '
|
||||
. 'Your license key could not be validated. Updates will not be available. '
|
||||
. 'Please verify your key in '
|
||||
. '<a href="index.php?option=com_installer&view=updatesites">System → Update Sites</a>.',
|
||||
'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)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -52,7 +52,7 @@ final class MokoWaaSHelper
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function getMasterUsernames(): array
|
||||
private static function getMasterUsernames(): array
|
||||
{
|
||||
if (self::$masterNames !== null)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<license>GNU General Public License version 3 or later; see LICENSE.md</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.33.00</version>
|
||||
<version>02.32.52</version>
|
||||
<description>This plugin rebrands the Joomla system interface with MokoWaaS identity. It applies language overrides and ensures consistent branding across the platform.</description>
|
||||
<namespace path=".">Moko\Plugin\System\MokoWaaS</namespace>
|
||||
<scriptfile>script.php</scriptfile>
|
||||
@@ -76,7 +76,9 @@
|
||||
<fields name="params"
|
||||
addfieldprefix="Moko\Plugin\System\MokoWaaS\Field"
|
||||
>
|
||||
<fieldset name="basic">
|
||||
<fieldset name="basic"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIELDSET_CORE_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIELDSET_CORE_DESC">
|
||||
<field
|
||||
name="health_api_token"
|
||||
type="CopyableToken"
|
||||
@@ -86,87 +88,6 @@
|
||||
filter="raw"
|
||||
readonly="true"
|
||||
/>
|
||||
<field name="dev_mode" type="radio" default="0"
|
||||
label="PLG_SYSTEM_MOKOWAAS_DEV_MODE_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_DEV_MODE_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="reset_hits"
|
||||
type="radio"
|
||||
label="PLG_SYSTEM_MOKOWAAS_RESET_HITS_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_RESET_HITS_DESC"
|
||||
default="0"
|
||||
class="btn-group btn-group-yesno"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="delete_versions"
|
||||
type="radio"
|
||||
label="PLG_SYSTEM_MOKOWAAS_DELETE_VERSIONS_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_DELETE_VERSIONS_DESC"
|
||||
default="0"
|
||||
class="btn-group btn-group-yesno"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
<fieldset name="tenant_restrictions"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIELDSET_TENANT_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIELDSET_TENANT_DESC"
|
||||
>
|
||||
<field name="restrict_installer" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_RESTRICT_INSTALLER_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_RESTRICT_INSTALLER_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="allow_extension_updates" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_ALLOW_UPDATES_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_ALLOW_UPDATES_DESC"
|
||||
class="btn-group btn-group-yesno"
|
||||
showon="restrict_installer:1">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="hide_sysinfo" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_HIDE_SYSINFO_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_HIDE_SYSINFO_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="restrict_global_config" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_RESTRICT_CONFIG_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_RESTRICT_CONFIG_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="restrict_template_editing" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_RESTRICT_TEMPLATE_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_RESTRICT_TEMPLATE_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="disable_install_url" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_DISABLE_INSTALL_URL_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_DISABLE_INSTALL_URL_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="hidden_menu_items" type="textarea"
|
||||
label="PLG_SYSTEM_MOKOWAAS_HIDDEN_MENUS_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_HIDDEN_MENUS_DESC"
|
||||
rows="5" filter="raw" />
|
||||
</fieldset>
|
||||
<fieldset name="demo_mode"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIELDSET_DEMO_LABEL"
|
||||
@@ -198,62 +119,11 @@
|
||||
type="AllowedIps"
|
||||
label=""
|
||||
/>
|
||||
<field name="force_https" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FORCE_HTTPS_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FORCE_HTTPS_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="admin_session_timeout" type="number"
|
||||
label="PLG_SYSTEM_MOKOWAAS_SESSION_TIMEOUT_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_SESSION_TIMEOUT_DESC"
|
||||
default="60" hint="Minutes (0 = Joomla default)" />
|
||||
<field
|
||||
name="current_ip_display"
|
||||
type="CurrentIp"
|
||||
label=""
|
||||
/>
|
||||
<field
|
||||
name="trusted_ips"
|
||||
type="subform"
|
||||
label="PLG_SYSTEM_MOKOWAAS_TRUSTED_IPS_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_TRUSTED_IPS_DESC"
|
||||
formsource="plugins/system/mokowaas/forms/trusted_ip_entry.xml"
|
||||
multiple="true"
|
||||
layout="joomla.form.field.subform.repeatable-table"
|
||||
groupByFieldset="false"
|
||||
buttons="add,remove,move"
|
||||
/>
|
||||
<field name="password_min_length" type="number" default="12"
|
||||
label="PLG_SYSTEM_MOKOWAAS_PASSWORD_LENGTH_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_PASSWORD_LENGTH_DESC" />
|
||||
<field name="password_require_uppercase" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_PASSWORD_UPPER_LABEL"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="password_require_number" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_PASSWORD_NUMBER_LABEL"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="password_require_special" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_PASSWORD_SPECIAL_LABEL"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="upload_allowed_types" type="text"
|
||||
label="PLG_SYSTEM_MOKOWAAS_UPLOAD_TYPES_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_UPLOAD_TYPES_DESC"
|
||||
default="jpg,jpeg,png,gif,webp,svg,pdf,doc,docx,xls,xlsx" />
|
||||
<field name="upload_max_size_mb" type="number"
|
||||
label="PLG_SYSTEM_MOKOWAAS_UPLOAD_SIZE_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_UPLOAD_SIZE_DESC"
|
||||
default="100" />
|
||||
</fieldset>
|
||||
</fields>
|
||||
</config>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user