Compare commits
207 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36658fa8ca | |||
| 5645516845 | |||
| 4ce8c6b4ea | |||
| 01056afe74 | |||
| 3cc39cfa8f | |||
| 0956757445 | |||
| 9c75d0254e | |||
| c847b4a274 | |||
| c93ae27b64 | |||
| 0e28958ede | |||
| 46bb7c31c2 | |||
| 04af4a93a8 | |||
| 3b99c5b6bc | |||
| 1b47876a6c | |||
| 48ff2b2109 | |||
| 0c4857d6e0 | |||
| 9f3e4b9d31 | |||
| 3834ba4c1c | |||
| a8a41e9bad | |||
| 8c927b0a1b | |||
| 21e57eaadc | |||
| fadd3a01cd | |||
| 95097c4d3f | |||
| e71b075d94 | |||
| 1ecc8be8d1 | |||
| 361a58f8cd | |||
| 51ac178281 | |||
| b46da78e6c | |||
| 57a54e8959 | |||
| 1c8625f828 | |||
| 66b19f184c | |||
| 4694e67e1c | |||
| e2e2ac8b56 | |||
| 415eeaac56 | |||
| 4c8bb93952 | |||
| 561fdcd881 | |||
| 0d096acfa8 | |||
| 3db14d29ef | |||
| dfff3c327f | |||
| 4ab3b163f6 | |||
| 60910c2b8b | |||
| 4e0151be1b | |||
| 36d958f31f | |||
| e482a293c9 | |||
| 04a4bf8aba | |||
| b3082f27e3 | |||
| f30d7dd7af | |||
| ecd5b6c786 | |||
| 1f7419f33d | |||
| 171f489e3d | |||
| e808a168cb | |||
| 1f89a323d5 | |||
| 329eca3db6 | |||
| 42b47be564 | |||
| 79ac068bc4 | |||
| ee7260b435 | |||
| 9498a56f98 | |||
| 8ea6df020b | |||
| b304d6c9a2 | |||
| 557c15cbe0 | |||
| 524523b8c6 | |||
| e858130375 | |||
| f0e2228700 | |||
| c5aef3c939 | |||
| f401a76227 | |||
| 71133cdc24 | |||
| 7bd9213ec5 | |||
| 5ca1eb98a8 | |||
| 65a6cdf505 | |||
| 5ab21a0fac | |||
| 0a5d43e12b | |||
| 92b32dd924 | |||
| 0d96174f75 | |||
| 27959a0afe | |||
| 6acae6d20f | |||
| 9a8b3b53fc | |||
| eae734afca | |||
| 1a42a71852 | |||
| 3976ce78c3 | |||
| 9c4d9f060e | |||
| dfa38b6e0e | |||
| c862a01a0f | |||
| 9dacc01a67 | |||
| e76248a1c9 | |||
| d30f8eb0db | |||
| ef654ad3fc | |||
| c7d914f786 | |||
| 729aa3850d | |||
| 6b9a0867ac | |||
| f6c73c4f82 | |||
| b24e4e097b | |||
| 6fa3f4fa82 | |||
| e01167f679 | |||
| d4176836a5 | |||
| 375d11c199 | |||
| ef9d98ea04 | |||
| 6d29e9a853 | |||
| fea6ae9f0a | |||
| 3f69fe6fc1 | |||
| 5d303287c0 | |||
| ffecdc4796 | |||
| b32a7c12e7 | |||
| 64eade2589 | |||
| f1dbc10e4d | |||
| 1289ef81b2 | |||
| 81781e393d | |||
| bd403e4617 | |||
| 7c6d8a1b65 | |||
| 31e1843fe1 | |||
| 1ab8230191 | |||
| 070df8982b | |||
| 07fd04d27e | |||
| 8d4a302730 | |||
| 2fbaf09e88 | |||
| 36082bd2e3 | |||
| 99179ad245 | |||
| 0fccd3f1a4 | |||
| 3bc1e66acf | |||
| dcf115e572 | |||
| 75f73b0dff | |||
| 30a6f6607a | |||
| ef873bda3b | |||
| a2006c2287 | |||
| 3243ecba4a | |||
| 0552c0a0b0 | |||
| 8de7b473a8 | |||
| 130aa26f27 | |||
| 3f6a7af83e | |||
| b8083203e9 | |||
| 290fc0fb99 | |||
| de7a945470 | |||
| 7d9dbe702b | |||
| 1819fa276c | |||
| 31a4d12ceb | |||
| 9fedffe570 | |||
| 8903af5d7f | |||
| 1c7738e276 | |||
| 234c6037c0 | |||
| 055562b06a | |||
| f057f0ba86 | |||
| 500644bc8d | |||
| 47e3802293 | |||
| a30db55024 | |||
| 53dec689b3 | |||
| 861086bf33 | |||
| ca2160d42f | |||
| d193d0992e | |||
| 0620ffd735 | |||
| 76fe9ba311 | |||
| 0b49a959f4 | |||
| 72e5e31a31 | |||
| 1389c26895 | |||
| 69776d9b77 | |||
| 806a798b87 | |||
| 6f7495703c | |||
| 9cb49ec4b9 | |||
| ade768b94c | |||
| d3561dd5c9 | |||
| d899bf945e | |||
| 6ca195fd9f | |||
| abd7a4a35e | |||
| 788c516fd6 | |||
| 2919722dab | |||
| 6892b6ac44 | |||
| 46a9701b62 | |||
| 4b4d5c714b | |||
| 645fbc66c6 | |||
| 8f936fc92c | |||
| 3a1fc7e4ac | |||
| d22d470aa2 | |||
| 8cd80ae7d2 | |||
| 6a4f81dd32 | |||
| dd20e42cb2 | |||
| 63fb1339b8 | |||
| c2a90265d2 | |||
| 53fe8c08a9 | |||
| 5a274f844c | |||
| 4c728ef7b6 | |||
| 79bc17912a | |||
| 236a148d42 | |||
| c9889d4abe | |||
| bc22f33a0c | |||
| 755954425e | |||
| 92cbcfeefd | |||
| aab196c26b | |||
| d306b01260 | |||
| 7e2476b250 | |||
| 6b195d0514 | |||
| fc1f3dd903 | |||
| 4f1b9ac3f2 | |||
| 188defdf1b | |||
| 3b972efcdc | |||
| 2fd3f04f79 | |||
| 2b1bbb9c94 | |||
| 34cf1235c2 | |||
| a8341d456d | |||
| d49fdd24fc | |||
| 47db66b70b | |||
| ca9ef82caf | |||
| b7057745a3 | |||
| 6d3eaa4471 | |||
| 4237740d32 | |||
| f79dc2a26e | |||
| 903999a262 | |||
| d4514aa37d | |||
| 723f25bb59 | |||
| 1522416287 |
@@ -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.32.38</version>
|
||||
<version>02.34.08</version>
|
||||
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
|
||||
</identity>
|
||||
<governance>
|
||||
|
||||
@@ -48,15 +48,12 @@ jobs:
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
if [ -d "/opt/moko-platform/cli" ]; then
|
||||
echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV"
|
||||
else
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
|
||||
fi
|
||||
rm -rf /tmp/moko-platform-api
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Bump version
|
||||
run: |
|
||||
|
||||
@@ -1,283 +1,316 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/universal/auto-release.yml.template
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: Universal build & release � detects platform from manifest.xml
|
||||
#
|
||||
# +========================================================================+
|
||||
# | UNIVERSAL BUILD & RELEASE PIPELINE |
|
||||
# +========================================================================+
|
||||
# | |
|
||||
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
||||
# | |
|
||||
# | Platform-specific: |
|
||||
# | joomla: XML manifest, updates.xml, type-prefixed packages |
|
||||
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
||||
# | generic: README-only, no update stream |
|
||||
# | |
|
||||
# +========================================================================+
|
||||
|
||||
name: "Universal: Build & Release"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, closed]
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
action:
|
||||
description: 'Action to perform'
|
||||
required: false
|
||||
type: choice
|
||||
default: release
|
||||
options:
|
||||
- release
|
||||
- promote-rc
|
||||
|
||||
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:
|
||||
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────
|
||||
promote-rc:
|
||||
name: Promote to RC
|
||||
runs-on: release
|
||||
if: >-
|
||||
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
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-api
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
|
||||
- name: Rename branch to rc
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/branch_rename.php \
|
||||
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
|
||||
--pr "${{ github.event.pull_request.number }}"
|
||||
|
||||
- name: Checkout rc and configure git
|
||||
run: |
|
||||
git fetch origin rc
|
||||
git checkout rc
|
||||
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"
|
||||
|
||||
- name: Publish RC release
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/release_publish.php \
|
||||
--path . --stability rc --bump minor --branch rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Branch renamed to rc, minor bump, RC + lesser stream releases built, updates.xml synced" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
|
||||
release:
|
||||
name: Build & Release Pipeline
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event.pull_request.merged == true ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure git for bot pushes
|
||||
run: |
|
||||
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"
|
||||
|
||||
- name: Check for merge conflict markers
|
||||
run: |
|
||||
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
|
||||
if [ -n "$CONFLICTS" ]; then
|
||||
echo "::error::Merge conflict markers found — aborting release"
|
||||
echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "No conflict markers found"
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
|
||||
run: |
|
||||
# Ensure PHP + Composer are available
|
||||
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-api
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
|
||||
|
||||
- name: "Publish stable release"
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/release_publish.php \
|
||||
--path . --stability stable --bump minor --branch main \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
|
||||
- name: "Step 9: Mirror release to GitHub"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
secrets.GH_MIRROR_TOKEN != ''
|
||||
continue-on-error: true
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php /tmp/moko-platform-api/cli/release_mirror.php \
|
||||
--version "$VERSION" --tag "$RELEASE_TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
|
||||
--branch main 2>&1 || true
|
||||
echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- STEP 10: Sync main branch to GitHub mirror ----------------------------
|
||||
- name: "Step 10: Push main to GitHub mirror"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
secrets.GH_MIRROR_TOKEN != ''
|
||||
continue-on-error: true
|
||||
run: |
|
||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||
GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
|
||||
GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
|
||||
git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
|
||||
git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
|
||||
git fetch origin main --depth=1
|
||||
git push github origin/main:refs/heads/main --force 2>/dev/null \
|
||||
&& echo "main branch pushed to GitHub mirror" \
|
||||
|| echo "WARNING: GitHub mirror push failed"
|
||||
|
||||
- name: "Step 11: Delete rc branch and recreate dev from main"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# Delete rc branch (ephemeral — created by promote-rc)
|
||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/branches/rc" 2>/dev/null \
|
||||
&& echo "Deleted rc branch" || echo "rc branch not found"
|
||||
|
||||
# Delete dev branch
|
||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
|
||||
|
||||
# Recreate dev from main (now includes version bump + changelog promotion)
|
||||
curl -sf -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_BASE}/branches" \
|
||||
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
|
||||
|
||||
echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: "Step 12: Create version branch from main"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
BRANCH_NAME="version/${VERSION}"
|
||||
MAIN_SHA=$(git rev-parse HEAD)
|
||||
|
||||
# Delete old version branch if it exists (same version re-release)
|
||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}"
|
||||
|
||||
# Create version/XX.YY.ZZ from main
|
||||
curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed"
|
||||
|
||||
echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
|
||||
|
||||
# -- Dolibarr post-release: Reset dev version -----------------------------
|
||||
- name: "Post-release: Reset dev version"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php /tmp/moko-platform-api/cli/version_reset_dev.php \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
|
||||
--branch dev --path . 2>&1 || true
|
||||
|
||||
# -- Summary --------------------------------------------------------------
|
||||
- name: Pipeline Summary
|
||||
if: always()
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
|
||||
echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
|
||||
echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
|
||||
elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
|
||||
echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/universal/auto-release.yml.template
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: Universal build & release � detects platform from manifest.xml
|
||||
#
|
||||
# +========================================================================+
|
||||
# | UNIVERSAL BUILD & RELEASE PIPELINE |
|
||||
# +========================================================================+
|
||||
# | |
|
||||
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
||||
# | |
|
||||
# | Platform-specific: |
|
||||
# | joomla: XML manifest, updates.xml, type-prefixed packages |
|
||||
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
||||
# | generic: README-only, no update stream |
|
||||
# | |
|
||||
# +========================================================================+
|
||||
|
||||
name: "Universal: Build & Release"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, closed]
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
action:
|
||||
description: 'Action to perform'
|
||||
required: false
|
||||
type: choice
|
||||
default: release
|
||||
options:
|
||||
- release
|
||||
- promote-rc
|
||||
|
||||
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:
|
||||
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────
|
||||
promote-rc:
|
||||
name: Promote to RC
|
||||
runs-on: release
|
||||
if: >-
|
||||
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
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
|
||||
rm -rf /tmp/moko-platform-api
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Rename branch to rc
|
||||
run: |
|
||||
php ${MOKO_CLI}/branch_rename.php \
|
||||
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
|
||||
--pr "${{ github.event.pull_request.number }}"
|
||||
|
||||
- name: Checkout rc and configure git
|
||||
run: |
|
||||
git fetch origin rc
|
||||
git checkout rc
|
||||
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"
|
||||
|
||||
- name: Publish RC release
|
||||
run: |
|
||||
php ${MOKO_CLI}/release_publish.php \
|
||||
--path . --stability rc --bump minor --branch rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--skip-update-stream
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Branch renamed to rc, minor bump, RC release built (updates.xml managed by Gitea Pages)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
|
||||
release:
|
||||
name: Build & Release Pipeline
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event.pull_request.merged == true ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure git for bot pushes
|
||||
run: |
|
||||
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"
|
||||
|
||||
- name: Check for merge conflict markers
|
||||
run: |
|
||||
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
|
||||
if [ -n "$CONFLICTS" ]; then
|
||||
echo "::error::Merge conflict markers found - aborting release"
|
||||
echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "No conflict markers found"
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_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
|
||||
rm -rf /tmp/moko-platform-api
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
|
||||
|
||||
- name: "Publish stable release"
|
||||
run: |
|
||||
php ${MOKO_CLI}/release_publish.php \
|
||||
--path . --stability stable --bump minor --branch main \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--skip-update-stream
|
||||
|
||||
- name: Update release notes from CHANGELOG.md
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Extract [Unreleased] section from changelog
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||
[ -z "$NOTES" ] && NOTES="Stable release"
|
||||
else
|
||||
NOTES="Stable release"
|
||||
fi
|
||||
|
||||
# Update release body via API
|
||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/stable" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
body = open('/dev/stdin').read()
|
||||
payload = json.dumps({'body': body}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/releases/${RELEASE_ID}',
|
||||
data=payload, method='PATCH',
|
||||
headers={
|
||||
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
urllib.request.urlopen(req)
|
||||
" <<< "$NOTES"
|
||||
echo "Release notes updated from CHANGELOG.md"
|
||||
fi
|
||||
|
||||
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
|
||||
- name: "Step 9: Mirror release to GitHub"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
secrets.GH_MIRROR_TOKEN != ''
|
||||
continue-on-error: true
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php ${MOKO_CLI}/release_mirror.php \
|
||||
--version "$VERSION" --tag "$RELEASE_TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
|
||||
--branch main 2>&1 || true
|
||||
echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- STEP 10: Sync main branch to GitHub mirror ----------------------------
|
||||
- name: "Step 10: Push main to GitHub mirror"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
secrets.GH_MIRROR_TOKEN != ''
|
||||
continue-on-error: true
|
||||
run: |
|
||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||
GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
|
||||
GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
|
||||
git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
|
||||
git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
|
||||
git fetch origin main --depth=1
|
||||
git push github origin/main:refs/heads/main --force 2>/dev/null \
|
||||
&& echo "main branch pushed to GitHub mirror" \
|
||||
|| echo "WARNING: GitHub mirror push failed"
|
||||
|
||||
- name: "Step 11: Delete rc branch and recreate dev from main"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# Delete rc branch (ephemeral - created by promote-rc)
|
||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/branches/rc" 2>/dev/null \
|
||||
&& echo "Deleted rc branch" || echo "rc branch not found"
|
||||
|
||||
# Delete dev branch
|
||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
|
||||
|
||||
# Recreate dev from main (now includes version bump + changelog promotion)
|
||||
curl -sf -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_BASE}/branches" \
|
||||
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
|
||||
|
||||
echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: "Step 12: Create version branch from main"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
BRANCH_NAME="version/${VERSION}"
|
||||
MAIN_SHA=$(git rev-parse HEAD)
|
||||
|
||||
# Delete old version branch if it exists (same version re-release)
|
||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}"
|
||||
|
||||
# Create version/XX.YY.ZZ from main
|
||||
curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed"
|
||||
|
||||
echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
|
||||
|
||||
# -- Dolibarr post-release: Reset dev version -----------------------------
|
||||
- name: "Post-release: Reset dev version"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php ${MOKO_CLI}/version_reset_dev.php \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
|
||||
--branch dev --path . 2>&1 || true
|
||||
|
||||
# -- Summary --------------------------------------------------------------
|
||||
- name: Pipeline Summary
|
||||
if: always()
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
|
||||
echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
|
||||
echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
|
||||
elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
|
||||
echo "## Already Released - ${VERSION}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Automation
|
||||
# VERSION: 02.32.38
|
||||
# VERSION: 02.34.08
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
@@ -147,6 +147,98 @@ jobs:
|
||||
echo "PHP lint: ${ERRORS} error(s)"
|
||||
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
|
||||
|
||||
- name: Joomla JEXEC guard check
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
ERRORS=0
|
||||
while IFS= read -r -d '' file; do
|
||||
# Skip vendor, node_modules, and index.html stub files
|
||||
case "$file" in ./vendor/*|./node_modules/*) continue ;; esac
|
||||
# Check first 10 lines for JEXEC or JPATH guard
|
||||
if ! head -20 "$file" | grep -qE "defined\s*\(\s*['\"](_JEXEC|JPATH_BASE|\\\\JPATH_PLATFORM)['\"]"; then
|
||||
echo "::error file=${file}::Missing JEXEC guard: ${file}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find . -name "*.php" -path "*/src/*" -not -path "./.git/*" -not -path "./vendor/*" -print0)
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo "::error::${ERRORS} PHP file(s) missing defined('_JEXEC') or die guard"
|
||||
echo "## JEXEC Guard Check: Failed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${ERRORS} file(s) in src/ are missing the Joomla execution guard." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "JEXEC guard: OK"
|
||||
|
||||
- name: Joomla directory listing protection
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
MISSING=0
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && exit 0
|
||||
while IFS= read -r dir; do
|
||||
if [ ! -f "${dir}/index.html" ]; then
|
||||
echo "::warning::Missing index.html in ${dir} (directory listing protection)"
|
||||
MISSING=$((MISSING + 1))
|
||||
fi
|
||||
done < <(find "$SOURCE_DIR" -type d -not -path "./.git/*" -not -path "*/vendor/*" -not -path "*/node_modules/*")
|
||||
if [ "$MISSING" -gt 0 ]; then
|
||||
echo "## Directory Protection" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${MISSING} director(ies) missing index.html" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "Directory protection: ${MISSING} missing (advisory)"
|
||||
|
||||
- name: Joomla script file and asset checks
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
ERRORS=0
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
[ -z "$MANIFEST" ] && exit 0
|
||||
MANIFEST_DIR=$(dirname "$MANIFEST")
|
||||
|
||||
# Check scriptfile exists if declared
|
||||
SCRIPTFILE=$(sed -n 's/.*<scriptfile>\([^<]*\)<\/scriptfile>.*/\1/p' "$MANIFEST" 2>/dev/null)
|
||||
if [ -n "$SCRIPTFILE" ]; then
|
||||
if [ ! -f "${MANIFEST_DIR}/${SCRIPTFILE}" ]; then
|
||||
echo "::error::Manifest declares <scriptfile>${SCRIPTFILE}</scriptfile> but file not found at ${MANIFEST_DIR}/${SCRIPTFILE}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "Script file: ${MANIFEST_DIR}/${SCRIPTFILE} (OK)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Require joomla.asset.json and validate it
|
||||
ASSET_JSON=$(find "$MANIFEST_DIR" -name "joomla.asset.json" -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
if [ -z "$ASSET_JSON" ]; then
|
||||
echo "::error::joomla.asset.json not found — Joomla asset system is required"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "json_decode(file_get_contents('$ASSET_JSON')); if(json_last_error()!==JSON_ERROR_NONE){echo json_last_error_msg();exit(1);}" 2>&1 || {
|
||||
echo "::error::joomla.asset.json is not valid JSON"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
}
|
||||
fi
|
||||
echo "joomla.asset.json: valid"
|
||||
fi
|
||||
|
||||
# Validate all XML files in src/ are well-formed
|
||||
XML_ERRORS=0
|
||||
if command -v php &> /dev/null; then
|
||||
while IFS= read -r -d '' xmlfile; do
|
||||
if ! php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$xmlfile'); if(!\$x){foreach(libxml_get_errors() as \$e) echo trim(\$e->message) . ' in $xmlfile'; exit(1);}" 2>&1; then
|
||||
XML_ERRORS=$((XML_ERRORS + 1))
|
||||
fi
|
||||
done < <(find "$MANIFEST_DIR" -name "*.xml" -not -path "./.git/*" -print0)
|
||||
fi
|
||||
if [ "$XML_ERRORS" -gt 0 ]; then
|
||||
echo "::error::${XML_ERRORS} XML file(s) are malformed"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "XML well-formedness: OK"
|
||||
fi
|
||||
|
||||
[ "$ERRORS" -gt 0 ] && exit 1
|
||||
echo "Joomla asset checks: OK"
|
||||
|
||||
- name: Validate platform manifest
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
@@ -164,6 +256,13 @@ jobs:
|
||||
for ELEMENT in name version description; do
|
||||
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
|
||||
done
|
||||
# Block legacy raw/branch update server URLs on MokoGitea
|
||||
RAW_URLS=$(grep -n 'raw/branch' "$MANIFEST" | grep -i 'mokoconsulting\|mokogitea\|git\.mokoconsulting\.tech' || true)
|
||||
if [ -n "$RAW_URLS" ]; then
|
||||
echo "::error::Manifest contains legacy raw/branch update server URL on MokoGitea. Use the Gitea Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)"
|
||||
echo "$RAW_URLS"
|
||||
exit 1
|
||||
fi
|
||||
echo "Joomla manifest valid"
|
||||
;;
|
||||
dolibarr)
|
||||
@@ -196,6 +295,162 @@ jobs:
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check changelog has unreleased entries (PRs to main)
|
||||
if: github.base_ref == 'main'
|
||||
run: |
|
||||
if [ ! -f "CHANGELOG.md" ]; then
|
||||
echo "::error::CHANGELOG.md not found — required for releases"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract content between [Unreleased] and next ## heading
|
||||
ENTRIES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found && /^- /{count++} END{print count+0}' CHANGELOG.md)
|
||||
|
||||
if [ "$ENTRIES" -eq 0 ]; then
|
||||
echo "::error::CHANGELOG.md has no entries under [Unreleased]. Add changelog entries before releasing."
|
||||
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "No entries found under \`[Unreleased]\` in CHANGELOG.md." >> $GITHUB_STEP_SUMMARY
|
||||
echo "Add entries describing what changed before merging to main." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Changelog: ${ENTRIES} unreleased entries found"
|
||||
echo "## Changelog Check: Passed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${ENTRIES} entries under [Unreleased]" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Validate Joomla language files
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
ERRORS=0
|
||||
WARNINGS=0
|
||||
|
||||
# Require both en-GB and en-US language directories
|
||||
LANG_ROOT=$(find . -path "*/language" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
if [ -z "$LANG_ROOT" ]; then
|
||||
echo "No language/ directory found — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -d "$LANG_ROOT/en-GB" ]; then
|
||||
echo "::error::Missing en-GB language directory (${LANG_ROOT}/en-GB)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
if [ ! -d "$LANG_ROOT/en-US" ]; then
|
||||
echo "::error::Missing en-US language directory (${LANG_ROOT}/en-US)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
|
||||
# Check that en-GB and en-US have matching .ini files
|
||||
if [ -d "$LANG_ROOT/en-GB" ] && [ -d "$LANG_ROOT/en-US" ]; then
|
||||
for GB_INI in "$LANG_ROOT/en-GB"/*.ini; do
|
||||
[ ! -f "$GB_INI" ] && continue
|
||||
US_INI="$LANG_ROOT/en-US/$(basename "$GB_INI")"
|
||||
if [ ! -f "$US_INI" ]; then
|
||||
echo "::error::$(basename "$GB_INI") exists in en-GB but missing from en-US"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
for US_INI in "$LANG_ROOT/en-US"/*.ini; do
|
||||
[ ! -f "$US_INI" ] && continue
|
||||
GB_INI="$LANG_ROOT/en-GB/$(basename "$US_INI")"
|
||||
if [ ! -f "$GB_INI" ]; then
|
||||
echo "::error::$(basename "$US_INI") exists in en-US but missing from en-GB"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Find all .ini language files
|
||||
INI_FILES=$(find . -path "*/language/*/*.ini" -not -path "./.git/*" 2>/dev/null)
|
||||
if [ -z "$INI_FILES" ]; then
|
||||
echo "No .ini language files found"
|
||||
[ "$ERRORS" -gt 0 ] && exit 1
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Found $(echo "$INI_FILES" | wc -l) language file(s)"
|
||||
|
||||
for FILE in $INI_FILES; do
|
||||
FNAME=$(basename "$FILE")
|
||||
LINENUM=0
|
||||
SEEN_KEYS=""
|
||||
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
LINENUM=$((LINENUM + 1))
|
||||
|
||||
# Skip empty lines and comments
|
||||
[ -z "$line" ] && continue
|
||||
echo "$line" | grep -qE '^\s*;' && continue
|
||||
echo "$line" | grep -qE '^\s*$' && continue
|
||||
|
||||
# Must match KEY="VALUE" format
|
||||
if ! echo "$line" | grep -qE '^[A-Z_][A-Z0-9_]*=".*"$'; then
|
||||
echo "::error file=${FILE},line=${LINENUM}::Malformed line: ${line}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Extract key and check for duplicates
|
||||
KEY=$(echo "$line" | sed 's/=.*//')
|
||||
if echo "$SEEN_KEYS" | grep -qx "$KEY"; then
|
||||
echo "::error file=${FILE},line=${LINENUM}::Duplicate key: ${KEY}"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
SEEN_KEYS="${SEEN_KEYS}
|
||||
${KEY}"
|
||||
done < "$FILE"
|
||||
|
||||
echo " ${FILE}: checked ${LINENUM} lines"
|
||||
done
|
||||
|
||||
# Cross-check en-GB vs en-US key consistency
|
||||
GB_DIR=$(find . -path "*/language/en-GB" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
US_DIR=$(find . -path "*/language/en-US" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
||||
|
||||
if [ -n "$GB_DIR" ] && [ -n "$US_DIR" ]; then
|
||||
for GB_FILE in "$GB_DIR"/*.ini; do
|
||||
[ ! -f "$GB_FILE" ] && continue
|
||||
FNAME=$(basename "$GB_FILE")
|
||||
US_FILE="$US_DIR/$FNAME"
|
||||
[ ! -f "$US_FILE" ] && continue
|
||||
|
||||
GB_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$GB_FILE" 2>/dev/null | sort)
|
||||
US_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$US_FILE" 2>/dev/null | sort)
|
||||
|
||||
# Keys in en-GB but not en-US
|
||||
MISSING_US=$(comm -23 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
|
||||
if [ -n "$MISSING_US" ]; then
|
||||
echo "::warning::Keys in en-GB/$FNAME but missing from en-US/$FNAME:"
|
||||
echo "$MISSING_US" | while read -r k; do echo " - $k"; done
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
fi
|
||||
|
||||
# Keys in en-US but not en-GB
|
||||
MISSING_GB=$(comm -13 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
|
||||
if [ -n "$MISSING_GB" ]; then
|
||||
echo "::warning::Keys in en-US/$FNAME but missing from en-GB/$FNAME:"
|
||||
echo "$MISSING_GB" | while read -r k; do echo " - $k"; done
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
{
|
||||
echo "### Language File Validation"
|
||||
echo "| Metric | Count |"
|
||||
echo "|---|---|"
|
||||
echo "| Files checked | $(echo "$INI_FILES" | wc -l) |"
|
||||
echo "| Errors | ${ERRORS} |"
|
||||
echo "| Warnings | ${WARNINGS} |"
|
||||
} >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo "::error::Language validation failed with ${ERRORS} error(s)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Language files: OK (${WARNINGS} warning(s))"
|
||||
|
||||
- name: Check changelog has unreleased entry
|
||||
run: |
|
||||
if [ ! -f "CHANGELOG.md" ]; then
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /templates/workflows/universal/pre-release.yml.template
|
||||
# VERSION: 09.23.00
|
||||
# VERSION: 05.01.00
|
||||
# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch
|
||||
|
||||
name: "Universal: Pre-Release"
|
||||
@@ -17,6 +17,10 @@ on:
|
||||
types: [closed]
|
||||
branches:
|
||||
- dev
|
||||
pull_request_target:
|
||||
types: [synchronize, opened, reopened]
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stability:
|
||||
@@ -43,7 +47,8 @@ jobs:
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev')
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev') ||
|
||||
(github.event_name == 'pull_request_target' && github.event.pull_request.base.ref == 'main')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -51,7 +56,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
submodules: recursive
|
||||
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
@@ -61,7 +66,6 @@ jobs:
|
||||
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-api
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
@@ -77,24 +81,40 @@ jobs:
|
||||
- name: Resolve metadata and bump version
|
||||
id: meta
|
||||
run: |
|
||||
STABILITY="${{ inputs.stability || 'development' }}"
|
||||
# Auto-detect stability: RC for PRs targeting main, else use input or default to development
|
||||
if [ "${{ github.event_name }}" = "pull_request_target" ] && [ "${{ github.event.pull_request.base.ref }}" = "main" ]; then
|
||||
STABILITY="release-candidate"
|
||||
else
|
||||
STABILITY="${{ inputs.stability || 'development' }}"
|
||||
fi
|
||||
|
||||
case "$STABILITY" in
|
||||
development) TAG="development" ;;
|
||||
alpha) TAG="alpha" ;;
|
||||
beta) TAG="beta" ;;
|
||||
release-candidate) TAG="release-candidate" ;;
|
||||
development) SUFFIX="-dev"; TAG="development" ;;
|
||||
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
|
||||
beta) SUFFIX="-beta"; TAG="beta" ;;
|
||||
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
|
||||
esac
|
||||
|
||||
# Set stability suffix, bump preserves it, fix consistency
|
||||
# Bump version via CLI: patch for dev/alpha/beta, minor for RC
|
||||
case "$STABILITY" in
|
||||
release-candidate) BUMP="minor" ;;
|
||||
*) BUMP="patch" ;;
|
||||
esac
|
||||
|
||||
php ${MOKO_CLI}/version_bump.php --path . $([ "$BUMP" = "minor" ] && echo "--minor") 2>/dev/null || true
|
||||
|
||||
# Set stability suffix and verify consistency
|
||||
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01")
|
||||
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
|
||||
|
||||
php ${MOKO_CLI}/version_set_platform.php \
|
||||
--path . --version "$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo '00.00.01')" \
|
||||
--branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true
|
||||
--path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 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)
|
||||
[ -z "$VERSION" ] && VERSION="00.00.01"
|
||||
# Append suffix for output
|
||||
if [ -n "$SUFFIX" ]; then
|
||||
VERSION="${VERSION}${SUFFIX}"
|
||||
fi
|
||||
|
||||
# Commit version bump
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
@@ -119,11 +139,12 @@ jobs:
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
||||
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION} ==="
|
||||
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
|
||||
|
||||
- name: Create release
|
||||
id: release
|
||||
@@ -136,6 +157,41 @@ jobs:
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --branch dev --prerelease
|
||||
|
||||
- name: Update release notes from CHANGELOG.md
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Extract [Unreleased] section from changelog (everything between [Unreleased] and next ## heading)
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
|
||||
else
|
||||
NOTES="Release ${VERSION}"
|
||||
fi
|
||||
|
||||
# Update release body via API
|
||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/${TAG}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
body = open('/dev/stdin').read()
|
||||
payload = json.dumps({'body': body}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/releases/${RELEASE_ID}',
|
||||
data=payload, method='PATCH',
|
||||
headers={
|
||||
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
urllib.request.urlopen(req)
|
||||
" <<< "$NOTES"
|
||||
echo "Release notes updated from CHANGELOG.md"
|
||||
fi
|
||||
|
||||
- name: Build package and upload
|
||||
id: package
|
||||
run: |
|
||||
@@ -147,55 +203,8 @@ jobs:
|
||||
--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
|
||||
if ! git diff --quiet updates.xml 2>/dev/null; then
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git add updates.xml
|
||||
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
|
||||
git push origin HEAD 2>&1 || echo "WARNING: push failed"
|
||||
fi
|
||||
|
||||
- name: "Sync updates.xml to all branches"
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
CURRENT_BRANCH="${{ github.ref_name }}"
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
|
||||
for BRANCH in main dev; do
|
||||
[ "$BRANCH" = "$CURRENT_BRANCH" ] && continue
|
||||
echo "Syncing updates.xml -> ${BRANCH}"
|
||||
git fetch origin "${BRANCH}" 2>/dev/null || continue
|
||||
git checkout "origin/${BRANCH}" -- updates.xml 2>/dev/null || continue
|
||||
git checkout "${CURRENT_BRANCH}" -- updates.xml
|
||||
if ! git diff --quiet updates.xml 2>/dev/null; then
|
||||
git add updates.xml
|
||||
git commit -m "chore: sync updates.xml from ${CURRENT_BRANCH} [skip ci]"
|
||||
git push origin HEAD:refs/heads/${BRANCH} 2>&1 || echo "WARNING: push to ${BRANCH} failed"
|
||||
fi
|
||||
git checkout "${CURRENT_BRANCH}" 2>/dev/null
|
||||
done
|
||||
# updates.xml is generated dynamically by MokoGitea license server
|
||||
# No need to build, commit, or sync updates.xml from workflows
|
||||
|
||||
- name: "Delete lesser pre-release channels (cascade)"
|
||||
continue-on-error: true
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/joomla/repo_health.yml.template
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Enforces repository guardrails by validating release configuration, scripts governance, tooling availability, and core repository health artifacts.
|
||||
# BRIEF: Enforces repository guardrails by validating scripts governance, tooling availability, and core repository health artifacts.
|
||||
# ============================================================================
|
||||
|
||||
name: "Generic: Repo Health"
|
||||
@@ -24,13 +24,12 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
profile:
|
||||
description: 'Validation profile: all, release, scripts, or repo'
|
||||
description: 'Validation profile: all, scripts, or repo'
|
||||
required: true
|
||||
default: all
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- release
|
||||
- scripts
|
||||
- repo
|
||||
pull_request:
|
||||
@@ -40,10 +39,6 @@ permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
# Release policy - Repository Variables Only
|
||||
RELEASE_REQUIRED_REPO_VARS: RS_FTP_PATH_SUFFIX
|
||||
RELEASE_OPTIONAL_REPO_VARS: DEV_FTP_SUFFIX
|
||||
|
||||
# Scripts governance policy
|
||||
SCRIPTS_REQUIRED_DIRS:
|
||||
SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate
|
||||
@@ -138,101 +133,6 @@ jobs:
|
||||
printf '%s\n' 'ERROR: Access denied. Admin permission required.' >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
|
||||
release_config:
|
||||
name: Release configuration
|
||||
needs: access_check
|
||||
if: ${{ needs.access_check.outputs.allowed == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Guardrails release vars
|
||||
env:
|
||||
PROFILE_RAW: ${{ github.event.inputs.profile }}
|
||||
RS_FTP_PATH_SUFFIX: ${{ vars.RS_FTP_PATH_SUFFIX }}
|
||||
DEV_FTP_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
profile="${PROFILE_RAW:-all}"
|
||||
case "${profile}" in
|
||||
all|release|scripts|repo) ;;
|
||||
*)
|
||||
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${profile}" = 'scripts' ] || [ "${profile}" = 'repo' ]; then
|
||||
{
|
||||
printf '%s\n' '### Release configuration (Repository Variables)'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' 'Status: SKIPPED'
|
||||
printf '%s\n' 'Reason: profile excludes release validation'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IFS=',' read -r -a required <<< "${RELEASE_REQUIRED_REPO_VARS}"
|
||||
IFS=',' read -r -a optional <<< "${RELEASE_OPTIONAL_REPO_VARS}"
|
||||
|
||||
missing=()
|
||||
missing_optional=()
|
||||
|
||||
for k in "${required[@]}"; do
|
||||
v="${!k:-}"
|
||||
[ -z "${v}" ] && missing+=("${k}")
|
||||
done
|
||||
|
||||
for k in "${optional[@]}"; do
|
||||
v="${!k:-}"
|
||||
[ -z "${v}" ] && missing_optional+=("${k}")
|
||||
done
|
||||
|
||||
{
|
||||
printf '%s\n' '### Release configuration (Repository Variables)'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' '| Variable | Status |'
|
||||
printf '%s\n' '|---|---|'
|
||||
printf '%s\n' "| RS_FTP_PATH_SUFFIX | ${RS_FTP_PATH_SUFFIX:-NOT SET} |"
|
||||
printf '%s\n' "| DEV_FTP_SUFFIX | ${DEV_FTP_SUFFIX:-NOT SET} |"
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
if [ "${#missing_optional[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Missing optional repository variables'
|
||||
for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Missing required repository variables'
|
||||
for m in "${missing[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '%s\n' 'ERROR: Guardrails failed. Missing required repository variables.'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
printf '%s\n' '### Repository variables validation result'
|
||||
printf '%s\n' 'Status: OK'
|
||||
printf '%s\n' 'All required repository variables present.'
|
||||
printf '%s\n' ''
|
||||
printf '%s\n' '**Note**: Organization secrets (RS_FTP_HOST, RS_FTP_USER, etc.) are validated at deployment time, not in repository health checks.'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
scripts_governance:
|
||||
name: Scripts governance
|
||||
needs: access_check
|
||||
@@ -256,14 +156,14 @@ jobs:
|
||||
|
||||
profile="${PROFILE_RAW:-all}"
|
||||
case "${profile}" in
|
||||
all|release|scripts|repo) ;;
|
||||
all|scripts|repo) ;;
|
||||
*)
|
||||
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${profile}" = 'release' ] || [ "${profile}" = 'repo' ]; then
|
||||
if [ "${profile}" = 'repo' ]; then
|
||||
{
|
||||
printf '%s\n' '### Scripts governance'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
@@ -370,14 +270,14 @@ jobs:
|
||||
|
||||
profile="${PROFILE_RAW:-all}"
|
||||
case "${profile}" in
|
||||
all|release|scripts|repo) ;;
|
||||
all|scripts|repo) ;;
|
||||
*)
|
||||
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${profile}" = 'release' ] || [ "${profile}" = 'scripts' ]; then
|
||||
if [ "${profile}" = 'scripts' ]; then
|
||||
{
|
||||
printf '%s\n' '### Repository health'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
@@ -704,7 +604,7 @@ jobs:
|
||||
printf '%s\n' '| Domain | Status | Notes |'
|
||||
printf '%s\n' '|---|---|---|'
|
||||
printf '%s\n' '| Access control | OK | Admin-only execution gate |'
|
||||
printf '%s\n' '| Release variables | OK | Repository variables validation |'
|
||||
printf '%s\n' '| Release policy | N/A | Releases handled by MokoGitea |'
|
||||
printf '%s\n' '| Scripts governance | OK | Directory policy and advisory reporting |'
|
||||
printf '%s\n' '| Repo required artifacts | OK | Required, optional, disallowed enforcement |'
|
||||
printf '%s\n' '| Repo content heuristics | OK | Brand, license, changelog structure |'
|
||||
@@ -773,11 +673,10 @@ jobs:
|
||||
report-issues:
|
||||
name: "Report Issues"
|
||||
runs-on: ubuntu-latest
|
||||
needs: [access_check, release_config, scripts_governance, repo_health]
|
||||
needs: [access_check, scripts_governance, repo_health]
|
||||
if: >-
|
||||
always() &&
|
||||
(needs.release_config.result == 'failure' ||
|
||||
needs.scripts_governance.result == 'failure' ||
|
||||
(needs.scripts_governance.result == 'failure' ||
|
||||
needs.repo_health.result == 'failure')
|
||||
|
||||
steps:
|
||||
@@ -803,10 +702,6 @@ jobs:
|
||||
fi
|
||||
}
|
||||
|
||||
report_gate "Release Configuration" \
|
||||
"${{ needs.release_config.result }}" \
|
||||
"Required repository variables are missing (RS_FTP_PATH_SUFFIX). Check repository settings."
|
||||
|
||||
report_gate "Scripts Governance" \
|
||||
"${{ needs.scripts_governance.result }}" \
|
||||
"Scripts directory policy violations detected. Review required and allowed directories."
|
||||
@@ -814,4 +709,3 @@ jobs:
|
||||
report_gate "Repository Health" \
|
||||
"${{ needs.repo_health.result }}" \
|
||||
"Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary."
|
||||
|
||||
|
||||
@@ -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
|
||||
+53
-18
@@ -14,12 +14,58 @@
|
||||
INGROUP: MokoWaaS.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
PATH: ./CHANGELOG.md
|
||||
VERSION: 02.32.38
|
||||
VERSION: 02.34.08
|
||||
BRIEF: Version history using `Keep a Changelog`
|
||||
-->
|
||||
|
||||
# Changelog
|
||||
## [02.32.00] - 2026-06-02
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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 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)
|
||||
- preflight() ALTER for #__extensions.element default (MySQL strict mode fix)
|
||||
- Retire MokoJoomTOS, MokoATS-Automation, MokoDPCalendarAPI, MokoGalleryCalendar on install
|
||||
- MokoJoomTOS settings auto-migrate to mokowaas_offline before removal
|
||||
- dev-release and pre-release workflows with changelog extraction into release notes
|
||||
- RC pre-release consolidates dev patches into clean minor version bump
|
||||
|
||||
|
||||
### Changed
|
||||
- Move security hardening methods (protectPlugin, ensureProtectedFlag, isOurExtension) from core plugin to firewall plugin (#155)
|
||||
- Admin menu module uses native Joomla MetisMenu CSS classes
|
||||
- Helpdesk icon changed to fa-handshake-angle, .htaccess to fa-solid fa-file-code
|
||||
- clearCache purges all cache files recursively (replaces Regular Labs Cache Cleaner behavior)
|
||||
- License key warning moved from every-page onAfterRoute to package postflight only
|
||||
- Update server URL changed to dynamic MokoGitea feed
|
||||
- Component manifest adds `<languages>` for global language dir deployment
|
||||
- Privacy and WAF Log added to component manifest submenu
|
||||
- MokoOnyx template removed from package manifest (separate repo/release)
|
||||
|
||||
|
||||
### Removed
|
||||
- Static updates.xml — MokoGitea generates update feed dynamically from releases
|
||||
- update-server.yml workflow — replaced by pre-release.yml
|
||||
|
||||
|
||||
### Fixed
|
||||
- Tickets list showing raw `<em>Unassigned</em>` HTML instead of italic text
|
||||
- Cache cleaner CSRF failure — token now sent as POST FormData
|
||||
- Admin menu icons missing for Helpdesk and .htaccess Maker
|
||||
- Firewall install error "Field 'element' doesn't have a default value" (MySQL strict mode)
|
||||
|
||||
|
||||
## [02.32] - 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
|
||||
@@ -43,7 +89,8 @@
|
||||
- License key validation (licensing system not ready — will return in future release)
|
||||
- Dynamic MokoGitea update feed dependency (replaced with static updates.xml)
|
||||
|
||||
## [02.31.00] - 2026-06-01
|
||||
## [02.31] - 2026-06-01
|
||||
|
||||
### Added
|
||||
- License key support via Joomla's native Update Sites download key system (dlid)
|
||||
- Update server URL migrated from static XML to MokoGitea's dynamic update feed endpoint
|
||||
@@ -76,7 +123,8 @@
|
||||
- Site Aliases config tab (hardcoded to dev.{primary_domain})
|
||||
- File sync (images/, files/, media/) — sync is API/DB content only
|
||||
|
||||
## [02.29.03] - 2026-05-31
|
||||
## [02.29] - 2026-05-31
|
||||
|
||||
### Added
|
||||
- `allow_extension_updates` param — separate update rights from installer restrictions; tenants can update extensions by default even when the installer is restricted
|
||||
- Hardcoded master usernames — multiple privileged users supported with identical access
|
||||
@@ -90,7 +138,6 @@
|
||||
|
||||
- Demo Mode with configurable warning banner on frontend when enabled
|
||||
|
||||
### Fixed
|
||||
- Demo banner countdown now shows weeks/days/months for longer intervals instead of raw hours
|
||||
- `DemoResetService` — baseline snapshot and restore for DB tables + media files
|
||||
- API endpoints `POST /?mokowaas=reset` and `POST /?mokowaas=snapshot` (query-string)
|
||||
@@ -105,16 +152,4 @@
|
||||
- Package installer: clean up legacy `mokowaasbrand` extension entries and files on install/update
|
||||
- API endpoint `GET /?mokowaas=extensions` and `GET /api/v1/mokowaas/extensions` — list installed extensions with version, status, and update server info
|
||||
|
||||
## [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).
|
||||
## [02.20] --- 2026-05-28
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.38
|
||||
VERSION: 02.34.08
|
||||
PATH: ./CODE_OF_CONDUCT.md
|
||||
BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default
|
||||
-->
|
||||
|
||||
@@ -127,6 +127,30 @@ The version tools update all files containing version stamps:
|
||||
|
||||
Files synced from other repos (with a `# REPO:` header) are not touched.
|
||||
|
||||
## Changelog
|
||||
|
||||
We use [Keep a Changelog](https://keepachangelog.com/) with an `[Unreleased]` staging section.
|
||||
|
||||
### Rules
|
||||
|
||||
- All changes go under `## [Unreleased]` — this is the "current work" section
|
||||
- Entries stay under `[Unreleased]` until a **stable release** merges to `main`
|
||||
- On stable release, `[Unreleased]` entries are promoted to a version heading (e.g., `## [02.34] - 2026-06-10`)
|
||||
- Only **minor versions** get changelog headings — patch numbers from dev are never shown
|
||||
- Dev/alpha/beta/RC pre-release descriptions pull from `[Unreleased]` automatically
|
||||
- **CI will block PRs to main** if `[Unreleased]` has no entries
|
||||
|
||||
### Categories
|
||||
|
||||
Use these headings under each version:
|
||||
|
||||
- `### Added` — new features
|
||||
- `### Changed` — changes to existing functionality
|
||||
- `### Deprecated` — features that will be removed
|
||||
- `### Removed` — features that were removed
|
||||
- `### Fixed` — bug fixes
|
||||
- `### Security` — vulnerability fixes
|
||||
|
||||
## Code Standards
|
||||
|
||||
- **PHP**: PSR-12, tabs for indentation
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@
|
||||
DEFGROUP: mokoconsulting-tech.MokoWaaSBrand
|
||||
INGROUP: MokoStandards.Governance
|
||||
REPO: https://github.com/mokoconsulting-tech/MokoWaaSBrand
|
||||
VERSION: 02.32.38
|
||||
VERSION: 02.34.08
|
||||
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.32.38
|
||||
VERSION: 02.34.08
|
||||
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.32.38
|
||||
VERSION: 02.34.08
|
||||
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.32.38
|
||||
VERSION: 02.34.08
|
||||
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.32.38
|
||||
VERSION: 02.34.08
|
||||
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.32.38)
|
||||
# MokoWaaS Build Guide (VERSION: 02.34.08)
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.38
|
||||
VERSION: 02.34.08
|
||||
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.32.38)
|
||||
# MokoWaaS Configuration Guide (VERSION: 02.34.08)
|
||||
|
||||
## 1. Objective
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.38
|
||||
VERSION: 02.34.08
|
||||
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.32.38)
|
||||
# MokoWaaS Installation Guide (VERSION: 02.34.08)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.38
|
||||
VERSION: 02.34.08
|
||||
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.32.38)
|
||||
# MokoWaaS Operations Guide (VERSION: 02.34.08)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.38
|
||||
VERSION: 02.34.08
|
||||
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.32.38)
|
||||
# MokoWaaS Rollback and Recovery Guide (VERSION: 02.34.08)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.38
|
||||
VERSION: 02.34.08
|
||||
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.32.38)
|
||||
# MokoWaaS Testing Guide (VERSION: 02.34.08)
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.38
|
||||
VERSION: 02.34.08
|
||||
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.32.38)
|
||||
# MokoWaaS Troubleshooting Guide (VERSION: 02.34.08)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.38
|
||||
VERSION: 02.34.08
|
||||
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.32.38)
|
||||
# MokoWaaS Upgrade and Versioning Guide (VERSION: 02.34.08)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
+2
-2
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.38
|
||||
VERSION: 02.34.08
|
||||
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.32.38)
|
||||
# MokoWaaS Documentation Index (VERSION: 02.34.08)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
INGROUP: MokoWaaS
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
PATH: /docs/plugin-basic.md
|
||||
VERSION: 02.32.38
|
||||
VERSION: 02.34.08
|
||||
BRIEF: Baseline documentation for the MokoWaaS system plugin
|
||||
NOTE: Foundational reference for internal and external stakeholders
|
||||
-->
|
||||
|
||||
# MokoWaaS Plugin Overview (VERSION: 02.32.38)
|
||||
# MokoWaaS Plugin Overview (VERSION: 02.34.08)
|
||||
|
||||
## 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.32.38
|
||||
VERSION: 02.34.08
|
||||
BRIEF: How this extension's Joomla update server file (update.xml) is managed
|
||||
-->
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -12,4 +12,8 @@ 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"
|
||||
|
||||
@@ -85,3 +85,51 @@ INSERT IGNORE INTO `#__mokowaas_ticket_categories` (`id`, `title`, `alias`, `des
|
||||
(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)');
|
||||
|
||||
@@ -29,6 +29,13 @@ class DisplayController extends BaseController
|
||||
'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 = [])
|
||||
@@ -58,6 +65,7 @@ class DisplayController extends BaseController
|
||||
if (!$this->checkAcl('mokowaas.plugins.toggle'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$app = Factory::getApplication();
|
||||
@@ -82,6 +90,7 @@ class DisplayController extends BaseController
|
||||
if (!$this->checkAcl('mokowaas.cache'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$this->jsonResponse($this->getModel('Dashboard')->clearCache());
|
||||
@@ -98,6 +107,7 @@ class DisplayController extends BaseController
|
||||
if (!$this->checkAcl('mokowaas.extensions'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$downloadUrl = Factory::getApplication()->getInput()->getString('download_url', '');
|
||||
@@ -105,6 +115,7 @@ class DisplayController extends BaseController
|
||||
if (empty($downloadUrl))
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Missing download URL.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->jsonResponse($this->getModel('Extensions')->installFromUrl($downloadUrl));
|
||||
@@ -121,6 +132,7 @@ class DisplayController extends BaseController
|
||||
if (!$this->checkAcl('mokowaas.htaccess'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$app = Factory::getApplication();
|
||||
@@ -152,6 +164,7 @@ class DisplayController extends BaseController
|
||||
if (!$this->checkAcl('mokowaas.htaccess'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$model = $this->getModel('Htaccess');
|
||||
@@ -179,6 +192,7 @@ class DisplayController extends BaseController
|
||||
if (!$this->checkAcl('mokowaas.tickets.create'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
@@ -198,6 +212,7 @@ class DisplayController extends BaseController
|
||||
if (!$this->checkAcl('mokowaas.tickets'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
@@ -216,6 +231,7 @@ class DisplayController extends BaseController
|
||||
if (!$this->checkAcl('mokowaas.tickets'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
@@ -268,6 +284,356 @@ class DisplayController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// 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();
|
||||
$action = $input->getString('action', 'deny');
|
||||
|
||||
if ($action === 'create')
|
||||
{
|
||||
$result = $model->createRequest(
|
||||
$input->getInt('user_id', 0),
|
||||
$input->getString('type', 'export')
|
||||
);
|
||||
$this->jsonResponse($result);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($action === 'approve' && !$input->getInt('request_id', 0) && $input->getInt('user_id', 0))
|
||||
{
|
||||
// Auto-process: create then immediately approve
|
||||
$result = $model->createRequest(
|
||||
$input->getInt('user_id', 0),
|
||||
$input->getString('type', 'export')
|
||||
);
|
||||
|
||||
if ($result['success'] && !empty($result['id']))
|
||||
{
|
||||
$result = $model->processRequest((int) $result['id'], 'approve');
|
||||
}
|
||||
|
||||
$this->jsonResponse($result);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->jsonResponse($model->processRequest(
|
||||
$input->getInt('request_id', 0),
|
||||
$action
|
||||
));
|
||||
}
|
||||
|
||||
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
|
||||
// ==================================================================
|
||||
@@ -279,6 +645,7 @@ class DisplayController extends BaseController
|
||||
if (!$this->checkAcl('mokowaas.tickets'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$this->jsonResponse($this->getModel('Import')->importAts());
|
||||
@@ -291,6 +658,7 @@ class DisplayController extends BaseController
|
||||
if (!$this->checkAcl('core.admin'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$this->jsonResponse($this->getModel('Import')->importAdminTools());
|
||||
@@ -333,5 +701,6 @@ class DisplayController extends BaseController
|
||||
private function jsonForbidden(): void
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN')]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,13 +267,18 @@ class DashboardModel extends BaseDatabaseModel
|
||||
{
|
||||
try
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
$app->get('cache_handler', 'file');
|
||||
|
||||
// Clear site and admin caches
|
||||
// Use Joomla's native cache API — same as com_cache
|
||||
$cache = Factory::getContainer()->get(\Joomla\CMS\Cache\CacheControllerFactoryInterface::class);
|
||||
Factory::getCache('', '')->gc();
|
||||
Factory::getCache('', '', 'administrator')->gc();
|
||||
$cache->createCacheController('', ['defaultgroup' => ''])->cache->clean('');
|
||||
|
||||
// Also clean admin cache
|
||||
$conf = Factory::getApplication()->get('cache_handler', 'file');
|
||||
$options = [
|
||||
'defaultgroup' => '',
|
||||
'cachebase' => JPATH_ADMINISTRATOR . '/cache',
|
||||
'storage' => $conf,
|
||||
];
|
||||
$cache->createCacheController('', $options)->cache->clean('');
|
||||
|
||||
// Clear opcache if available
|
||||
if (\function_exists('opcache_reset'))
|
||||
@@ -281,7 +286,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)
|
||||
{
|
||||
@@ -447,4 +452,84 @@ class DashboardModel extends BaseDatabaseModel
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WAF blocks per day for the last 14 days.
|
||||
*/
|
||||
public function getWafBlocksByDay(int $days = 14): array
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$db->setQuery(
|
||||
"SELECT DATE(" . $db->quoteName('created') . ") AS day, COUNT(*) AS total"
|
||||
. " FROM " . $db->quoteName('#__mokowaas_waf_log')
|
||||
. " WHERE " . $db->quoteName('created') . " >= DATE_SUB(NOW(), INTERVAL $days DAY)"
|
||||
. " GROUP BY day ORDER BY day"
|
||||
);
|
||||
$rows = $db->loadObjectList() ?: [];
|
||||
|
||||
// Fill in missing days with zero
|
||||
$result = [];
|
||||
$date = new \DateTime("-{$days} days");
|
||||
$now = new \DateTime('now');
|
||||
$map = [];
|
||||
foreach ($rows as $r)
|
||||
{
|
||||
$map[$r->day] = (int) $r->total;
|
||||
}
|
||||
while ($date <= $now)
|
||||
{
|
||||
$key = $date->format('Y-m-d');
|
||||
$result[] = (object) ['day' => $date->format('M d'), 'total' => $map[$key] ?? 0];
|
||||
$date->modify('+1 day');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin logins per day for the last 14 days.
|
||||
*/
|
||||
public function getLoginsByDay(int $days = 14): array
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$db->setQuery(
|
||||
"SELECT DATE(" . $db->quoteName('log_date') . ") AS day, COUNT(*) AS total"
|
||||
. " FROM " . $db->quoteName('#__action_logs')
|
||||
. " WHERE " . $db->quoteName('message_language_key') . " = 'PLG_ACTIONLOG_JOOMLA_USER_LOGGED_IN'"
|
||||
. " AND " . $db->quoteName('log_date') . " >= DATE_SUB(NOW(), INTERVAL $days DAY)"
|
||||
. " GROUP BY day ORDER BY day"
|
||||
);
|
||||
$rows = $db->loadObjectList() ?: [];
|
||||
|
||||
$result = [];
|
||||
$date = new \DateTime("-{$days} days");
|
||||
$now = new \DateTime('now');
|
||||
$map = [];
|
||||
foreach ($rows as $r)
|
||||
{
|
||||
$map[$r->day] = (int) $r->total;
|
||||
}
|
||||
while ($date <= $now)
|
||||
{
|
||||
$key = $date->format('Y-m-d');
|
||||
$result[] = (object) ['day' => $date->format('M d'), 'total' => $map[$key] ?? 0];
|
||||
$date->modify('+1 day');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ class ExtensionsModel extends BaseDatabaseModel
|
||||
'type' => 'package',
|
||||
'icon' => 'icon-shield-alt',
|
||||
'category' => 'Platform',
|
||||
'article' => 'https://mokoconsulting.tech/kb/mokowaas-platform',
|
||||
'article' => 'https://mokoconsulting.tech/support/products/mokowaas-platform',
|
||||
'protected' => true,
|
||||
],
|
||||
'MokoOnyx' => [
|
||||
@@ -43,7 +43,7 @@ class ExtensionsModel extends BaseDatabaseModel
|
||||
'type' => 'template',
|
||||
'icon' => 'icon-paint-brush',
|
||||
'category' => 'Templates',
|
||||
'article' => 'https://mokoconsulting.tech/kb/mokoonyx-template',
|
||||
'article' => 'https://mokoconsulting.tech/support/products/mokoonyx-template',
|
||||
'protected' => false,
|
||||
],
|
||||
'MokoJoomTOS' => [
|
||||
@@ -53,7 +53,7 @@ class ExtensionsModel extends BaseDatabaseModel
|
||||
'type' => 'component',
|
||||
'icon' => 'icon-file-contract',
|
||||
'category' => 'Components',
|
||||
'article' => 'https://mokoconsulting.tech/kb/mokojoomtos',
|
||||
'article' => 'https://mokoconsulting.tech/support/products/mokojoomtos',
|
||||
'protected' => false,
|
||||
],
|
||||
'MokoJoomHero' => [
|
||||
@@ -63,7 +63,7 @@ class ExtensionsModel extends BaseDatabaseModel
|
||||
'type' => 'module',
|
||||
'icon' => 'icon-image',
|
||||
'category' => 'Modules',
|
||||
'article' => 'https://mokoconsulting.tech/kb/mokojoomhero',
|
||||
'article' => 'https://mokoconsulting.tech/support/products/mokojoomhero',
|
||||
'protected' => false,
|
||||
],
|
||||
'MokoWaaSAnnounce' => [
|
||||
@@ -73,7 +73,7 @@ class ExtensionsModel extends BaseDatabaseModel
|
||||
'type' => 'module',
|
||||
'icon' => 'icon-bullhorn',
|
||||
'category' => 'Modules',
|
||||
'article' => 'https://mokoconsulting.tech/kb/mokowaas-announce',
|
||||
'article' => 'https://mokoconsulting.tech/support/products/mokowaas-announce',
|
||||
'protected' => false,
|
||||
],
|
||||
'MokoDPCalendarAPI' => [
|
||||
@@ -83,7 +83,7 @@ class ExtensionsModel extends BaseDatabaseModel
|
||||
'type' => 'plugin',
|
||||
'icon' => 'icon-calendar',
|
||||
'category' => 'Plugins',
|
||||
'article' => 'https://mokoconsulting.tech/kb/mokodpcalendarapi',
|
||||
'article' => 'https://mokoconsulting.tech/support/products/mokodpcalendarapi',
|
||||
'protected' => false,
|
||||
],
|
||||
'MokoGalleryCalendar' => [
|
||||
@@ -93,7 +93,7 @@ class ExtensionsModel extends BaseDatabaseModel
|
||||
'type' => 'plugin',
|
||||
'icon' => 'icon-images',
|
||||
'category' => 'Plugins',
|
||||
'article' => 'https://mokoconsulting.tech/kb/mokogallerycalendar',
|
||||
'article' => 'https://mokoconsulting.tech/support/products/mokogallerycalendar',
|
||||
'protected' => false,
|
||||
],
|
||||
'MokoJoomOpenGraph' => [
|
||||
@@ -103,7 +103,7 @@ class ExtensionsModel extends BaseDatabaseModel
|
||||
'type' => 'package',
|
||||
'icon' => 'icon-share-alt',
|
||||
'category' => 'Components',
|
||||
'article' => 'https://mokoconsulting.tech/kb/mokojoomopengraph',
|
||||
'article' => 'https://mokoconsulting.tech/support/products/mokojoomopengraph',
|
||||
'protected' => false,
|
||||
],
|
||||
];
|
||||
|
||||
@@ -28,9 +28,15 @@ 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
|
||||
@@ -138,6 +144,8 @@ class ImportModel extends BaseDatabaseModel
|
||||
$this->disableAdminTools($db);
|
||||
$results['disabled'] = true;
|
||||
|
||||
$this->markImported('admintools');
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => \sprintf(
|
||||
@@ -542,9 +550,15 @@ class ImportModel extends BaseDatabaseModel
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -612,6 +626,63 @@ class ImportModel extends BaseDatabaseModel
|
||||
$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;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ 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
|
||||
{
|
||||
@@ -173,8 +174,9 @@ class TicketsModel extends BaseDatabaseModel
|
||||
|
||||
$db->insertObject('#__mokowaas_tickets', $ticket, 'id');
|
||||
|
||||
// Run automation
|
||||
// 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];
|
||||
}
|
||||
@@ -205,19 +207,31 @@ class TicketsModel extends BaseDatabaseModel
|
||||
|
||||
$db->insertObject('#__mokowaas_ticket_replies', $reply, 'id');
|
||||
|
||||
// Mark SLA as responded if first staff reply
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__mokowaas_tickets'))
|
||||
->set($db->quoteName('modified') . ' = ' . $db->quote($now))
|
||||
->set($db->quoteName('sla_responded') . ' = 1')
|
||||
->where($db->quoteName('id') . ' = ' . $ticketId)
|
||||
->where($db->quoteName('sla_responded') . ' = 0')
|
||||
)->execute();
|
||||
// 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;
|
||||
|
||||
// Run automation
|
||||
$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)
|
||||
@@ -243,6 +257,15 @@ class TicketsModel extends BaseDatabaseModel
|
||||
$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),
|
||||
@@ -265,8 +288,9 @@ class TicketsModel extends BaseDatabaseModel
|
||||
->where($db->quoteName('id') . ' = ' . $ticketId)
|
||||
)->execute();
|
||||
|
||||
// Run automation
|
||||
// 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 . '.'];
|
||||
}
|
||||
@@ -569,10 +593,138 @@ class TicketsModel extends BaseDatabaseModel
|
||||
];
|
||||
$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.
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ class HtmlView extends BaseHtmlView
|
||||
protected $pendingUpdates = [];
|
||||
protected $checkedOutItems = [];
|
||||
protected $wafBlocks = [];
|
||||
protected $wafChartData = [];
|
||||
protected $loginChartData = [];
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
@@ -34,6 +36,8 @@ class HtmlView extends BaseHtmlView
|
||||
$this->pendingUpdates = $model->getPendingUpdates();
|
||||
$this->checkedOutItems = $model->getCheckedOutItems();
|
||||
$this->wafBlocks = $model->getRecentWafBlocks(5);
|
||||
$this->wafChartData = $model->getWafBlocksByDay(14);
|
||||
$this->loginChartData = $model->getLoginsByDay(14);
|
||||
|
||||
// Check for importable Akeeba data
|
||||
try
|
||||
|
||||
@@ -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,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,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>
|
||||
@@ -65,6 +65,10 @@ $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>
|
||||
|
||||
@@ -132,12 +136,12 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-xl-3">
|
||||
<?php
|
||||
// Use Community Builder if available, otherwise Joomla user manager
|
||||
// Use MokoJoomCommunity 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';
|
||||
$userLabel = $useCB ? 'MokoJoomCommunity' : '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>
|
||||
@@ -180,7 +184,7 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
|
||||
<span class="badge bg-light text-dark"><?php echo $this->escape($plugin->version); ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<p class="card-text text-muted small flex-grow-1"><?php echo $this->escape($plugin->description); ?></p>
|
||||
<p class="card-text text-muted text-muted flex-grow-1"><?php echo $this->escape($plugin->description); ?></p>
|
||||
<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>
|
||||
@@ -196,7 +200,7 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.togglePlugin&format=json'); ?>"
|
||||
data-token="<?php echo $token; ?>"
|
||||
<?php echo $plugin->enabled ? 'checked' : ''; ?>>
|
||||
<label class="form-check-label small" for="toggle-<?php echo $plugin->extension_id; ?>">
|
||||
<label class="form-check-label" for="toggle-<?php echo $plugin->extension_id; ?>">
|
||||
<?php echo $plugin->enabled ? Text::_('COM_MOKOWAAS_ENABLED') : Text::_('COM_MOKOWAAS_DISABLED'); ?>
|
||||
</label>
|
||||
</div>
|
||||
@@ -215,8 +219,28 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<!-- Right: Information Tables (4 cols) -->
|
||||
<div class="col-12 col-xl-4">
|
||||
<!-- Right: Charts & Information (4 cols) -->
|
||||
<div class="col-12 col-xl-4" style="border-left:1px solid var(--gray-300, #dee2e6);padding-left:1.5rem;">
|
||||
|
||||
<!-- WAF Activity Chart -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<strong><span class="icon-shield-alt" aria-hidden="true"></span> WAF Activity (14 days)</strong>
|
||||
</div>
|
||||
<div class="card-body py-2">
|
||||
<canvas id="mokowaas-chart-waf" height="140"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Activity Chart -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<strong><span class="icon-user" aria-hidden="true"></span> Login Activity (14 days)</strong>
|
||||
</div>
|
||||
<div class="card-body py-2">
|
||||
<canvas id="mokowaas-chart-logins" height="140"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pending Updates -->
|
||||
<div class="card mb-3">
|
||||
@@ -231,16 +255,16 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
|
||||
<tbody>
|
||||
<?php foreach ($pendingUpdates as $upd): ?>
|
||||
<tr>
|
||||
<td class="small"><?php echo $this->escape($upd->name); ?></td>
|
||||
<td class="small text-muted"><?php echo $this->escape($upd->current_version); ?></td>
|
||||
<td class="small text-success"><?php echo $this->escape($upd->version); ?></td>
|
||||
<td class="text-muted"><?php echo $this->escape($upd->name); ?></td>
|
||||
<td class="text-muted"><?php echo $this->escape($upd->current_version); ?></td>
|
||||
<td class="text-success fw-bold"><?php echo $this->escape($upd->version); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="card-body text-center text-muted small py-3">
|
||||
<div class="card-body text-center text-muted py-3">
|
||||
<span class="icon-check-circle text-success"></span> All extensions up to date
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
@@ -259,19 +283,19 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
|
||||
<tbody>
|
||||
<?php foreach ($checkedOut as $item): ?>
|
||||
<tr>
|
||||
<td class="small"><?php echo $this->escape(mb_substr($item->title, 0, 30)); ?></td>
|
||||
<td class="small"><?php echo $this->escape($item->username ?? ''); ?></td>
|
||||
<td class="small text-muted"><?php echo HTMLHelper::_('date', $item->checked_out_time, 'M d H:i'); ?></td>
|
||||
<td class="text-muted"><?php echo $this->escape(mb_substr($item->title, 0, 30)); ?></td>
|
||||
<td class="text-muted"><?php echo $this->escape($item->username ?? ''); ?></td>
|
||||
<td class="text-muted"><?php echo HTMLHelper::_('date', $item->checked_out_time, 'M d H:i'); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer text-center py-1">
|
||||
<a href="<?php echo Route::_('index.php?option=com_checkin'); ?>" class="small">Global Check-in</a>
|
||||
<a href="<?php echo Route::_('index.php?option=com_checkin'); ?>" class="text-muted">Global Check-in</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="card-body text-center text-muted small py-3">
|
||||
<div class="card-body text-center text-muted py-3">
|
||||
<span class="icon-check-circle text-success"></span> No checked out items
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
@@ -290,16 +314,16 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
|
||||
<tbody>
|
||||
<?php foreach ($wafBlocks as $block): ?>
|
||||
<tr>
|
||||
<td class="small"><code><?php echo $this->escape($block->ip); ?></code></td>
|
||||
<td class="small"><span class="badge bg-danger"><?php echo $this->escape($block->rule); ?></span></td>
|
||||
<td class="small text-muted"><?php echo HTMLHelper::_('date', $block->created, 'M d H:i'); ?></td>
|
||||
<td class="text-muted"><code><?php echo $this->escape($block->ip); ?></code></td>
|
||||
<td class="text-muted"><span class="badge bg-danger"><?php echo $this->escape($block->rule); ?></span></td>
|
||||
<td class="text-muted"><?php echo HTMLHelper::_('date', $block->created, 'M d H:i'); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="card-body text-center text-muted small py-3">
|
||||
<div class="card-body text-center text-muted py-3">
|
||||
<span class="icon-check-circle text-success"></span> No recent blocks
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
@@ -317,19 +341,85 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
|
||||
<tbody>
|
||||
<?php foreach ($recentLogins as $login): ?>
|
||||
<tr>
|
||||
<td class="small"><?php echo $this->escape($login->username ?? ''); ?></td>
|
||||
<td class="small"><code><?php echo $this->escape($login->ip_address ?? ''); ?></code></td>
|
||||
<td class="small text-muted"><?php echo HTMLHelper::_('date', $login->log_date, 'M d H:i'); ?></td>
|
||||
<td class="text-muted"><?php echo $this->escape($login->username ?? ''); ?></td>
|
||||
<td class="text-muted"><code><?php echo $this->escape($login->ip_address ?? ''); ?></code></td>
|
||||
<td class="text-muted"><?php echo HTMLHelper::_('date', $login->log_date, 'M d H:i'); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="card-body text-center text-muted small py-3">No login activity recorded</div>
|
||||
<div class="card-body text-center text-muted py-3">No login activity recorded</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
</div><!-- /.col-xl-4 -->
|
||||
</div><!-- /.row -->
|
||||
</div>
|
||||
|
||||
<?php
|
||||
// Prepare chart data as JSON for JavaScript
|
||||
$wafChartData = $this->wafChartData ?? [];
|
||||
$loginChartData = $this->loginChartData ?? [];
|
||||
|
||||
$wafLabels = array_map(fn($d) => $d->day, $wafChartData);
|
||||
$wafValues = array_map(fn($d) => $d->total, $wafChartData);
|
||||
$loginLabels = array_map(fn($d) => $d->day, $loginChartData);
|
||||
$loginValues = array_map(fn($d) => $d->total, $loginChartData);
|
||||
?>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js" integrity="sha384-jb8JQMbMoBUzgWatfe6COACi2ljcDdZQ2OxczGA3bGNeWe+6DChMTBJemed7ZnvJ" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var chartDefaults = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { grid: { display: false }, ticks: { maxRotation: 45, font: { size: 10 } } },
|
||||
y: { beginAtZero: true, ticks: { stepSize: 1, font: { size: 10 } } }
|
||||
}
|
||||
};
|
||||
|
||||
// WAF chart
|
||||
var wafCtx = document.getElementById('mokowaas-chart-waf');
|
||||
if (wafCtx) {
|
||||
new Chart(wafCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: <?php echo json_encode($wafLabels); ?>,
|
||||
datasets: [{
|
||||
data: <?php echo json_encode($wafValues); ?>,
|
||||
backgroundColor: 'rgba(197, 40, 39, 0.6)',
|
||||
borderColor: '#c52827',
|
||||
borderWidth: 1,
|
||||
borderRadius: 3
|
||||
}]
|
||||
},
|
||||
options: chartDefaults
|
||||
});
|
||||
}
|
||||
|
||||
// Login chart
|
||||
var loginCtx = document.getElementById('mokowaas-chart-logins');
|
||||
if (loginCtx) {
|
||||
new Chart(loginCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: <?php echo json_encode($loginLabels); ?>,
|
||||
datasets: [{
|
||||
data: <?php echo json_encode($loginValues); ?>,
|
||||
borderColor: '#2a69b8',
|
||||
backgroundColor: 'rgba(42, 105, 184, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
pointBackgroundColor: '#2a69b8'
|
||||
}]
|
||||
},
|
||||
options: chartDefaults
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
@@ -83,6 +83,26 @@ $statusBadge = [
|
||||
Install
|
||||
</button>
|
||||
<?php elseif ($pkg->status === 'installed'): ?>
|
||||
<?php
|
||||
$dashLink = '';
|
||||
if ($pkg->type === 'component')
|
||||
{
|
||||
$dashLink = 'index.php?option=' . $pkg->element;
|
||||
}
|
||||
elseif ($pkg->type === 'package' && strpos($pkg->element, 'pkg_') === 0)
|
||||
{
|
||||
$comElement = 'com_' . substr($pkg->element, 4);
|
||||
if (is_dir(JPATH_ADMINISTRATOR . '/components/' . $comElement))
|
||||
{
|
||||
$dashLink = 'index.php?option=' . $comElement;
|
||||
}
|
||||
}
|
||||
?>
|
||||
<?php if ($dashLink): ?>
|
||||
<a href="<?php echo Route::_($dashLink); ?>" class="btn btn-sm btn-outline-primary" title="Open">
|
||||
<span class="icon-arrow-right" aria-hidden="true"></span> Open
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<span class="btn btn-sm btn-outline-success disabled">
|
||||
<span class="icon-check" aria-hidden="true"></span> Installed
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
<?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>
|
||||
|
||||
<!-- New Request Form -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong><span class="icon-plus"></span> Create Data Request</strong>
|
||||
<button class="btn btn-sm btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#newRequestForm" aria-expanded="false">
|
||||
<span class="icon-plus"></span> New Request
|
||||
</button>
|
||||
</div>
|
||||
<div class="collapse" id="newRequestForm">
|
||||
<div class="card-body">
|
||||
<form id="formNewRequest" class="row g-3">
|
||||
<div class="col-12 col-md-5">
|
||||
<label for="req_user_id" class="form-label">User</label>
|
||||
<select id="req_user_id" class="form-select" required>
|
||||
<option value="">Select a user...</option>
|
||||
<?php
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select([$db->quoteName('id'), $db->quoteName('name'), $db->quoteName('email')])
|
||||
->from($db->quoteName('#__users'))
|
||||
->where($db->quoteName('block') . ' = 0')
|
||||
->order($db->quoteName('name'))
|
||||
);
|
||||
foreach ($db->loadObjectList() as $u):
|
||||
?>
|
||||
<option value="<?php echo (int) $u->id; ?>"><?php echo $this->escape($u->name); ?> (<?php echo $this->escape($u->email); ?>)</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<label for="req_type" class="form-label">Request Type</label>
|
||||
<select id="req_type" class="form-select" required>
|
||||
<option value="export">Export Data</option>
|
||||
<option value="delete">Delete Data</option>
|
||||
<option value="anonymize">Anonymize Data</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-2">
|
||||
<label for="req_auto" class="form-label">Auto-process</label>
|
||||
<select id="req_auto" class="form-select">
|
||||
<option value="0">No (pending)</option>
|
||||
<option value="1">Yes (immediate)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-2 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-primary w-100" id="btnCreateRequest"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.processDataRequest&format=json'); ?>"
|
||||
data-token="<?php echo $token; ?>">
|
||||
<span class="icon-check"></span> Submit
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</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; }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Create new request
|
||||
var form = document.getElementById('formNewRequest');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
var btn = document.getElementById('btnCreateRequest');
|
||||
var userId = document.getElementById('req_user_id').value;
|
||||
var type = document.getElementById('req_type').value;
|
||||
var auto = document.getElementById('req_auto').value;
|
||||
if (!userId) { Joomla.renderMessages({warning:['Please select a user.']}); return; }
|
||||
btn.disabled = true;
|
||||
var fd = new FormData();
|
||||
fd.append('user_id', userId);
|
||||
fd.append('type', type);
|
||||
fd.append('action', auto === '1' ? 'approve' : 'create');
|
||||
fd.append(btn.dataset.token, '1');
|
||||
fetch(btn.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){
|
||||
if (d.success) { Joomla.renderMessages({message:[d.message || 'Request created.']}); location.reload(); }
|
||||
else { Joomla.renderMessages({error:[d.message || 'Failed.']}); btn.disabled = false; }
|
||||
})
|
||||
.catch(function(){ btn.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>
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
@@ -112,7 +113,7 @@ $priorityBadge = [
|
||||
<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 $this->escape($t->assigned_to_name ?? '<em>Unassigned</em>'); ?></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): ?>
|
||||
|
||||
@@ -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>
|
||||
@@ -8,7 +8,7 @@
|
||||
DEFGROUP: Joomla.Component
|
||||
INGROUP: MokoWaaS
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
|
||||
VERSION: 02.32.04
|
||||
VERSION: 02.34.00
|
||||
PATH: /mokowaas.xml
|
||||
BRIEF: Component manifest for MokoWaaS admin dashboard and REST API
|
||||
-->
|
||||
@@ -20,7 +20,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.32.38</version>
|
||||
<version>02.34.08-dev</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>
|
||||
@@ -32,6 +32,10 @@
|
||||
<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>
|
||||
@@ -39,12 +43,16 @@
|
||||
</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">
|
||||
|
||||
@@ -50,6 +50,7 @@ class DisplayController extends BaseController
|
||||
if ($user->guest)
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Please log in.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
@@ -78,6 +79,7 @@ class DisplayController extends BaseController
|
||||
if ($user->guest)
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Please log in.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$ticketId = $input->getInt('ticket_id', 0);
|
||||
@@ -87,12 +89,14 @@ class DisplayController extends BaseController
|
||||
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
|
||||
@@ -115,6 +119,7 @@ class DisplayController extends BaseController
|
||||
if (!$this->isStaff($user))
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Access denied.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
@@ -138,6 +143,7 @@ class DisplayController extends BaseController
|
||||
if (!$user->authorise('mokowaas.tickets.assign', 'com_mokowaas'))
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Access denied.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
@@ -160,9 +166,31 @@ class DisplayController extends BaseController
|
||||
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).
|
||||
*/
|
||||
|
||||
@@ -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,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,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.34.08-dev</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.32.38</version>
|
||||
<version>02.34.08-dev</version>
|
||||
<description>MOD_MOKOWAAS_CPANEL_DESC</description>
|
||||
<namespace path="src">Moko\Module\MokoWaaSCpanel</namespace>
|
||||
|
||||
|
||||
@@ -24,6 +24,18 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI
|
||||
{
|
||||
$data = parent::getLayoutData();
|
||||
|
||||
// Hide on MokoWaaS dashboard — the dashboard has its own info panels
|
||||
$app = Factory::getApplication();
|
||||
$option = $app->getInput()->get('option', '');
|
||||
$view = $app->getInput()->get('view', '');
|
||||
|
||||
if ($option === 'com_mokowaas' && ($view === '' || $view === 'dashboard'))
|
||||
{
|
||||
$data['hidden'] = true;
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
$helper = $this->getHelperFactory()->getHelper('CpanelHelper');
|
||||
|
||||
@@ -33,6 +45,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
// Hidden when on MokoWaaS dashboard (redundant info)
|
||||
if (!empty($hidden)) return;
|
||||
|
||||
|
||||
$siteInfo = $siteInfo ?? (object) [];
|
||||
$plugins = $plugins ?? [];
|
||||
@@ -55,25 +58,47 @@ $diskColor = ($diskPct !== null && $diskPct > 90) ? 'bg-danger' : (($diskPct !==
|
||||
?>
|
||||
|
||||
<div class="mod-mokowaas-cpanel card p-3 mb-4">
|
||||
<!-- Header row (always visible, acts as collapse toggle) -->
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<a class="d-flex align-items-center gap-2 text-decoration-none text-reset" data-bs-toggle="collapse" href="#mokowaas-cpanel-body" role="button" aria-expanded="<?php echo $collapsed ? 'false' : 'true'; ?>" aria-controls="mokowaas-cpanel-body">
|
||||
<span class="icon-shield-alt" aria-hidden="true" style="font-size:1.25rem;color:#1a2744"></span>
|
||||
<strong>MokoWaaS</strong>
|
||||
<span class="badge bg-primary"><?php echo htmlspecialchars($siteInfo->mokowaas_version ?? ''); ?></span>
|
||||
<?php if (!empty($siteInfo->debug)): ?>
|
||||
<span class="badge bg-warning text-dark">Debug</span>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($siteInfo->offline)): ?>
|
||||
<span class="badge bg-danger">Offline</span>
|
||||
<?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">
|
||||
<span class="icon-cogs" aria-hidden="true"></span>
|
||||
<?php echo Text::_('MOD_MOKOWAAS_CPANEL_OPEN_DASHBOARD'); ?>
|
||||
</a>
|
||||
<!-- Header row -->
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button type="button" class="btn btn-sm btn-link p-0 text-muted" data-bs-toggle="collapse" data-bs-target="#mokowaas-cpanel-body" aria-expanded="<?php echo $collapsed ? 'false' : 'true'; ?>" aria-controls="mokowaas-cpanel-body" id="mokowaas-cpanel-toggle" style="font-size:1rem;line-height:1;width:1.5rem;">
|
||||
<span class="fa-solid fa-caret-<?php echo $collapsed ? 'right' : 'down'; ?>" aria-hidden="true" id="mokowaas-cpanel-caret"></span>
|
||||
</button>
|
||||
<span class="icon-shield-alt" aria-hidden="true" style="font-size:1.25rem;color:#1a2744"></span>
|
||||
<strong>MokoWaaS</strong>
|
||||
<span class="badge bg-primary"><?php echo htmlspecialchars($siteInfo->mokowaas_version ?? ''); ?></span>
|
||||
<?php if (!empty($siteInfo->debug)): ?>
|
||||
<span class="badge bg-warning text-dark">Debug</span>
|
||||
<?php endif; ?>
|
||||
<?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="ms-auto">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokowaas'); ?>" class="btn btn-sm btn-primary">
|
||||
<span class="icon-cogs" aria-hidden="true"></span>
|
||||
<?php echo Text::_('MOD_MOKOWAAS_CPANEL_OPEN_DASHBOARD'); ?>
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var target = document.getElementById('mokowaas-cpanel-body');
|
||||
var caret = document.getElementById('mokowaas-cpanel-caret');
|
||||
if (target && caret) {
|
||||
target.addEventListener('show.bs.collapse', function() { caret.className = 'fa-solid fa-caret-down'; });
|
||||
target.addEventListener('hide.bs.collapse', function() { caret.className = 'fa-solid fa-caret-right'; });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Collapsible body -->
|
||||
<div class="collapse<?php echo $collapsed ? '' : ' show'; ?> mt-3" id="mokowaas-cpanel-body">
|
||||
@@ -130,6 +155,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.34.08-dev</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,191 @@
|
||||
<?php
|
||||
/**
|
||||
* MokoWaaS Admin Sidebar Menu
|
||||
*
|
||||
* Renders MokoWaaS static views first, then auto-discovers installed
|
||||
* Moko components from #__menu and renders their submenu items as
|
||||
* nested MetisMenu collapsible sections.
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
$app = Factory::getApplication();
|
||||
$currentOption = $app->getInput()->get('option', '');
|
||||
$currentView = $app->getInput()->get('view', '');
|
||||
|
||||
// ── Static MokoWaaS views ────────────────────────────────────────────
|
||||
$mokowaasItems = [
|
||||
['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'],
|
||||
];
|
||||
|
||||
// ── Auto-discover Moko component menus from #__menu ──────────────────
|
||||
$mokoComponents = [];
|
||||
|
||||
try
|
||||
{
|
||||
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
||||
|
||||
// Find all Moko component menu items (exclude com_mokowaas — handled above)
|
||||
$db->setQuery(
|
||||
"SELECT m.id, m.title, m.link, m.level, m.parent_id, m.img, e.element"
|
||||
. " FROM " . $db->quoteName('#__menu') . " m"
|
||||
. " LEFT JOIN " . $db->quoteName('#__extensions') . " e ON m.component_id = e.extension_id"
|
||||
. " WHERE m.client_id = 1 AND m.level >= 1 AND m.published = 1"
|
||||
. " AND e.element LIKE 'com_moko%'"
|
||||
. " AND e.element != 'com_mokowaas'"
|
||||
. " AND e.enabled = 1"
|
||||
. " ORDER BY e.element, m.level, m.lft"
|
||||
);
|
||||
$menuItems = $db->loadObjectList() ?: [];
|
||||
|
||||
// Load sys.ini language files for discovered components
|
||||
$lang = Factory::getLanguage();
|
||||
$loadedLangs = [];
|
||||
foreach ($menuItems as $m)
|
||||
{
|
||||
if (!isset($loadedLangs[$m->element]))
|
||||
{
|
||||
$lang->load($m->element . '.sys', JPATH_ADMINISTRATOR);
|
||||
$lang->load($m->element, JPATH_ADMINISTRATOR);
|
||||
$loadedLangs[$m->element] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Group: level 1 = component parent, level 2 = children
|
||||
foreach ($menuItems as $m)
|
||||
{
|
||||
if ((int) $m->level === 1)
|
||||
{
|
||||
$mokoComponents[$m->element] = [
|
||||
'id' => $m->id,
|
||||
'title' => Text::_($m->title),
|
||||
'link' => $m->link,
|
||||
'icon' => str_replace('class:', 'icon-', $m->img ?: 'class:puzzle-piece'),
|
||||
'element' => $m->element,
|
||||
'children' => [],
|
||||
];
|
||||
}
|
||||
elseif ((int) $m->level === 2 && isset($mokoComponents[$m->element]))
|
||||
{
|
||||
$mokoComponents[$m->element]['children'][] = [
|
||||
'title' => Text::_($m->title),
|
||||
'link' => $m->link,
|
||||
'icon' => str_replace('class:', 'icon-', $m->img ?: 'class:cog'),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
// Silent — menu works without auto-discovered components
|
||||
}
|
||||
|
||||
// ── Determine active state ───────────────────────────────────────────
|
||||
$mokowaasActive = ($currentOption === 'com_mokowaas');
|
||||
$anyMokoActive = $mokowaasActive;
|
||||
|
||||
foreach ($mokoComponents as $comp)
|
||||
{
|
||||
$parsed = [];
|
||||
parse_str(parse_url($comp['link'], PHP_URL_QUERY) ?? '', $parsed);
|
||||
if (($parsed['option'] ?? '') === $currentOption)
|
||||
{
|
||||
$anyMokoActive = true;
|
||||
}
|
||||
}
|
||||
|
||||
$topClass = 'item parent item-level-1' . ($anyMokoActive ? ' mm-active' : '');
|
||||
$topCollapse = 'collapse-level-1 mm-collapse' . ($anyMokoActive ? ' mm-show' : '');
|
||||
?>
|
||||
|
||||
<style>
|
||||
.sidebar-wrapper .item-level-1 > a { padding-inline-start: 1.5rem; }
|
||||
.sidebar-wrapper .mokowaas-menu-item > a { padding-inline-start: 1rem; }
|
||||
.sidebar-wrapper .mokowaas-menu-child > a { padding-inline-start: 1.5rem; }
|
||||
</style>
|
||||
|
||||
<ul class="nav flex-column main-nav">
|
||||
<li class="<?php echo $topClass; ?>">
|
||||
<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 $topCollapse; ?>" style="padding-inline-start:0.5rem;">
|
||||
|
||||
<?php // ── MokoWaaS static items ── ?>
|
||||
<?php foreach ($mokowaasItems 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 mokowaas-menu-item' . ($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; ?>
|
||||
|
||||
<?php // ── Auto-discovered Moko components with submenus ── ?>
|
||||
<?php foreach ($mokoComponents as $comp): ?>
|
||||
<?php
|
||||
$compParsed = [];
|
||||
parse_str(parse_url($comp['link'], PHP_URL_QUERY) ?? '', $compParsed);
|
||||
$compActive = ($compParsed['option'] ?? '') === $currentOption;
|
||||
$hasChildren = !empty($comp['children']);
|
||||
$compLiClass = 'item mokowaas-menu-item' . ($hasChildren ? ' parent' : '') . ($compActive ? ' mm-active' : '');
|
||||
$compAClass = ($hasChildren ? 'has-arrow' : 'no-dropdown') . ($compActive ? ' mm-active' : '');
|
||||
$childCollapse = 'collapse-level-2 mm-collapse' . ($compActive ? ' mm-show' : '');
|
||||
?>
|
||||
<li class="<?php echo $compLiClass; ?>">
|
||||
<a class="<?php echo $compAClass; ?>" href="<?php echo $hasChildren ? '#' : Route::_($comp['link']); ?>"<?php echo ($compActive && !$hasChildren) ? ' aria-current="page"' : ''; ?>>
|
||||
<span class="<?php echo $comp['icon']; ?>" aria-hidden="true" style="display:inline-block!important;width:1.25em;text-align:center;margin-inline-end:0.4em;"></span>
|
||||
<span class="sidebar-item-title"><?php echo $comp['title']; ?></span>
|
||||
</a>
|
||||
<?php if ($hasChildren): ?>
|
||||
<ul class="<?php echo $childCollapse; ?>" style="padding-inline-start:0.75rem;">
|
||||
<?php foreach ($comp['children'] as $child): ?>
|
||||
<?php
|
||||
$childParsed = [];
|
||||
parse_str(parse_url($child['link'], PHP_URL_QUERY) ?? '', $childParsed);
|
||||
$childActive = ($childParsed['option'] ?? '') === $currentOption
|
||||
&& ($childParsed['view'] ?? '') === $currentView;
|
||||
$childLiClass = 'item mokowaas-menu-child' . ($childActive ? ' mm-active' : '');
|
||||
$childAClass = 'no-dropdown' . ($childActive ? ' mm-active' : '');
|
||||
?>
|
||||
<li class="<?php echo $childLiClass; ?>">
|
||||
<a class="<?php echo $childAClass; ?>" href="<?php echo Route::_($child['link']); ?>"<?php echo $childActive ? ' aria-current="page"' : ''; ?>>
|
||||
<span class="<?php echo $child['icon']; ?>" aria-hidden="true" style="display:inline-block!important;width:1.25em;text-align:center;margin-inline-end:0.4em;"></span>
|
||||
<span class="sidebar-item-title"><?php echo $child['title']; ?></span>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -22,9 +22,9 @@
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoWaaS
|
||||
* REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
* VERSION: 02.32.38
|
||||
* VERSION: 02.34.08
|
||||
* PATH: /src/Extension/MokoWaaS.php
|
||||
* NOTE: Handles Joomla system events for rebranding functionality
|
||||
* NOTE: Core system plugin for MokoWaaS admin tools suite
|
||||
*/
|
||||
|
||||
namespace Moko\Plugin\System\MokoWaaS\Extension;
|
||||
@@ -42,10 +42,9 @@ use Joomla\CMS\User\UserHelper;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
/**
|
||||
* MokoWaaS Brand System Plugin
|
||||
* MokoWaaS Core System Plugin
|
||||
*
|
||||
* This plugin rebrands the Joomla system interface with MokoWaaS identity.
|
||||
* It applies language overrides and ensures consistent branding across the platform.
|
||||
* This plugin provides core coordination for the MokoWaaS admin tools suite.
|
||||
*
|
||||
* @since 01.04.00
|
||||
*/
|
||||
@@ -187,18 +186,14 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
$this->handleMokoApi($mokoAction);
|
||||
}
|
||||
|
||||
// Admin-only core controls (branding, emergency access, master user)
|
||||
// Admin-only core controls (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->loadLanguageOverrides();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -698,102 +693,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
return in_array($clientIp, $allowedIps, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the placeholder → value map from plugin params.
|
||||
*
|
||||
* @return array Associative array of placeholder => replacement value
|
||||
*
|
||||
* @since 02.01.08
|
||||
*/
|
||||
protected function getPlaceholders()
|
||||
{
|
||||
return [
|
||||
'{{BRAND_NAME}}' => self::BRAND_NAME,
|
||||
'{{COMPANY_NAME}}' => self::COMPANY_NAME,
|
||||
'{{SUPPORT_URL}}' => self::SUPPORT_URL,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load language override templates and inject resolved strings into Joomla.
|
||||
*
|
||||
* Reads the override template shipped with the plugin, replaces
|
||||
* {{BRAND_NAME}}, {{COMPANY_NAME}} and {{SUPPORT_URL}} with the
|
||||
* values from plugin params, then injects the resolved strings into
|
||||
* the active Language object.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.01.08
|
||||
*/
|
||||
protected function loadLanguageOverrides()
|
||||
{
|
||||
$language = $this->app->getLanguage();
|
||||
$tag = $language->getTag();
|
||||
$pluginPath = JPATH_PLUGINS . '/system/mokowaas';
|
||||
$isAdmin = $this->app->isClient('administrator');
|
||||
|
||||
$overridePath = $isAdmin
|
||||
? $pluginPath . '/administrator/language/overrides/' . $tag . '.override.ini'
|
||||
: $pluginPath . '/language/overrides/' . $tag . '.override.ini';
|
||||
|
||||
if (!file_exists($overridePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$strings = $this->parseLanguageFile($overridePath);
|
||||
$placeholders = $this->getPlaceholders();
|
||||
|
||||
foreach ($strings as $key => $value)
|
||||
{
|
||||
$language->_strings[$key] = str_replace(
|
||||
array_keys($placeholders),
|
||||
array_values($placeholders),
|
||||
$value
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a language INI file and return the raw strings (with placeholders).
|
||||
*
|
||||
* @param string $filePath The path to the language file
|
||||
*
|
||||
* @return array Array of language strings (key => raw value)
|
||||
*
|
||||
* @since 02.01.08
|
||||
*/
|
||||
protected function parseLanguageFile($filePath)
|
||||
{
|
||||
$strings = [];
|
||||
|
||||
if (!file_exists($filePath))
|
||||
{
|
||||
return $strings;
|
||||
}
|
||||
|
||||
$content = file_get_contents($filePath);
|
||||
$lines = explode("\n", $content);
|
||||
|
||||
foreach ($lines as $line)
|
||||
{
|
||||
$line = trim($line);
|
||||
|
||||
if ($line === '' || $line[0] === ';')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^([A-Z0-9_]+)="(.+)"$/i', $line, $matches))
|
||||
{
|
||||
$strings[strtoupper($matches[1])] = $matches[2];
|
||||
}
|
||||
}
|
||||
|
||||
return $strings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event triggered after an extension's config is saved.
|
||||
*
|
||||
@@ -931,16 +830,66 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
*
|
||||
* @since 02.01.08
|
||||
*/
|
||||
public function onAfterRoute()
|
||||
// ------------------------------------------------------------------
|
||||
// Automation event hooks (#151) — delegate to ticket automation engine
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
public function onUserLogin($user, $options = [])
|
||||
{
|
||||
if (!$this->app->isClient('administrator'))
|
||||
// Security alert for admin logins (#131)
|
||||
if ($this->app->isClient('administrator'))
|
||||
{
|
||||
return;
|
||||
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) {}
|
||||
}
|
||||
|
||||
// NOTE: warnMissingLicenseKey and enforceAdminRestrictions
|
||||
// are now handled by feature plugins (deferred / tenant)
|
||||
$this->protectPlugin();
|
||||
$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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -984,7 +933,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
return;
|
||||
}
|
||||
|
||||
$this->injectFavicon($doc);
|
||||
$this->redirectHelpMenu($doc);
|
||||
|
||||
// Hide MokoWaaS from plugin list for non-master users
|
||||
@@ -1175,163 +1123,20 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
|
||||
$doc->addScriptDeclaration("
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var url = " . json_encode($supportUrl) . ";
|
||||
document.querySelectorAll('a[href*=\"help.joomla.org\"], a[href*=\"docs.joomla.org\"]').forEach(function(link) {
|
||||
link.href = " . json_encode($supportUrl) . ";
|
||||
link.href = url;
|
||||
link.target = '_blank';
|
||||
});
|
||||
document.querySelectorAll('a[href*=\"dashboard=help\"]').forEach(function(link) {
|
||||
link.href = url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
});
|
||||
});
|
||||
");
|
||||
}
|
||||
|
||||
/**
|
||||
* Protect the plugin from being disabled or uninstalled by non-master users.
|
||||
* Does NOT self-heal (no lock) — master users can still disable if needed.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.03.04
|
||||
*/
|
||||
protected function protectPlugin()
|
||||
{
|
||||
// Ensure protected flag is set (self-healing — runs once per session)
|
||||
static $flagChecked = false;
|
||||
|
||||
if (!$flagChecked)
|
||||
{
|
||||
$flagChecked = true;
|
||||
$this->ensureProtectedFlag();
|
||||
}
|
||||
|
||||
if ($this->isMasterUser())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$option = $this->app->input->get('option', '');
|
||||
$task = $this->app->input->get('task', '');
|
||||
|
||||
// Block non-master from uninstalling MokoWaaS
|
||||
if ($option === 'com_installer' && strpos($task, 'manage.remove') !== false)
|
||||
{
|
||||
$cid = $this->app->input->get('cid', [], 'array');
|
||||
|
||||
if ($this->isOurExtension($cid))
|
||||
{
|
||||
$this->app->enqueueMessage('MokoWaaS cannot be uninstalled.', 'error');
|
||||
$this->app->redirect('index.php?option=com_installer&view=manage');
|
||||
}
|
||||
}
|
||||
|
||||
// Block non-master from disabling via list toggle
|
||||
if ($option === 'com_plugins' && strpos($task, 'plugins.publish') !== false)
|
||||
{
|
||||
$cid = $this->app->input->get('cid', [], 'array');
|
||||
|
||||
if ($this->isOurExtension($cid))
|
||||
{
|
||||
$this->app->enqueueMessage('MokoWaaS cannot be disabled.', 'error');
|
||||
$this->app->redirect('index.php?option=com_plugins');
|
||||
}
|
||||
}
|
||||
|
||||
// Block non-master from viewing or editing MokoWaaS plugin settings
|
||||
if ($option === 'com_plugins')
|
||||
{
|
||||
$view = $this->app->input->get('view', '');
|
||||
$layout = $this->app->input->get('layout', '');
|
||||
$extensionId = (int) $this->app->input->get('extension_id', 0);
|
||||
|
||||
if (($view === 'plugin' || $layout === 'edit') && $extensionId > 0)
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('extension_id') . ' = ' . $extensionId)
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'));
|
||||
|
||||
if ((int) $db->setQuery($query)->loadResult() > 0)
|
||||
{
|
||||
$this->app->enqueueMessage('MokoWaaS settings are restricted to the master user.', 'warning');
|
||||
$this->app->redirect('index.php?option=com_plugins');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the protected flag is set on MokoWaaS extensions in the DB.
|
||||
*
|
||||
* Sets protected=1, locked=0 so the extension can't be disabled or
|
||||
* uninstalled but can still receive updates and config changes.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.03.10
|
||||
*/
|
||||
protected function ensureProtectedFlag()
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Set protected=1, locked=0 on MokoWaaS extensions
|
||||
$query = $db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('protected') . ' = 1')
|
||||
->set($db->quoteName('locked') . ' = 0')
|
||||
->where('(' . $db->quoteName('element') . ' = ' . $db->quote('mokowaas')
|
||||
. ' OR ' . $db->quoteName('element') . ' = ' . $db->quote('pkg_mokowaas') . ')')
|
||||
->where($db->quoteName('protected') . ' = 0');
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
|
||||
// Ensure update site stays enabled (protected extensions get their update site disabled by Joomla)
|
||||
$query = $db->getQuery(true)
|
||||
->update($db->quoteName('#__update_sites') . ' AS us')
|
||||
->join('INNER', $db->quoteName('#__update_sites_extensions') . ' AS use2 ON us.update_site_id = use2.update_site_id')
|
||||
->join('INNER', $db->quoteName('#__extensions') . ' AS e ON use2.extension_id = e.extension_id')
|
||||
->set('us.enabled = 1')
|
||||
->where('us.enabled = 0')
|
||||
->where('(' . $db->quoteName('e.element') . ' = ' . $db->quote('mokowaas')
|
||||
. ' OR ' . $db->quoteName('e.element') . ' = ' . $db->quote('pkg_mokowaas') . ')');
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any of the given extension IDs belong to MokoWaaS.
|
||||
*
|
||||
* @param array $ids Extension IDs to check
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 02.03.04
|
||||
*/
|
||||
protected function isOurExtension(array $ids): bool
|
||||
{
|
||||
if (empty($ids))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('extension_id') . ' IN (' . implode(',', array_map('intval', $ids)) . ')')
|
||||
->where('(' . $db->quoteName('element') . ' = ' . $db->quote('mokowaas')
|
||||
. ' OR ' . $db->quoteName('element') . ' = ' . $db->quote('pkg_mokowaas') . ')');
|
||||
|
||||
return (int) $db->setQuery($query)->loadResult() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent non-master users from disabling the plugin via save.
|
||||
*
|
||||
@@ -4359,115 +4164,7 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
* @since 02.01.08
|
||||
*/
|
||||
|
||||
/**
|
||||
* Set a parameter on a template style.
|
||||
*
|
||||
* @param string $template Template element name
|
||||
* @param string $key Parameter key
|
||||
* @param mixed $value Parameter value
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.31.00
|
||||
*/
|
||||
private function setTemplateParam(string $template, string $key, $value): void
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select([$db->quoteName('id'), $db->quoteName('params')])
|
||||
->from($db->quoteName('#__template_styles'))
|
||||
->where($db->quoteName('template') . ' = ' . $db->quote($template));
|
||||
$db->setQuery($query);
|
||||
$styles = $db->loadObjectList();
|
||||
|
||||
foreach ($styles as $style)
|
||||
{
|
||||
$params = new \Joomla\Registry\Registry($style->params ?: '{}');
|
||||
|
||||
if ($params->get($key) != $value)
|
||||
{
|
||||
$params->set($key, $value);
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__template_styles'))
|
||||
->set($db->quoteName('params') . ' = ' . $db->quote($params->toString()))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $style->id)
|
||||
)->execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
// Silent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce login support module URLs on admin requests.
|
||||
*
|
||||
* Checks the mod_loginsupport module params and corrects them if
|
||||
* they have been changed away from the expected values.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.01.08
|
||||
*/
|
||||
protected function enforceLoginSupportUrls()
|
||||
{
|
||||
$expected = [
|
||||
'forum_url' => 'https://mokoconsulting.tech/support',
|
||||
'documentation_url' => 'https://mokoconsulting.tech/kb',
|
||||
'news_url' => 'https://mokoconsulting.tech/news',
|
||||
];
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select([$db->quoteName('id'), $db->quoteName('params')])
|
||||
->from($db->quoteName('#__modules'))
|
||||
->where($db->quoteName('module') . ' = '
|
||||
. $db->quote('mod_loginsupport'));
|
||||
|
||||
$db->setQuery($query);
|
||||
$modules = $db->loadObjectList();
|
||||
|
||||
if (empty($modules))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($modules as $module)
|
||||
{
|
||||
$params = new \Joomla\Registry\Registry(
|
||||
$module->params ?: '{}'
|
||||
);
|
||||
$needsFix = false;
|
||||
|
||||
foreach ($expected as $key => $url)
|
||||
{
|
||||
if ($params->get($key) !== $url)
|
||||
{
|
||||
$params->set($key, $url);
|
||||
$needsFix = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($needsFix)
|
||||
{
|
||||
$update = $db->getQuery(true)
|
||||
->update($db->quoteName('#__modules'))
|
||||
->set($db->quoteName('params') . ' = '
|
||||
. $db->quote($params->toString()))
|
||||
->where($db->quoteName('id') . ' = '
|
||||
. (int) $module->id);
|
||||
|
||||
$db->setQuery($update);
|
||||
$db->execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Tenant Restrictions (called from onAfterRoute)
|
||||
@@ -4524,224 +4221,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
return $this->masterNames;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Atum Template Branding (called from onAfterInitialise)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Enforce Atum admin template branding params.
|
||||
*
|
||||
* Sets logoBrandLarge, logoBrandSmall, loginLogo, and alt text
|
||||
* in the Atum template style params. Uses the plugin's media
|
||||
* folder as the image source. Only writes to DB when values
|
||||
* have drifted.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.01.08
|
||||
*/
|
||||
protected function enforceAtumBranding()
|
||||
{
|
||||
$mediaBase = 'media/plg_system_mokowaas/';
|
||||
|
||||
// Logo params
|
||||
$expected = [
|
||||
'logoBrandLarge' => $mediaBase . 'logo.png',
|
||||
'logoBrandSmall' => $mediaBase . 'favicon_256.png',
|
||||
'loginLogo' => $mediaBase . 'logo.png',
|
||||
'logoBrandLargeAlt' => '',
|
||||
'logoBrandSmallAlt' => '',
|
||||
'loginLogoAlt' => '',
|
||||
'emptyLogoBrandLargeAlt' => '1',
|
||||
'emptyLogoBrandSmallAlt' => '1',
|
||||
'emptyLoginLogoAlt' => '1',
|
||||
];
|
||||
|
||||
// Hardcoded color scheme
|
||||
$primary = self::COLOR_PRIMARY;
|
||||
$sidebar = self::COLOR_SIDEBAR;
|
||||
$link = self::COLOR_LINK;
|
||||
|
||||
if (!empty($primary))
|
||||
{
|
||||
// Convert hex to HSL for Atum's hue param
|
||||
$hsl = $this->hexToHsl($primary);
|
||||
|
||||
if ($hsl)
|
||||
{
|
||||
$expected['hue'] = sprintf(
|
||||
'hsl(%d, %d%%, %d%%)',
|
||||
$hsl[0], $hsl[1], $hsl[2]
|
||||
);
|
||||
}
|
||||
|
||||
$expected['special-color'] = $primary;
|
||||
}
|
||||
|
||||
if (!empty($sidebar))
|
||||
{
|
||||
$expected['header-color'] = $sidebar;
|
||||
}
|
||||
|
||||
if (!empty($link))
|
||||
{
|
||||
$expected['link-color'] = $link;
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select([$db->quoteName('id'), $db->quoteName('params')])
|
||||
->from($db->quoteName('#__template_styles'))
|
||||
->where($db->quoteName('template') . ' = '
|
||||
. $db->quote('atum'))
|
||||
->where($db->quoteName('client_id') . ' = 1');
|
||||
|
||||
$db->setQuery($query);
|
||||
$styles = $db->loadObjectList();
|
||||
|
||||
if (empty($styles))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($styles as $style)
|
||||
{
|
||||
$params = new \Joomla\Registry\Registry(
|
||||
$style->params ?: '{}'
|
||||
);
|
||||
$needsFix = false;
|
||||
|
||||
foreach ($expected as $key => $value)
|
||||
{
|
||||
if ($params->get($key) !== $value)
|
||||
{
|
||||
$params->set($key, $value);
|
||||
$needsFix = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($needsFix)
|
||||
{
|
||||
$update = $db->getQuery(true)
|
||||
->update($db->quoteName('#__template_styles'))
|
||||
->set($db->quoteName('params') . ' = '
|
||||
. $db->quote($params->toString()))
|
||||
->where($db->quoteName('id') . ' = '
|
||||
. (int) $style->id);
|
||||
|
||||
$db->setQuery($update);
|
||||
$db->execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a hex color to HSL values.
|
||||
*
|
||||
* @param string $hex Hex color (e.g., "#1a2744")
|
||||
*
|
||||
* @return array|null [hue, saturation%, lightness%] or null
|
||||
*
|
||||
* @since 02.01.08
|
||||
*/
|
||||
protected function hexToHsl($hex)
|
||||
{
|
||||
$hex = ltrim($hex, '#');
|
||||
|
||||
if (strlen($hex) !== 6)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
$r = hexdec(substr($hex, 0, 2)) / 255;
|
||||
$g = hexdec(substr($hex, 2, 2)) / 255;
|
||||
$b = hexdec(substr($hex, 4, 2)) / 255;
|
||||
|
||||
$max = max($r, $g, $b);
|
||||
$min = min($r, $g, $b);
|
||||
$l = ($max + $min) / 2;
|
||||
|
||||
if ($max === $min)
|
||||
{
|
||||
return [0, 0, (int) round($l * 100)];
|
||||
}
|
||||
|
||||
$d = $max - $min;
|
||||
$s = $l > 0.5
|
||||
? $d / (2 - $max - $min)
|
||||
: $d / ($max + $min);
|
||||
|
||||
if ($max === $r)
|
||||
{
|
||||
$h = ($g - $b) / $d + ($g < $b ? 6 : 0);
|
||||
}
|
||||
elseif ($max === $g)
|
||||
{
|
||||
$h = ($b - $r) / $d + 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
$h = ($r - $g) / $d + 4;
|
||||
}
|
||||
|
||||
$h = $h / 6;
|
||||
|
||||
return [
|
||||
(int) round($h * 360),
|
||||
(int) round($s * 100),
|
||||
(int) round($l * 100),
|
||||
];
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Visual Branding (called from onBeforeCompileHead)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Replace the default favicon with a custom one.
|
||||
*
|
||||
* @param \Joomla\CMS\Document\HtmlDocument $doc
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.01.08
|
||||
*/
|
||||
protected function injectFavicon($doc)
|
||||
{
|
||||
$mediaBase = 'media/plg_system_mokowaas/';
|
||||
$root = Uri::root();
|
||||
|
||||
// Remove all existing favicon/icon links
|
||||
foreach ($doc->_links as $href => $attrs)
|
||||
{
|
||||
if (isset($attrs['relation'])
|
||||
&& strpos($attrs['relation'], 'icon') !== false)
|
||||
{
|
||||
unset($doc->_links[$href]);
|
||||
}
|
||||
}
|
||||
|
||||
// SVG favicon (modern browsers, preferred)
|
||||
$doc->addHeadLink(
|
||||
$root . $mediaBase . 'favicon.svg',
|
||||
'icon',
|
||||
'rel',
|
||||
['type' => 'image/svg+xml']
|
||||
);
|
||||
// ICO fallback (legacy browsers)
|
||||
$doc->addHeadLink(
|
||||
$root . $mediaBase . 'favicon.ico',
|
||||
'alternate icon',
|
||||
'rel',
|
||||
['type' => 'image/vnd.microsoft.icon']
|
||||
);
|
||||
// PNG for Apple/Android
|
||||
$doc->addHeadLink(
|
||||
$root . $mediaBase . 'favicon_256.png',
|
||||
'apple-touch-icon',
|
||||
'rel',
|
||||
['sizes' => '256x256']
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoWaaS
|
||||
* VERSION: 02.32.38
|
||||
* VERSION: 02.34.08
|
||||
* 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.32.38
|
||||
* VERSION: 02.34.08
|
||||
* 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.32.38
|
||||
* VERSION: 02.34.08
|
||||
* 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.32.38
|
||||
* VERSION: 02.34.08
|
||||
* 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.32.38
|
||||
* VERSION: 02.34.08
|
||||
* 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.32.38
|
||||
* VERSION: 02.34.08
|
||||
* 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.32.38
|
||||
* VERSION: 02.34.08
|
||||
* 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.32.38
|
||||
* VERSION: 02.34.08
|
||||
* 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.32.38
|
||||
* VERSION: 02.34.08
|
||||
* BRIEF: Content-only snapshot/restore for demo site reset
|
||||
*/
|
||||
|
||||
|
||||
+2
-2
@@ -15,5 +15,5 @@
|
||||
; Variables: (none)
|
||||
; -----------------------------------------------------------------------------
|
||||
|
||||
PLG_SYSTEM_MOKOWAAS="System - Moko WaaS"
|
||||
PLG_SYSTEM_MOKOWAAS_XML_DESCRIPTION="This plugin rebrands the Joomla system interface with MokoWaaS identity. It applies language overrides and ensures consistent branding across the platform."
|
||||
PLG_SYSTEM_MOKOWAAS="System - MokoWaaS Core"
|
||||
PLG_SYSTEM_MOKOWAAS_XML_DESCRIPTION="MokoWaaS core system plugin — coordinates feature plugins, master user management, event routing, and admin customizations."
|
||||
|
||||
+2
-2
@@ -15,5 +15,5 @@
|
||||
; Variables: (none)
|
||||
; -----------------------------------------------------------------------------
|
||||
|
||||
PLG_SYSTEM_MOKOWAAS="System - Moko WaaS"
|
||||
PLG_SYSTEM_MOKOWAAS_XML_DESCRIPTION="This plugin rebrands the Joomla system interface with MokoWaaS identity. It applies language overrides and ensures consistent branding across the platform."
|
||||
PLG_SYSTEM_MOKOWAAS="System - MokoWaaS Core"
|
||||
PLG_SYSTEM_MOKOWAAS_XML_DESCRIPTION="MokoWaaS core system plugin — coordinates feature plugins, master user management, event routing, and admin customizations."
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
; Variables: (none)
|
||||
; -----------------------------------------------------------------------------
|
||||
|
||||
PLG_SYSTEM_MOKOWAAS="System - MokoWaaS"
|
||||
PLG_SYSTEM_MOKOWAAS_XML_DESCRIPTION="This plugin rebrands the Joomla system interface with MokoWaaS identity. It applies language overrides and ensures consistent branding across the platform."
|
||||
PLG_SYSTEM_MOKOWAAS="System - MokoWaaS Core"
|
||||
PLG_SYSTEM_MOKOWAAS_XML_DESCRIPTION="MokoWaaS core system plugin — coordinates feature plugins, master user management, event routing, and admin customizations."
|
||||
|
||||
PLG_SYSTEM_MOKOWAAS_ENABLE_BRANDING_LABEL="Enable Branding"
|
||||
PLG_SYSTEM_MOKOWAAS_ENABLE_BRANDING_DESC="Enable or disable the branding overrides across the system."
|
||||
@@ -111,7 +111,7 @@ PLG_SYSTEM_MOKOWAAS_SYNC_PUSH_NOW_DESC="Set to Yes and save to immediately push
|
||||
PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_URL_LABEL="Site URL"
|
||||
PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_URL_DESC="Full URL of the remote Joomla site (e.g. https://client.example.com)."
|
||||
PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_TOKEN_LABEL="API Token"
|
||||
PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_TOKEN_DESC="The health_api_token from the remote site's MokoWaaS plugin settings."
|
||||
PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_TOKEN_DESC="The heartbeat token from the remote site's MokoWaaS plugin settings."
|
||||
PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_LABEL_LABEL="Label"
|
||||
PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_LABEL_DESC="Friendly name for this target (for identification only)."
|
||||
|
||||
@@ -121,7 +121,7 @@ PLG_SYSTEM_MOKOWAAS_FIELDSET_DIAGNOSTICS_DESC="Health check endpoint for externa
|
||||
|
||||
PLG_SYSTEM_MOKOWAAS_ENABLE_HEALTH_LABEL="Enable Health Endpoint"
|
||||
PLG_SYSTEM_MOKOWAAS_ENABLE_HEALTH_DESC="Expose a JSON health check endpoint at <code>/?mokowaas=health</code>. Requires a valid API token. A random token is generated automatically when enabled."
|
||||
PLG_SYSTEM_MOKOWAAS_HEALTH_TOKEN_LABEL="Health API Token"
|
||||
PLG_SYSTEM_MOKOWAAS_HEALTH_TOKEN_LABEL="Heartbeat Token"
|
||||
PLG_SYSTEM_MOKOWAAS_HEALTH_TOKEN_DESC="Auto-generated bearer token for the health endpoint. Use this token in your Grafana datasource configuration. Send as <code>Authorization: Bearer <token></code> header or <code>&token=<value></code> query parameter."
|
||||
PLG_SYSTEM_MOKOWAAS_GRAFANA_URL_LABEL="Grafana URL"
|
||||
PLG_SYSTEM_MOKOWAAS_GRAFANA_URL_DESC="Base URL of your Grafana instance (e.g. <code>https://grafana.example.com</code>). When provided along with an API key, the plugin will auto-provision a datasource and dashboard in Grafana when the health endpoint is enabled."
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
; Variables: (none)
|
||||
; -----------------------------------------------------------------------------
|
||||
|
||||
PLG_SYSTEM_MOKOWAAS="System - MokoWaaS"
|
||||
PLG_SYSTEM_MOKOWAAS_XML_DESCRIPTION="This plugin rebrands the Joomla system interface with MokoWaaS identity. It applies language overrides and ensures consistent branding across the platform."
|
||||
PLG_SYSTEM_MOKOWAAS="System - MokoWaaS Core"
|
||||
PLG_SYSTEM_MOKOWAAS_XML_DESCRIPTION="MokoWaaS core system plugin — coordinates feature plugins, master user management, event routing, and admin customizations."
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
; Variables: (none)
|
||||
; -----------------------------------------------------------------------------
|
||||
|
||||
PLG_SYSTEM_MOKOWAAS="System - MokoWaaS"
|
||||
PLG_SYSTEM_MOKOWAAS_XML_DESCRIPTION="This plugin rebrands the Joomla system interface with MokoWaaS identity. It applies language overrides and ensures consistent branding across the platform."
|
||||
PLG_SYSTEM_MOKOWAAS="System - MokoWaaS Core"
|
||||
PLG_SYSTEM_MOKOWAAS_XML_DESCRIPTION="MokoWaaS core system plugin — coordinates feature plugins, master user management, event routing, and admin customizations."
|
||||
|
||||
PLG_SYSTEM_MOKOWAAS_ENABLE_BRANDING_LABEL="Enable Branding"
|
||||
PLG_SYSTEM_MOKOWAAS_ENABLE_BRANDING_DESC="Enable or disable the branding overrides across the system."
|
||||
@@ -111,7 +111,7 @@ PLG_SYSTEM_MOKOWAAS_SYNC_PUSH_NOW_DESC="Set to Yes and save to immediately push
|
||||
PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_URL_LABEL="Site URL"
|
||||
PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_URL_DESC="Full URL of the remote Joomla site (e.g. https://client.example.com)."
|
||||
PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_TOKEN_LABEL="API Token"
|
||||
PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_TOKEN_DESC="The health_api_token from the remote site's MokoWaaS plugin settings."
|
||||
PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_TOKEN_DESC="The heartbeat token from the remote site's MokoWaaS plugin settings."
|
||||
PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_LABEL_LABEL="Label"
|
||||
PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_LABEL_DESC="Friendly name for this target (for identification only)."
|
||||
|
||||
@@ -121,7 +121,7 @@ PLG_SYSTEM_MOKOWAAS_FIELDSET_DIAGNOSTICS_DESC="Health check endpoint for externa
|
||||
|
||||
PLG_SYSTEM_MOKOWAAS_ENABLE_HEALTH_LABEL="Enable Health Endpoint"
|
||||
PLG_SYSTEM_MOKOWAAS_ENABLE_HEALTH_DESC="Expose a JSON health check endpoint at <code>/?mokowaas=health</code>. Requires a valid API token. A random token is generated automatically when enabled."
|
||||
PLG_SYSTEM_MOKOWAAS_HEALTH_TOKEN_LABEL="Health API Token"
|
||||
PLG_SYSTEM_MOKOWAAS_HEALTH_TOKEN_LABEL="Heartbeat Token"
|
||||
PLG_SYSTEM_MOKOWAAS_HEALTH_TOKEN_DESC="Auto-generated bearer token for the health endpoint. Use this token in your Grafana datasource configuration. Send as <code>Authorization: Bearer <token></code> header or <code>&token=<value></code> query parameter."
|
||||
PLG_SYSTEM_MOKOWAAS_GRAFANA_URL_LABEL="Grafana URL"
|
||||
PLG_SYSTEM_MOKOWAAS_GRAFANA_URL_DESC="Base URL of your Grafana instance (e.g. <code>https://grafana.example.com</code>). When provided along with an API key, the plugin will auto-provision a datasource and dashboard in Grafana when the health endpoint is enabled."
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
; Variables: (none)
|
||||
; -----------------------------------------------------------------------------
|
||||
|
||||
PLG_SYSTEM_MOKOWAAS="System - MokoWaaS"
|
||||
PLG_SYSTEM_MOKOWAAS_XML_DESCRIPTION="This plugin rebrands the Joomla system interface with MokoWaaS identity. It applies language overrides and ensures consistent branding across the platform."
|
||||
PLG_SYSTEM_MOKOWAAS="System - MokoWaaS Core"
|
||||
PLG_SYSTEM_MOKOWAAS_XML_DESCRIPTION="MokoWaaS core system plugin — coordinates feature plugins, master user management, event routing, and admin customizations."
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.04
|
||||
VERSION: 02.34.00
|
||||
PATH: /src/mokowaas.xml
|
||||
BRIEF: Plugin manifest for MokoWaaS system plugin
|
||||
NOTE: Defines installation metadata, files, and configuration for Joomla
|
||||
-->
|
||||
<extension type="plugin" group="system" method="upgrade">
|
||||
<name>System - MokoWaaS</name>
|
||||
<name>System - MokoWaaS Core</name>
|
||||
<element>mokowaas</element>
|
||||
<author>Moko Consulting</author>
|
||||
<creationDate>2026-05-22</creationDate>
|
||||
@@ -30,8 +30,8 @@
|
||||
<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.32.38</version>
|
||||
<description>This plugin rebrands the Joomla system interface with MokoWaaS identity. It applies language overrides and ensures consistent branding across the platform.</description>
|
||||
<version>02.34.08-dev</version>
|
||||
<description>MokoWaaS core system plugin — coordinates feature plugins, master user management, event routing, and admin customizations.</description>
|
||||
<namespace path=".">Moko\Plugin\System\MokoWaaS</namespace>
|
||||
<scriptfile>script.php</scriptfile>
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoWaaS
|
||||
* REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
* VERSION: 02.32.38
|
||||
* VERSION: 02.34.08
|
||||
* PATH: /src/script.php
|
||||
* BRIEF: Installation script for MokoWaaS plugin
|
||||
* NOTE: Handles installation, update, and uninstallation tasks including language override deployment
|
||||
@@ -127,7 +127,6 @@ class plgSystemMokoWaaSInstallerScript implements InstallerScriptInterface
|
||||
$this->ensureMokoCassiopeia();
|
||||
$this->installLanguageOverrides();
|
||||
$this->updateLoginSupportUrls();
|
||||
$this->updateAtumBranding();
|
||||
$this->registerActionLogExtension();
|
||||
$this->provisionHealthEndpoint();
|
||||
$this->sendInstallNotification($type);
|
||||
@@ -552,7 +551,6 @@ class plgSystemMokoWaaSInstallerScript implements InstallerScriptInterface
|
||||
$params = $this->getPluginParams();
|
||||
|
||||
return [
|
||||
'{{BRAND_NAME}}' => $params->get('brand_name', 'MokoWaaS'),
|
||||
'{{COMPANY_NAME}}' => $params->get('company_name', 'Moko Consulting'),
|
||||
'{{SUPPORT_URL}}' => $params->get('support_url', 'https://mokoconsulting.tech/support'),
|
||||
];
|
||||
@@ -696,7 +694,7 @@ class plgSystemMokoWaaSInstallerScript implements InstallerScriptInterface
|
||||
|
||||
$supportUrls = [
|
||||
'forum_url' => 'https://mokoconsulting.tech/support',
|
||||
'documentation_url' => 'https://mokoconsulting.tech/kb',
|
||||
'documentation_url' => 'https://mokoconsulting.tech/support/products',
|
||||
'news_url' => 'https://mokoconsulting.tech/news',
|
||||
];
|
||||
|
||||
@@ -727,75 +725,6 @@ class plgSystemMokoWaaSInstallerScript implements InstallerScriptInterface
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Atum admin template branding params at install time.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.01.08
|
||||
*/
|
||||
private function updateAtumBranding()
|
||||
{
|
||||
$mediaBase = 'media/plg_system_mokowaas/';
|
||||
|
||||
$expected = [
|
||||
'logoBrandLarge' => $mediaBase . 'logo.png',
|
||||
'logoBrandSmall' => $mediaBase . 'favicon_256.png',
|
||||
'loginLogo' => $mediaBase . 'logo.png',
|
||||
'logoBrandLargeAlt' => '',
|
||||
'logoBrandSmallAlt' => '',
|
||||
'loginLogoAlt' => '',
|
||||
'emptyLogoBrandLargeAlt' => '1',
|
||||
'emptyLogoBrandSmallAlt' => '1',
|
||||
'emptyLoginLogoAlt' => '1',
|
||||
'hue' => 'hsl(219, 44%, 18%)',
|
||||
'special-color' => '#1a2744',
|
||||
'link-color' => '#0051ad',
|
||||
];
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select([$db->quoteName('id'), $db->quoteName('params')])
|
||||
->from($db->quoteName('#__template_styles'))
|
||||
->where($db->quoteName('template') . ' = '
|
||||
. $db->quote('atum'))
|
||||
->where($db->quoteName('client_id') . ' = 1');
|
||||
|
||||
$db->setQuery($query);
|
||||
$styles = $db->loadObjectList();
|
||||
|
||||
if (empty($styles))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($styles as $style)
|
||||
{
|
||||
$params = new \Joomla\Registry\Registry(
|
||||
$style->params ?: '{}'
|
||||
);
|
||||
|
||||
foreach ($expected as $key => $value)
|
||||
{
|
||||
$params->set($key, $value);
|
||||
}
|
||||
|
||||
$update = $db->getQuery(true)
|
||||
->update($db->quoteName('#__template_styles'))
|
||||
->set($db->quoteName('params') . ' = '
|
||||
. $db->quote($params->toString()))
|
||||
->where($db->quoteName('id') . ' = '
|
||||
. (int) $style->id);
|
||||
|
||||
$db->setQuery($update);
|
||||
$db->execute();
|
||||
}
|
||||
|
||||
Factory::getApplication()->enqueueMessage(
|
||||
'Updated Atum template branding.', 'message'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the plugin in #__action_logs_extensions so it appears
|
||||
* as a filterable extension in System > Action Logs.
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoWaaS
|
||||
* REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
* VERSION: 02.32.38
|
||||
* VERSION: 02.34.08
|
||||
* PATH: /src/services/provider.php
|
||||
* BRIEF: Service provider for dependency injection in Joomla 5.x
|
||||
* NOTE: Registers the plugin with Joomla's DI container
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.32.38</version>
|
||||
<version>02.34.08-dev</version>
|
||||
<description>PLG_SYSTEM_MOKOWAAS_DEVTOOLS_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoWaaSDevTools</namespace>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.32.38</version>
|
||||
<version>02.34.08-dev</version>
|
||||
<description>PLG_SYSTEM_MOKOWAAS_FIREWALL_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoWaaSFirewall</namespace>
|
||||
|
||||
@@ -127,6 +127,53 @@
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<!-- Security Headers -->
|
||||
<fieldset name="headers"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_HEADERS"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_HEADERS_DESC">
|
||||
|
||||
<field name="header_xframe" type="radio" default="1"
|
||||
label="X-Frame-Options" description="Clickjacking protection (SAMEORIGIN)"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option><option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="header_xcontent" type="radio" default="1"
|
||||
label="X-Content-Type-Options" description="MIME sniffing prevention (nosniff)"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option><option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="header_xxss" type="radio" default="1"
|
||||
label="X-XSS-Protection" description="Browser XSS filter (1; mode=block)"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option><option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="header_referrer" type="list" default="strict-origin-when-cross-origin"
|
||||
label="Referrer-Policy" description="Controls referrer information sent with requests">
|
||||
<option value="off">Off</option>
|
||||
<option value="no-referrer">no-referrer</option>
|
||||
<option value="same-origin">same-origin</option>
|
||||
<option value="strict-origin-when-cross-origin">strict-origin-when-cross-origin</option>
|
||||
</field>
|
||||
<field name="header_hsts" type="radio" default="0"
|
||||
label="HSTS (Strict-Transport-Security)" description="Force HTTPS via browser header"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option><option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="header_hsts_maxage" type="number" default="31536000"
|
||||
label="HSTS Max Age (seconds)" showon="header_hsts:1" />
|
||||
<field name="header_hsts_subdomains" type="radio" default="0"
|
||||
label="HSTS Include Subdomains" class="btn-group btn-group-yesno"
|
||||
showon="header_hsts:1">
|
||||
<option value="1">JYES</option><option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="header_csp" type="textarea" default=""
|
||||
label="Content-Security-Policy" description="CSP header value (leave empty to disable)"
|
||||
rows="2" filter="raw" />
|
||||
<field name="header_permissions" type="textarea" default=""
|
||||
label="Permissions-Policy" description="e.g. camera=(), microphone=(), geolocation=()"
|
||||
rows="2" filter="raw" />
|
||||
</fieldset>
|
||||
|
||||
<!-- Access Control -->
|
||||
<fieldset name="access_control"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_ACCESS"
|
||||
@@ -152,6 +199,16 @@
|
||||
default="" filter="url" hint="Empty = 403 Forbidden"
|
||||
showon="admin_secret!:" />
|
||||
|
||||
<field name="autoban_threshold" type="number" default="10"
|
||||
label="Auto-Ban Threshold"
|
||||
description="Auto-ban IP after this many WAF blocks (0 = disabled)"
|
||||
hint="0 = disabled" />
|
||||
|
||||
<field name="autoban_window" type="number" default="5"
|
||||
label="Auto-Ban Window (minutes)"
|
||||
description="Time window for counting blocks before auto-ban"
|
||||
showon="autoban_threshold!:0" />
|
||||
|
||||
<field name="block_frontend_superuser" type="radio" default="0"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_BLOCK_FE_SU_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_BLOCK_FE_SU_DESC"
|
||||
|
||||
@@ -65,6 +65,7 @@ class Firewall extends CMSPlugin implements SubscriberInterface, BootableExtensi
|
||||
{
|
||||
return [
|
||||
'onAfterInitialise' => 'onAfterInitialise',
|
||||
'onAfterRoute' => 'onAfterRoute',
|
||||
'onUserBeforeSave' => 'onUserBeforeSave',
|
||||
];
|
||||
}
|
||||
@@ -111,7 +112,8 @@ class Firewall extends CMSPlugin implements SubscriberInterface, BootableExtensi
|
||||
$this->checkDirectPhpAccess();
|
||||
}
|
||||
|
||||
// Existing features
|
||||
// Security headers + existing features
|
||||
$this->injectSecurityHeaders();
|
||||
$this->enforceHttps();
|
||||
$this->enforceUploadRestrictions();
|
||||
|
||||
@@ -400,6 +402,46 @@ class Firewall extends CMSPlugin implements SubscriberInterface, BootableExtensi
|
||||
'created' => gmdate('Y-m-d H:i:s'),
|
||||
];
|
||||
$db->insertObject('#__mokowaas_waf_log', $row);
|
||||
|
||||
// Security alert email (#131) — rate limited to 1 per IP per 5 minutes
|
||||
try
|
||||
{
|
||||
$alertKey = 'mokowaas_waf_alert_' . md5($ip);
|
||||
$session = \Joomla\CMS\Factory::getSession();
|
||||
|
||||
if (!$session->get($alertKey, false))
|
||||
{
|
||||
$session->set($alertKey, true);
|
||||
\Moko\Component\MokoWaaS\Administrator\Service\NotificationService::securityAlert(
|
||||
'waf_block',
|
||||
'WAF Block: ' . $rule . ' from ' . $ip,
|
||||
"Rule: {$rule}\nIP: {$ip}\nURI: {$uri}\nDetail: " . substr($detail, 0, 200)
|
||||
);
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e) {}
|
||||
|
||||
// Auto-ban: if IP has N+ blocks in last M minutes, add to blocklist (#143)
|
||||
$threshold = (int) $this->params->get('autoban_threshold', 10);
|
||||
$window = (int) $this->params->get('autoban_window', 5);
|
||||
|
||||
if ($threshold > 0 && $window > 0)
|
||||
{
|
||||
$cutoff = gmdate('Y-m-d H:i:s', time() - ($window * 60));
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokowaas_waf_log'))
|
||||
->where($db->quoteName('ip') . ' = ' . $db->quote($ip))
|
||||
->where($db->quoteName('created') . ' >= ' . $db->quote($cutoff))
|
||||
);
|
||||
$recentBlocks = (int) $db->loadResult();
|
||||
|
||||
if ($recentBlocks >= $threshold)
|
||||
{
|
||||
$this->autoBanIp($ip, $db);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
@@ -418,6 +460,51 @@ class Firewall extends CMSPlugin implements SubscriberInterface, BootableExtensi
|
||||
// Input Scanning
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* Auto-ban an IP by adding it to the blocklist params (#143).
|
||||
*/
|
||||
private function autoBanIp(string $ip, $db): void
|
||||
{
|
||||
try
|
||||
{
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'));
|
||||
$db->setQuery($query);
|
||||
$params = new \Joomla\Registry\Registry($db->loadResult() ?? '{}');
|
||||
$blocklist = json_decode($params->get('ip_blocklist', '[]'), true) ?: [];
|
||||
|
||||
foreach ($blocklist as $entry)
|
||||
{
|
||||
if (($entry['ip'] ?? '') === $ip)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$blocklist[] = ['ip' => $ip, 'enabled' => '1', 'label' => 'Auto-banned by WAF (' . gmdate('Y-m-d H:i') . ')'];
|
||||
$params->set('ip_blocklist', json_encode($blocklist));
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('params') . ' = ' . $db->quote($params->toString()))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
|
||||
)->execute();
|
||||
|
||||
Log::add('WAF auto-banned IP: ' . $ip, Log::WARNING, 'mokowaas');
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
// Silent
|
||||
}
|
||||
}
|
||||
|
||||
private function scanInput(array $input, string $pattern): ?string
|
||||
{
|
||||
foreach ($input as $key => $value)
|
||||
@@ -435,7 +522,8 @@ class Firewall extends CMSPlugin implements SubscriberInterface, BootableExtensi
|
||||
}
|
||||
|
||||
$value = (string) $value;
|
||||
$decoded = urldecode($value);
|
||||
// Double-decode to catch %25xx encoding tricks
|
||||
$decoded = urldecode(urldecode($value));
|
||||
|
||||
if (preg_match($pattern, $value) || preg_match($pattern, $decoded))
|
||||
{
|
||||
@@ -547,6 +635,68 @@ class Firewall extends CMSPlugin implements SubscriberInterface, BootableExtensi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject HTTP security headers at runtime (#124).
|
||||
*/
|
||||
private function injectSecurityHeaders(): void
|
||||
{
|
||||
$app = $this->getApplication();
|
||||
|
||||
if ($app->isClient('cli'))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->params->get('header_xframe', 1))
|
||||
{
|
||||
$app->setHeader('X-Frame-Options', 'SAMEORIGIN', true);
|
||||
}
|
||||
|
||||
if ($this->params->get('header_xcontent', 1))
|
||||
{
|
||||
$app->setHeader('X-Content-Type-Options', 'nosniff', true);
|
||||
}
|
||||
|
||||
if ($this->params->get('header_xxss', 1))
|
||||
{
|
||||
$app->setHeader('X-XSS-Protection', '1; mode=block', true);
|
||||
}
|
||||
|
||||
$referrer = $this->params->get('header_referrer', '');
|
||||
|
||||
if (!empty($referrer) && $referrer !== 'off')
|
||||
{
|
||||
$app->setHeader('Referrer-Policy', $referrer, true);
|
||||
}
|
||||
|
||||
if ($this->params->get('header_hsts', 0))
|
||||
{
|
||||
$maxAge = (int) $this->params->get('header_hsts_maxage', 31536000);
|
||||
$hsts = 'max-age=' . $maxAge;
|
||||
|
||||
if ($this->params->get('header_hsts_subdomains', 0))
|
||||
{
|
||||
$hsts .= '; includeSubDomains';
|
||||
}
|
||||
|
||||
$app->setHeader('Strict-Transport-Security', $hsts, true);
|
||||
}
|
||||
|
||||
$csp = $this->params->get('header_csp', '');
|
||||
|
||||
if (!empty($csp))
|
||||
{
|
||||
$app->setHeader('Content-Security-Policy', $csp, true);
|
||||
}
|
||||
|
||||
$perms = $this->params->get('header_permissions', '');
|
||||
|
||||
if (!empty($perms))
|
||||
{
|
||||
$app->setHeader('Permissions-Policy', $perms, true);
|
||||
}
|
||||
}
|
||||
|
||||
private function enforceHttps(): void
|
||||
{
|
||||
if (!$this->params->get('force_https', 0))
|
||||
@@ -643,4 +793,177 @@ class Firewall extends CMSPlugin implements SubscriberInterface, BootableExtensi
|
||||
$config->set('upload_maxsize', $maxMb);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Extension Protection (#155)
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* Protect MokoWaaS extensions after routing.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.35.00
|
||||
*/
|
||||
public function onAfterRoute(): void
|
||||
{
|
||||
$app = $this->getApplication();
|
||||
|
||||
if (!$app->isClient('administrator'))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$this->protectPlugin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Protect the plugin from being disabled or uninstalled by non-master users.
|
||||
* Does NOT self-heal (no lock) -- master users can still disable if needed.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.03.04
|
||||
*/
|
||||
private function protectPlugin(): void
|
||||
{
|
||||
// Ensure protected flag is set (self-healing -- runs once per session)
|
||||
static $flagChecked = false;
|
||||
|
||||
if (!$flagChecked)
|
||||
{
|
||||
$flagChecked = true;
|
||||
$this->ensureProtectedFlag();
|
||||
}
|
||||
|
||||
if (MokoWaaSHelper::isMasterUser())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$app = $this->getApplication();
|
||||
$option = $app->input->get('option', '');
|
||||
$task = $app->input->get('task', '');
|
||||
|
||||
// Block non-master from uninstalling MokoWaaS
|
||||
if ($option === 'com_installer' && strpos($task, 'manage.remove') !== false)
|
||||
{
|
||||
$cid = $app->input->get('cid', [], 'array');
|
||||
|
||||
if ($this->isOurExtension($cid))
|
||||
{
|
||||
$app->enqueueMessage('MokoWaaS cannot be uninstalled.', 'error');
|
||||
$app->redirect('index.php?option=com_installer&view=manage');
|
||||
}
|
||||
}
|
||||
|
||||
// Block non-master from disabling via list toggle
|
||||
if ($option === 'com_plugins' && strpos($task, 'plugins.publish') !== false)
|
||||
{
|
||||
$cid = $app->input->get('cid', [], 'array');
|
||||
|
||||
if ($this->isOurExtension($cid))
|
||||
{
|
||||
$app->enqueueMessage('MokoWaaS cannot be disabled.', 'error');
|
||||
$app->redirect('index.php?option=com_plugins');
|
||||
}
|
||||
}
|
||||
|
||||
// Block non-master from viewing or editing MokoWaaS plugin settings
|
||||
if ($option === 'com_plugins')
|
||||
{
|
||||
$view = $app->input->get('view', '');
|
||||
$layout = $app->input->get('layout', '');
|
||||
$extensionId = (int) $app->input->get('extension_id', 0);
|
||||
|
||||
if (($view === 'plugin' || $layout === 'edit') && $extensionId > 0)
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('extension_id') . ' = ' . $extensionId)
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'));
|
||||
|
||||
if ((int) $db->setQuery($query)->loadResult() > 0)
|
||||
{
|
||||
$app->enqueueMessage('MokoWaaS settings are restricted to the master user.', 'warning');
|
||||
$app->redirect('index.php?option=com_plugins');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the protected flag is set on MokoWaaS extensions in the DB.
|
||||
*
|
||||
* Sets protected=1, locked=0 so the extension can't be disabled or
|
||||
* uninstalled but can still receive updates and config changes.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.03.10
|
||||
*/
|
||||
private function ensureProtectedFlag(): void
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Set protected=1, locked=0 on MokoWaaS extensions
|
||||
$query = $db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('protected') . ' = 1')
|
||||
->set($db->quoteName('locked') . ' = 0')
|
||||
->where('(' . $db->quoteName('element') . ' = ' . $db->quote('mokowaas')
|
||||
. ' OR ' . $db->quoteName('element') . ' = ' . $db->quote('pkg_mokowaas') . ')')
|
||||
->where($db->quoteName('protected') . ' = 0');
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
|
||||
// Ensure update site stays enabled (protected extensions get their update site disabled by Joomla)
|
||||
$query = $db->getQuery(true)
|
||||
->update($db->quoteName('#__update_sites') . ' AS us')
|
||||
->join('INNER', $db->quoteName('#__update_sites_extensions') . ' AS use2 ON us.update_site_id = use2.update_site_id')
|
||||
->join('INNER', $db->quoteName('#__extensions') . ' AS e ON use2.extension_id = e.extension_id')
|
||||
->set('us.enabled = 1')
|
||||
->where('us.enabled = 0')
|
||||
->where('(' . $db->quoteName('e.element') . ' = ' . $db->quote('mokowaas')
|
||||
. ' OR ' . $db->quoteName('e.element') . ' = ' . $db->quote('pkg_mokowaas') . ')');
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any of the given extension IDs belong to MokoWaaS.
|
||||
*
|
||||
* @param array $ids Extension IDs to check
|
||||
*
|
||||
* @return bool
|
||||
*
|
||||
* @since 02.03.04
|
||||
*/
|
||||
private function isOurExtension(array $ids): bool
|
||||
{
|
||||
if (empty($ids))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('extension_id') . ' IN (' . implode(',', array_map('intval', $ids)) . ')')
|
||||
->where('(' . $db->quoteName('element') . ' = ' . $db->quote('mokowaas')
|
||||
. ' OR ' . $db->quoteName('element') . ' = ' . $db->quote('pkg_mokowaas') . ')');
|
||||
|
||||
return (int) $db->setQuery($query)->loadResult() > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.32.38</version>
|
||||
<version>02.34.08-dev</version>
|
||||
<description>PLG_SYSTEM_MOKOWAAS_MONITOR_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoWaaSMonitor</namespace>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user