Compare commits
372 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 | |||
| 9cf3b51024 | |||
| 6f762534fe | |||
| c8af0fa5ca | |||
| ac920b997a | |||
| 7e2476b250 | |||
| c5552a94fb | |||
| 8168bfb2dc | |||
| d73b8b06ef | |||
| f3a3bc90b3 | |||
| 756c2bff32 | |||
| 6b195d0514 | |||
| b8fbb0d1d6 | |||
| fd6c79d3a2 | |||
| f350cd0169 | |||
| 4a18318cb9 | |||
| ce04701616 | |||
| b37120341f | |||
| 7f0b7756e4 | |||
| 80c2658b06 | |||
| 995fc4b591 | |||
| 240a947bec | |||
| a2091b1a67 | |||
| fc1f3dd903 | |||
| 4f1b9ac3f2 | |||
| 188defdf1b | |||
| eab0ed1b80 | |||
| 3b972efcdc | |||
| 23d6a1ad44 | |||
| 2706d81267 | |||
| ed138fdc57 | |||
| 2fd3f04f79 | |||
| 883e7c72f0 | |||
| cb33aabb0c | |||
| fe87e9038a | |||
| c4b3892d9c | |||
| adccf3bd2a | |||
| 2b1bbb9c94 | |||
| bb03cd94d6 | |||
| 6ae5daffa2 | |||
| 614b813056 | |||
| 33ce8b115c | |||
| 34cf1235c2 | |||
| cf85a560e4 | |||
| 4684c4a1eb | |||
| e69953ad17 | |||
| b50661d9ee | |||
| a8341d456d | |||
| 1ce287cb2e | |||
| 6798a5da7e | |||
| 7425c412fc | |||
| 7f64651517 | |||
| d49fdd24fc | |||
| db260008a2 | |||
| 8ea724116c | |||
| 94b20b0c54 | |||
| 0e5caf6b3f | |||
| 47db66b70b | |||
| 25e2c29e2e | |||
| b5eebb0acc | |||
| f3d6ef948b | |||
| 1cdbfd035d | |||
| ca9ef82caf | |||
| b7d90f9b18 | |||
| 3be42ec37a | |||
| 9565911089 | |||
| 9a375740b9 | |||
| b7057745a3 | |||
| a89d516623 | |||
| cf39c169d2 | |||
| 1ad1f1c010 | |||
| 1e6a255fab | |||
| a78178b5dd | |||
| 6d3eaa4471 | |||
| 79c3cfc1f0 | |||
| dac5c6c052 | |||
| b4beaf5bc9 | |||
| d563e2eac8 | |||
| 267beea8f9 | |||
| 4237740d32 | |||
| 5a1a2f98b0 | |||
| ed4b06d330 | |||
| 23dc30b5f9 | |||
| af841ace19 | |||
| f79dc2a26e | |||
| 8ce3452125 | |||
| 12e9115a6a | |||
| eeb4822b37 | |||
| 0632981d88 | |||
| a013755ce4 | |||
| 8240e693fb | |||
| 6a02a2b4e5 | |||
| 903999a262 | |||
| 5d94419d9f | |||
| 3d3c918848 | |||
| d0a3b5d6a4 | |||
| 4f2aea75f5 | |||
| 7be52a964e | |||
| d4514aa37d | |||
| 723f25bb59 | |||
| 1522416287 | |||
| 83402f84d5 | |||
| 605d940445 | |||
| 963a1f0c93 | |||
| d32b0d414f | |||
| ce53f7c879 | |||
| 0dd77817df | |||
| 3032bcd418 | |||
| 183c8e6d29 | |||
| c1b587aed4 | |||
| e7979baf76 | |||
| 3850d8636e | |||
| d3daa01667 | |||
| 838820f558 | |||
| a1ab5f512a | |||
| bb3c40594f | |||
| 6fd6acc716 | |||
| 623edf7254 | |||
| 32d5579d56 | |||
| 3605d77135 | |||
| da5ee0a76b | |||
| ebc482cc8f | |||
| 4fe546091f | |||
| 16d3a9b535 | |||
| 23496adb3a | |||
| bca298cbfe | |||
| fe90cfd99f | |||
| 33da807dcc | |||
| 29305f66bf | |||
| d728af427c | |||
| 2ac5d57b75 | |||
| 167b05e75b | |||
| 2546f542e7 | |||
| 885b24bfa9 | |||
| 7fb136b6ef | |||
| 155b8e6d5c | |||
| 8d6026b62a | |||
| 7632acfbd8 | |||
| 7de88eab36 | |||
| 9ce2eb65f1 | |||
| 6d3af46d73 | |||
| a04de05544 | |||
| 5bec1393fc | |||
| 7b8bbf024a | |||
| 78d24d2d15 | |||
| 53a5355600 | |||
| ac753d090f | |||
| 0cfcd8282c | |||
| 0649741a1c | |||
| d9495abab1 | |||
| 2e673f0d55 | |||
| 82aa63edd5 | |||
| da49140bff | |||
| 039ae15559 | |||
| c5b04891ce | |||
| 2261bf6ba3 | |||
| 9f229962e2 | |||
| 681f09f28c | |||
| f21bcdd6bb | |||
| d3ec76dc0f | |||
| e30bdb64cb | |||
| 75f93e43ad | |||
| a5795d381c | |||
| fdc53ce76d | |||
| 1ee52979de | |||
| 1b68e9e478 | |||
| f4085d7ea6 | |||
| 01e11d1600 | |||
| 9d1b6d01a4 | |||
| ad7d974654 | |||
| 7c87c8ec3f | |||
| 801e00e310 | |||
| b6d67ed68a | |||
| 7b34bca639 | |||
| 91140076db | |||
| 94b0e1760b | |||
| 679f5e986d | |||
| 176f67dd30 | |||
| 46275d81a8 | |||
| 75e004b416 | |||
| c96bfaf646 | |||
| 0183c34a0d | |||
| fed6102980 | |||
| e7f03b1bf5 | |||
| 7b5148d089 | |||
| ceb6b8de3d | |||
| 505d9dbdcd |
@@ -122,6 +122,7 @@ build/
|
||||
dist/
|
||||
out/
|
||||
site/
|
||||
!src/packages/*/site/
|
||||
*.map
|
||||
*.css.map
|
||||
*.js.map
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
[submodule "src/packages/tpl_mokoonyx"]
|
||||
path = src/packages/tpl_mokoonyx
|
||||
url = https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx.git
|
||||
branch = main
|
||||
@@ -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.03</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: |
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/universal/auto-release.yml.template
|
||||
# VERSION: 09.23.00
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: Universal build & release � detects platform from manifest.xml
|
||||
#
|
||||
# +========================================================================+
|
||||
@@ -74,17 +74,17 @@ 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" \
|
||||
/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 /tmp/moko-platform-api/cli/branch_rename.php \
|
||||
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}" \
|
||||
@@ -100,15 +100,16 @@ jobs:
|
||||
|
||||
- name: Publish RC release
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/release_publish.php \
|
||||
php ${MOKO_CLI}/release_publish.php \
|
||||
--path . --stability rc --bump minor --branch rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
--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 + lesser stream releases built, updates.xml synced" >> $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:
|
||||
@@ -131,30 +132,75 @@ jobs:
|
||||
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
|
||||
|
||||
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
|
||||
|
||||
- name: "Publish stable release"
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/release_publish.php \
|
||||
php ${MOKO_CLI}/release_publish.php \
|
||||
--path . --stability stable --bump minor --branch main \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
--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"
|
||||
@@ -167,7 +213,7 @@ jobs:
|
||||
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 \
|
||||
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" \
|
||||
@@ -198,7 +244,7 @@ jobs:
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# Delete rc branch (ephemeral — created by promote-rc)
|
||||
# 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"
|
||||
@@ -241,7 +287,7 @@ jobs:
|
||||
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 \
|
||||
php ${MOKO_CLI}/version_reset_dev.php \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
|
||||
--branch dev --path . 2>&1 || true
|
||||
|
||||
@@ -255,7 +301,7 @@ jobs:
|
||||
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
|
||||
echo "## Already Released - ${VERSION}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Automation
|
||||
# VERSION: 02.32.03
|
||||
# VERSION: 02.34.08
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
+532
-236
@@ -1,236 +1,532 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.CI
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/universal/pr-check.yml.template
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: PR gate — branch policy + code validation before merge
|
||||
|
||||
name: "Universal: PR Check"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
# ── Branch Policy ──────────────────────────────────────────────────────
|
||||
branch-policy:
|
||||
name: Branch Policy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check branch merge target
|
||||
run: |
|
||||
HEAD="${{ github.head_ref }}"
|
||||
BASE="${{ github.base_ref }}"
|
||||
|
||||
echo "PR: ${HEAD} → ${BASE}"
|
||||
|
||||
ALLOWED=true
|
||||
REASON=""
|
||||
|
||||
case "$HEAD" in
|
||||
feature/*|feat/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Feature branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
fix/*|bugfix/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Fix branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
patch/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
hotfix/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
rc)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="RC branch can only merge into 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
dev)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Dev branch can only merge into 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$ALLOWED" = false ]; then
|
||||
echo "::error::${REASON}"
|
||||
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
||||
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Code Validation ────────────────────────────────────────────────────
|
||||
validate:
|
||||
name: Validate PR
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
# Read platform from XML manifest (<platform> tag) or plain text fallback
|
||||
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
|
||||
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
|
||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup PHP
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
run: |
|
||||
if ! command -v php &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
- name: PHP syntax check
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
run: |
|
||||
ERRORS=0
|
||||
while IFS= read -r -d '' file; do
|
||||
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
|
||||
echo "PHP lint: ${ERRORS} error(s)"
|
||||
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
|
||||
|
||||
- name: Validate platform manifest
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "::warning::No Joomla manifest found (WaaS site)"
|
||||
exit 0
|
||||
fi
|
||||
echo "Manifest: ${MANIFEST}"
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
|
||||
fi
|
||||
for ELEMENT in name version description; do
|
||||
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
|
||||
done
|
||||
echo "Joomla manifest valid"
|
||||
;;
|
||||
dolibarr)
|
||||
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MOD_FILE" ]; then
|
||||
echo "::error::No mod*.class.php found"
|
||||
exit 1
|
||||
fi
|
||||
echo "Dolibarr module: ${MOD_FILE}"
|
||||
;;
|
||||
*)
|
||||
echo "Generic platform — no manifest validation"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check update stream format
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
if [ -f "updates.xml" ]; then
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
|
||||
fi
|
||||
echo "updates.xml valid"
|
||||
fi
|
||||
;;
|
||||
dolibarr)
|
||||
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check changelog has unreleased entry
|
||||
run: |
|
||||
if [ ! -f "CHANGELOG.md" ]; then
|
||||
echo "::warning::No CHANGELOG.md found"
|
||||
exit 0
|
||||
fi
|
||||
# Check for content under [Unreleased] section
|
||||
if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then
|
||||
echo "::error::CHANGELOG.md missing [Unreleased] section"
|
||||
exit 1
|
||||
fi
|
||||
# Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased
|
||||
UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true)
|
||||
if [ "$UNRELEASED_CONTENT" -eq 0 ]; then
|
||||
echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes."
|
||||
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY
|
||||
echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]"
|
||||
|
||||
- name: Verify package source
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
if [ ! -d "$SOURCE_DIR" ]; then
|
||||
echo "::warning::No src/ or htdocs/ directory"
|
||||
exit 0
|
||||
fi
|
||||
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
|
||||
echo "Source: ${FILE_COUNT} files"
|
||||
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
|
||||
|
||||
# ── Pre-Release RC Build ─────────────────────────────────────────────────
|
||||
pre-release:
|
||||
name: Build RC Package
|
||||
runs-on: ubuntu-latest
|
||||
needs: [branch-policy, validate]
|
||||
|
||||
steps:
|
||||
- name: Trigger RC pre-release
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
BRANCH: ${{ github.head_ref }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
|
||||
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.CI
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/universal/pr-check.yml.template
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: PR gate — branch policy + code validation before merge
|
||||
|
||||
name: "Universal: PR Check"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
# ── Branch Policy ──────────────────────────────────────────────────────
|
||||
branch-policy:
|
||||
name: Branch Policy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check branch merge target
|
||||
run: |
|
||||
HEAD="${{ github.head_ref }}"
|
||||
BASE="${{ github.base_ref }}"
|
||||
|
||||
echo "PR: ${HEAD} → ${BASE}"
|
||||
|
||||
ALLOWED=true
|
||||
REASON=""
|
||||
|
||||
case "$HEAD" in
|
||||
feature/*|feat/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Feature branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
fix/*|bugfix/*)
|
||||
if [ "$BASE" != "dev" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Fix branches must target 'dev', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
patch/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
hotfix/*)
|
||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
rc)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="RC branch can only merge into 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
dev)
|
||||
if [ "$BASE" != "main" ]; then
|
||||
ALLOWED=false
|
||||
REASON="Dev branch can only merge into 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$ALLOWED" = false ]; then
|
||||
echo "::error::${REASON}"
|
||||
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
||||
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Code Validation ────────────────────────────────────────────────────
|
||||
validate:
|
||||
name: Validate PR
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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 in source files"
|
||||
echo "## Conflict Markers Found" >> $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: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
# Read platform from XML manifest (<platform> tag) or plain text fallback
|
||||
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
|
||||
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
|
||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup PHP
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
run: |
|
||||
if ! command -v php &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
- name: PHP syntax check
|
||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||
run: |
|
||||
ERRORS=0
|
||||
while IFS= read -r -d '' file; do
|
||||
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
|
||||
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 }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "::warning::No Joomla manifest found (WaaS site)"
|
||||
exit 0
|
||||
fi
|
||||
echo "Manifest: ${MANIFEST}"
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
|
||||
fi
|
||||
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)
|
||||
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MOD_FILE" ]; then
|
||||
echo "::error::No mod*.class.php found"
|
||||
exit 1
|
||||
fi
|
||||
echo "Dolibarr module: ${MOD_FILE}"
|
||||
;;
|
||||
*)
|
||||
echo "Generic platform — no manifest validation"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check update stream format
|
||||
run: |
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
if [ -f "updates.xml" ]; then
|
||||
if command -v php &> /dev/null; then
|
||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
|
||||
fi
|
||||
echo "updates.xml valid"
|
||||
fi
|
||||
;;
|
||||
dolibarr)
|
||||
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
|
||||
;;
|
||||
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
|
||||
echo "::warning::No CHANGELOG.md found"
|
||||
exit 0
|
||||
fi
|
||||
# Check for content under [Unreleased] section
|
||||
if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then
|
||||
echo "::error::CHANGELOG.md missing [Unreleased] section"
|
||||
exit 1
|
||||
fi
|
||||
# Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased
|
||||
UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true)
|
||||
if [ "$UNRELEASED_CONTENT" -eq 0 ]; then
|
||||
echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes."
|
||||
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY
|
||||
echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]"
|
||||
|
||||
- name: Verify package source
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
if [ ! -d "$SOURCE_DIR" ]; then
|
||||
echo "::warning::No src/ or htdocs/ directory"
|
||||
exit 0
|
||||
fi
|
||||
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
|
||||
echo "Source: ${FILE_COUNT} files"
|
||||
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
|
||||
|
||||
# ── Pre-Release RC Build ─────────────────────────────────────────────────
|
||||
pre-release:
|
||||
name: Build RC Package
|
||||
runs-on: ubuntu-latest
|
||||
needs: [branch-policy, validate]
|
||||
|
||||
steps:
|
||||
- name: Trigger RC pre-release
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
BRANCH: ${{ github.head_ref }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
|
||||
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Issue Reporter ──────────────────────────────────────────────────────
|
||||
report-issues:
|
||||
name: Report Issues
|
||||
runs-on: ubuntu-latest
|
||||
needs: [branch-policy, validate]
|
||||
if: >-
|
||||
always() &&
|
||||
needs.validate.result == 'failure'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: automation/ci-issue-reporter.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: "File issue for PR validation failure"
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
chmod +x automation/ci-issue-reporter.sh
|
||||
./automation/ci-issue-reporter.sh \
|
||||
--gate "PR Validation" \
|
||||
--workflow "PR Check" \
|
||||
--severity error \
|
||||
--details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed."
|
||||
|
||||
@@ -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,6 +56,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
@@ -60,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" \
|
||||
@@ -76,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"
|
||||
@@ -118,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
|
||||
@@ -135,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: |
|
||||
@@ -146,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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.03
|
||||
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.03
|
||||
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.03
|
||||
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.03
|
||||
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.03
|
||||
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.03
|
||||
VERSION: 02.34.08
|
||||
BRIEF: Security vulnerability reporting and handling policy
|
||||
-->
|
||||
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Automation.CI
|
||||
# INGROUP: moko-platform.Automation
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /automation/ci-issue-reporter.sh
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Creates or updates a Gitea issue when a CI gate fails.
|
||||
# Deduplicates by searching open issues with the "ci-auto" label
|
||||
# whose title matches the gate. If a matching issue exists, a comment
|
||||
# is appended instead of opening a duplicate.
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Defaults ────────────────────────────────────────────────────────────────
|
||||
GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}"
|
||||
GITEA_TOKEN="${GITEA_TOKEN:-}"
|
||||
REPO="${GITHUB_REPOSITORY:-}"
|
||||
RUN_URL="${GITHUB_SERVER_URL:-${GITEA_URL}}/${REPO}/actions/runs/${GITHUB_RUN_ID:-0}"
|
||||
LABEL_NAME="ci-auto"
|
||||
LABEL_COLOR="#e11d48"
|
||||
|
||||
GATE=""
|
||||
DETAILS=""
|
||||
SEVERITY="error"
|
||||
WORKFLOW=""
|
||||
|
||||
# ── Parse arguments ─────────────────────────────────────────────────────────
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: ci-issue-reporter.sh --gate NAME --details TEXT [OPTIONS]
|
||||
|
||||
Required:
|
||||
--gate CI gate name (e.g. "Code Quality", "Self-Health")
|
||||
--details Human-readable failure description
|
||||
|
||||
Optional:
|
||||
--severity "error" (default) or "warning"
|
||||
--workflow Workflow name for the issue title
|
||||
--repo owner/repo (default: \$GITHUB_REPOSITORY)
|
||||
--run-url URL to the CI run (auto-detected from env)
|
||||
--token Gitea API token (default: \$GITEA_TOKEN)
|
||||
--url Gitea base URL (default: \$GITEA_URL)
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--gate) GATE="$2"; shift 2 ;;
|
||||
--details) DETAILS="$2"; shift 2 ;;
|
||||
--severity) SEVERITY="$2"; shift 2 ;;
|
||||
--workflow) WORKFLOW="$2"; shift 2 ;;
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--run-url) RUN_URL="$2"; shift 2 ;;
|
||||
--token) GITEA_TOKEN="$2"; shift 2 ;;
|
||||
--url) GITEA_URL="$2"; shift 2 ;;
|
||||
-h|--help) usage ;;
|
||||
*) echo "Unknown option: $1"; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$GATE" ]] && { echo "ERROR: --gate is required"; usage; }
|
||||
[[ -z "$DETAILS" ]] && { echo "ERROR: --details is required"; usage; }
|
||||
[[ -z "$GITEA_TOKEN" ]] && { echo "ERROR: GITEA_TOKEN not set"; exit 1; }
|
||||
[[ -z "$REPO" ]] && { echo "ERROR: GITHUB_REPOSITORY not set"; exit 1; }
|
||||
|
||||
API="${GITEA_URL}/api/v1/repos/${REPO}"
|
||||
|
||||
# ── Build title ─────────────────────────────────────────────────────────────
|
||||
if [[ -n "$WORKFLOW" ]]; then
|
||||
TITLE="[CI] ${WORKFLOW}: ${GATE} failed"
|
||||
else
|
||||
TITLE="[CI] ${GATE} failed"
|
||||
fi
|
||||
|
||||
# ── Ensure label exists ─────────────────────────────────────────────────────
|
||||
ensure_label() {
|
||||
local exists
|
||||
exists=$(curl -sf -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null || echo "000")
|
||||
|
||||
if [[ "$exists" == "200" ]]; then
|
||||
# Check if label already exists
|
||||
local found
|
||||
found=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null \
|
||||
| grep -o "\"name\":\"${LABEL_NAME}\"" || true)
|
||||
|
||||
if [[ -z "$found" ]]; then
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/labels" \
|
||||
-d "{\"name\":\"${LABEL_NAME}\",\"color\":\"${LABEL_COLOR}\",\"description\":\"Auto-created by CI issue reporter\"}" \
|
||||
> /dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Search for existing open issue ──────────────────────────────────────────
|
||||
find_existing_issue() {
|
||||
# URL-encode the gate name for the query
|
||||
local query
|
||||
query=$(printf '%s' "[CI] ${GATE}" | sed 's/ /%20/g; s/\[/%5B/g; s/\]/%5D/g')
|
||||
|
||||
local response
|
||||
response=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/issues?type=issues&state=open&labels=${LABEL_NAME}&q=${query}&limit=5" \
|
||||
2>/dev/null || echo "[]")
|
||||
|
||||
# Extract the first matching issue number
|
||||
echo "$response" \
|
||||
| grep -oP '"number":\s*\K[0-9]+' \
|
||||
| head -1
|
||||
}
|
||||
|
||||
# ── Build issue body ────────────────────────────────────────────────────────
|
||||
build_body() {
|
||||
local severity_badge
|
||||
if [[ "$SEVERITY" == "error" ]]; then
|
||||
severity_badge="**Severity:** Error"
|
||||
else
|
||||
severity_badge="**Severity:** Warning"
|
||||
fi
|
||||
|
||||
cat <<BODY
|
||||
## CI Gate Failure: ${GATE}
|
||||
|
||||
${severity_badge}
|
||||
**Workflow:** ${WORKFLOW:-unknown}
|
||||
**Branch:** ${GITHUB_REF_NAME:-unknown}
|
||||
**Commit:** \`${GITHUB_SHA:0:8}\`
|
||||
**Run:** [View CI run](${RUN_URL})
|
||||
|
||||
### Details
|
||||
|
||||
${DETAILS}
|
||||
|
||||
### Resolution
|
||||
|
||||
Fix the issue described above and push a new commit. This issue will be closed automatically when the gate passes, or can be closed manually.
|
||||
|
||||
---
|
||||
*Auto-created by [ci-issue-reporter](${GITEA_URL}/${REPO}/src/branch/main/automation/ci-issue-reporter.sh)*
|
||||
BODY
|
||||
}
|
||||
|
||||
# ── Build comment body (for existing issues) ────────────────────────────────
|
||||
build_comment() {
|
||||
cat <<COMMENT
|
||||
### CI failure recurrence
|
||||
|
||||
**Branch:** ${GITHUB_REF_NAME:-unknown}
|
||||
**Commit:** \`${GITHUB_SHA:0:8}\`
|
||||
**Run:** [View CI run](${RUN_URL})
|
||||
|
||||
${DETAILS}
|
||||
COMMENT
|
||||
}
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────────────
|
||||
ensure_label
|
||||
|
||||
EXISTING=$(find_existing_issue)
|
||||
|
||||
if [[ -n "$EXISTING" ]]; then
|
||||
# Append comment to existing issue
|
||||
COMMENT_BODY=$(build_comment)
|
||||
COMMENT_JSON=$(printf '%s' "$COMMENT_BODY" | python3 -c "
|
||||
import sys, json
|
||||
print(json.dumps({'body': sys.stdin.read()}))" 2>/dev/null)
|
||||
|
||||
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${EXISTING}/comments" \
|
||||
-d "${COMMENT_JSON}" 2>/dev/null || echo "000")
|
||||
|
||||
if [[ "$HTTP" == "201" ]]; then
|
||||
echo "Commented on existing issue #${EXISTING}"
|
||||
else
|
||||
echo "WARNING: Failed to comment on issue #${EXISTING} (HTTP ${HTTP})"
|
||||
fi
|
||||
else
|
||||
# Create new issue
|
||||
ISSUE_BODY=$(build_body)
|
||||
ISSUE_JSON=$(python3 -c "
|
||||
import sys, json
|
||||
body = sys.stdin.read()
|
||||
print(json.dumps({
|
||||
'title': sys.argv[1],
|
||||
'body': body,
|
||||
'labels': []
|
||||
}))" "$TITLE" <<< "$ISSUE_BODY" 2>/dev/null)
|
||||
|
||||
# Create the issue
|
||||
RESPONSE=$(curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues" \
|
||||
-d "${ISSUE_JSON}" 2>/dev/null || echo "{}")
|
||||
|
||||
ISSUE_NUM=$(echo "$RESPONSE" | grep -oP '"number":\s*\K[0-9]+' | head -1)
|
||||
|
||||
if [[ -n "$ISSUE_NUM" ]]; then
|
||||
# Apply label (separate call — more reliable across Gitea versions)
|
||||
LABEL_ID=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/labels" 2>/dev/null \
|
||||
| grep -oP "\"id\":\s*\K[0-9]+(?=[^}]*\"name\":\s*\"${LABEL_NAME}\")" \
|
||||
| head -1 || true)
|
||||
|
||||
if [[ -n "$LABEL_ID" ]]; then
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/issues/${ISSUE_NUM}/labels" \
|
||||
-d "{\"labels\":[${LABEL_ID}]}" \
|
||||
> /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
echo "Created issue #${ISSUE_NUM}: ${TITLE}"
|
||||
else
|
||||
echo "WARNING: Failed to create issue"
|
||||
echo "Response: ${RESPONSE}"
|
||||
fi
|
||||
fi
|
||||
@@ -11,13 +11,13 @@
|
||||
INGROUP: MokoWaaS.Build
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
FILE: build-guide.md
|
||||
VERSION: 02.32.03
|
||||
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.03)
|
||||
# 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.03
|
||||
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.03)
|
||||
# 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.03
|
||||
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.03)
|
||||
# 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.03
|
||||
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.03)
|
||||
# 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.03
|
||||
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.03)
|
||||
# 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.03
|
||||
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.03)
|
||||
# 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.03
|
||||
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.03)
|
||||
# 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.03
|
||||
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.03)
|
||||
# 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.03
|
||||
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.03)
|
||||
# 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.03
|
||||
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.03)
|
||||
# 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.03
|
||||
VERSION: 02.34.08
|
||||
BRIEF: How this extension's Joomla update server file (update.xml) is managed
|
||||
-->
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<access component="com_mokowaas">
|
||||
<section name="component">
|
||||
<action name="core.admin" title="JACTION_ADMIN" description="JACTION_ADMIN_COMPONENT_DESC" />
|
||||
<action name="core.manage" title="JACTION_MANAGE" description="JACTION_MANAGE_COMPONENT_DESC" />
|
||||
<action name="mokowaas.dashboard" title="COM_MOKOWAAS_ACL_DASHBOARD" description="COM_MOKOWAAS_ACL_DASHBOARD_DESC" />
|
||||
<action name="mokowaas.extensions" title="COM_MOKOWAAS_ACL_EXTENSIONS" description="COM_MOKOWAAS_ACL_EXTENSIONS_DESC" />
|
||||
<action name="mokowaas.htaccess" title="COM_MOKOWAAS_ACL_HTACCESS" description="COM_MOKOWAAS_ACL_HTACCESS_DESC" />
|
||||
<action name="mokowaas.tickets" title="COM_MOKOWAAS_ACL_TICKETS" description="COM_MOKOWAAS_ACL_TICKETS_DESC" />
|
||||
<action name="mokowaas.tickets.create" title="COM_MOKOWAAS_ACL_TICKETS_CREATE" description="COM_MOKOWAAS_ACL_TICKETS_CREATE_DESC" />
|
||||
<action name="mokowaas.tickets.assign" title="COM_MOKOWAAS_ACL_TICKETS_ASSIGN" description="COM_MOKOWAAS_ACL_TICKETS_ASSIGN_DESC" />
|
||||
<action name="mokowaas.plugins.toggle" title="COM_MOKOWAAS_ACL_PLUGINS_TOGGLE" description="COM_MOKOWAAS_ACL_PLUGINS_TOGGLE_DESC" />
|
||||
<action name="mokowaas.cache" title="COM_MOKOWAAS_ACL_CACHE" description="COM_MOKOWAAS_ACL_CACHE_DESC" />
|
||||
</section>
|
||||
</access>
|
||||
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<config>
|
||||
<fieldset name="notifications" label="Email Notifications" description="Configure email recipients for ticket and security notifications.">
|
||||
<field name="admin_emails" type="text" default=""
|
||||
label="Admin Email Addresses"
|
||||
description="Comma-separated email addresses to receive all notifications."
|
||||
hint="admin@example.com, support@example.com" />
|
||||
<field name="admin_user_ids" type="text" default=""
|
||||
label="Admin User IDs"
|
||||
description="Comma-separated Joomla user IDs to receive notifications."
|
||||
hint="320, 321" />
|
||||
<field name="security_alerts" type="radio" default="1"
|
||||
label="Security Alerts"
|
||||
description="Send email alerts for WAF blocks and admin logins."
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="helpdesk" label="Helpdesk Settings" description="Default helpdesk behavior.">
|
||||
<field name="default_category" type="sql" default=""
|
||||
label="Default Ticket Category"
|
||||
description="Category assigned to tickets without a selection."
|
||||
query="SELECT id AS value, title AS text FROM #__mokowaas_ticket_categories WHERE published = 1 ORDER BY ordering" />
|
||||
<field name="autoclose_days" type="number" default="7"
|
||||
label="Auto-Close After (days)"
|
||||
description="Resolved tickets are auto-closed after this many days. 0 = disabled." />
|
||||
<field name="kb_search_enabled" type="radio" default="1"
|
||||
label="KB Search on Ticket Forms"
|
||||
description="Show knowledge base search before ticket submission."
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="permissions" label="COM_MOKOWAAS_ACL_TITLE"
|
||||
description="COM_MOKOWAAS_ACL_DESC">
|
||||
<field name="rules" type="rules"
|
||||
label="COM_MOKOWAAS_ACL_TITLE"
|
||||
validate="rules"
|
||||
filter="rules"
|
||||
component="com_mokowaas"
|
||||
section="component" />
|
||||
</fieldset>
|
||||
</config>
|
||||
@@ -16,3 +16,26 @@ COM_MOKOWAAS_CONFIGURE="Configure"
|
||||
COM_MOKOWAAS_TOGGLE_SUCCESS="Plugin state updated."
|
||||
COM_MOKOWAAS_TOGGLE_FAIL="Failed to update plugin state."
|
||||
COM_MOKOWAAS_CACHE_CLEARED="Cache cleared successfully."
|
||||
COM_MOKOWAAS_EXTENSIONS_TITLE="Moko Extensions"
|
||||
COM_MOKOWAAS_EXTENSIONS_INFO="Install Moko Consulting Joomla packages from the official release server. Updates are handled through Joomla's native System > Update mechanism — each package registers its own update server."
|
||||
COM_MOKOWAAS_EXTENSIONS_LINK="Moko Extensions"
|
||||
COM_MOKOWAAS_HTACCESS_TITLE=".htaccess Maker"
|
||||
COM_MOKOWAAS_TICKETS_TITLE="Helpdesk"
|
||||
|
||||
; ACL
|
||||
COM_MOKOWAAS_ACL_DASHBOARD="View Dashboard"
|
||||
COM_MOKOWAAS_ACL_DASHBOARD_DESC="Allow viewing the MokoWaaS control panel dashboard."
|
||||
COM_MOKOWAAS_ACL_EXTENSIONS="Manage Extensions"
|
||||
COM_MOKOWAAS_ACL_EXTENSIONS_DESC="Allow installing and uninstalling Moko extensions."
|
||||
COM_MOKOWAAS_ACL_HTACCESS="Manage .htaccess"
|
||||
COM_MOKOWAAS_ACL_HTACCESS_DESC="Allow editing and saving the .htaccess configuration."
|
||||
COM_MOKOWAAS_ACL_TICKETS="View Tickets"
|
||||
COM_MOKOWAAS_ACL_TICKETS_DESC="Allow viewing helpdesk tickets."
|
||||
COM_MOKOWAAS_ACL_TICKETS_CREATE="Create Tickets"
|
||||
COM_MOKOWAAS_ACL_TICKETS_CREATE_DESC="Allow creating new helpdesk tickets."
|
||||
COM_MOKOWAAS_ACL_TICKETS_ASSIGN="Assign Tickets"
|
||||
COM_MOKOWAAS_ACL_TICKETS_ASSIGN_DESC="Allow assigning tickets to other users."
|
||||
COM_MOKOWAAS_ACL_PLUGINS_TOGGLE="Toggle Plugins"
|
||||
COM_MOKOWAAS_ACL_PLUGINS_TOGGLE_DESC="Allow enabling and disabling MokoWaaS feature plugins."
|
||||
COM_MOKOWAAS_ACL_CACHE="Clear Cache"
|
||||
COM_MOKOWAAS_ACL_CACHE_DESC="Allow clearing the Joomla cache from the dashboard."
|
||||
|
||||
@@ -5,3 +5,15 @@
|
||||
COM_MOKOWAAS="MokoWaaS"
|
||||
COM_MOKOWAAS_DESCRIPTION="MokoWaaS admin dashboard and REST API. Control panel for managing site features, health monitoring, and remote management."
|
||||
COM_MOKOWAAS_DASHBOARD_TITLE="MokoWaaS Control Panel"
|
||||
COM_MOKOWAAS_MENU_DASHBOARD="Dashboard"
|
||||
COM_MOKOWAAS_MENU_EXTENSIONS="Moko Extensions"
|
||||
COM_MOKOWAAS_MENU_PLUGINS="Feature Plugins"
|
||||
COM_MOKOWAAS_MENU_UPDATES="Joomla Updates"
|
||||
COM_MOKOWAAS_MENU_CHECKIN="Global Check-in"
|
||||
COM_MOKOWAAS_MENU_TICKETS="Helpdesk"
|
||||
COM_MOKOWAAS_MENU_HTACCESS=".htaccess Maker"
|
||||
COM_MOKOWAAS_MENU_PRIVACY="Privacy Guard"
|
||||
COM_MOKOWAAS_MENU_WAFLOG="WAF Log"
|
||||
COM_MOKOWAAS_MENU_DATABASE="Database Tools"
|
||||
COM_MOKOWAAS_MENU_CLEANUP="Cache Cleanup"
|
||||
COM_MOKOWAAS_MENU_CACHE="Cache Management"
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
--
|
||||
-- MokoWaaS Helpdesk Tables
|
||||
--
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokowaas_ticket_categories` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`alias` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`description` TEXT,
|
||||
`auto_assign_user` INT DEFAULT NULL,
|
||||
`sla_response_minutes` INT UNSIGNED NOT NULL DEFAULT 480,
|
||||
`sla_resolution_minutes` INT UNSIGNED NOT NULL DEFAULT 2880,
|
||||
`ordering` INT NOT NULL DEFAULT 0,
|
||||
`published` TINYINT NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_alias` (`alias`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokowaas_tickets` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`subject` VARCHAR(512) NOT NULL,
|
||||
`body` TEXT NOT NULL,
|
||||
`status` ENUM('open','in_progress','waiting','resolved','closed') NOT NULL DEFAULT 'open',
|
||||
`priority` ENUM('low','normal','high','urgent') NOT NULL DEFAULT 'normal',
|
||||
`category_id` INT UNSIGNED DEFAULT NULL,
|
||||
`created_by` INT NOT NULL DEFAULT 0,
|
||||
`assigned_to` INT DEFAULT NULL,
|
||||
`created` DATETIME NOT NULL,
|
||||
`modified` DATETIME DEFAULT NULL,
|
||||
`resolved` DATETIME DEFAULT NULL,
|
||||
`closed` DATETIME DEFAULT NULL,
|
||||
`sla_response_due` DATETIME DEFAULT NULL,
|
||||
`sla_resolution_due` DATETIME DEFAULT NULL,
|
||||
`sla_responded` TINYINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_status` (`status`),
|
||||
KEY `idx_priority` (`priority`),
|
||||
KEY `idx_assigned` (`assigned_to`),
|
||||
KEY `idx_category` (`category_id`),
|
||||
KEY `idx_created` (`created`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokowaas_ticket_replies` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`ticket_id` INT UNSIGNED NOT NULL,
|
||||
`user_id` INT NOT NULL DEFAULT 0,
|
||||
`body` TEXT NOT NULL,
|
||||
`is_internal` TINYINT NOT NULL DEFAULT 0,
|
||||
`created` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_ticket` (`ticket_id`),
|
||||
KEY `idx_created` (`created`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokowaas_ticket_canned` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`body` TEXT NOT NULL,
|
||||
`category_id` INT UNSIGNED DEFAULT NULL,
|
||||
`ordering` INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokowaas_ticket_automation` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`trigger_event` VARCHAR(50) NOT NULL DEFAULT 'ticket_created',
|
||||
`conditions` TEXT NOT NULL DEFAULT '[]',
|
||||
`actions` TEXT NOT NULL DEFAULT '[]',
|
||||
`enabled` TINYINT NOT NULL DEFAULT 1,
|
||||
`ordering` INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Default automation rules
|
||||
INSERT IGNORE INTO `#__mokowaas_ticket_automation` (`id`, `title`, `trigger_event`, `conditions`, `actions`, `enabled`, `ordering`) VALUES
|
||||
(1, 'Auto-close resolved tickets after 7 days', 'scheduled', '[{"field":"status","op":"eq","value":"resolved"},{"field":"age_hours","op":"gt","value":"168"}]', '[{"type":"set_status","value":"closed"},{"type":"add_note","value":"Auto-closed after 7 days with no response."}]', 1, 1),
|
||||
(2, 'Escalate urgent tickets with no response in 1 hour', 'scheduled', '[{"field":"priority","op":"eq","value":"urgent"},{"field":"sla_responded","op":"eq","value":"0"},{"field":"age_hours","op":"gt","value":"1"}]', '[{"type":"add_note","value":"SLA BREACH: Urgent ticket has no staff response after 1 hour."}]', 1, 2),
|
||||
(3, 'Notify on high priority ticket creation', 'ticket_created', '[{"field":"priority","op":"in","value":"high,urgent"}]', '[{"type":"add_note","value":"High/urgent ticket created — requires immediate attention."}]', 1, 3);
|
||||
|
||||
-- Default categories
|
||||
INSERT IGNORE INTO `#__mokowaas_ticket_categories` (`id`, `title`, `alias`, `description`, `sla_response_minutes`, `sla_resolution_minutes`, `ordering`) VALUES
|
||||
(1, 'General Support', 'general-support', 'General questions and assistance', 480, 2880, 1),
|
||||
(2, 'Bug Report', 'bug-report', 'Report a software bug or issue', 240, 1440, 2),
|
||||
(3, 'Feature Request', 'feature-request', 'Request a new feature or enhancement', 1440, 10080, 3),
|
||||
(4, 'Billing', 'billing', 'Billing, invoicing, and payment questions', 240, 1440, 4),
|
||||
(5, 'Urgent / Outage', 'urgent-outage', 'Site down or critical issue', 60, 240, 5);
|
||||
|
||||
--
|
||||
-- Privacy Guard Tables
|
||||
--
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokowaas_consent_log` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT NOT NULL,
|
||||
`category` VARCHAR(50) NOT NULL,
|
||||
`action` ENUM('granted','revoked') NOT NULL,
|
||||
`ip_address` VARCHAR(45) NOT NULL DEFAULT '',
|
||||
`created` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user` (`user_id`),
|
||||
KEY `idx_category` (`category`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokowaas_data_requests` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT NOT NULL,
|
||||
`type` ENUM('export','delete','anonymize') NOT NULL,
|
||||
`status` ENUM('pending','processing','completed','denied') NOT NULL DEFAULT 'pending',
|
||||
`notes` TEXT,
|
||||
`processed_by` INT DEFAULT NULL,
|
||||
`created` DATETIME NOT NULL,
|
||||
`processed` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user` (`user_id`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokowaas_retention_policies` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`content_type` VARCHAR(100) NOT NULL,
|
||||
`retention_days` INT UNSIGNED NOT NULL DEFAULT 365,
|
||||
`action` ENUM('anonymize','delete','archive') NOT NULL DEFAULT 'anonymize',
|
||||
`enabled` TINYINT NOT NULL DEFAULT 1,
|
||||
`description` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Default retention policies
|
||||
INSERT IGNORE INTO `#__mokowaas_retention_policies` (`id`, `content_type`, `retention_days`, `action`, `enabled`, `description`) VALUES
|
||||
(1, 'action_logs', 90, 'delete', 1, 'Delete action log entries older than 90 days'),
|
||||
(2, 'waf_logs', 30, 'delete', 1, 'Delete WAF block logs older than 30 days'),
|
||||
(3, 'sessions', 7, 'delete', 1, 'Purge expired sessions older than 7 days'),
|
||||
(4, 'inactive_users', 730, 'anonymize', 0, 'Anonymize users inactive for 2 years (disabled by default)'),
|
||||
(5, 'closed_tickets', 365, 'anonymize', 0, 'Anonymize closed tickets older than 1 year (disabled by default)');
|
||||
@@ -20,72 +20,687 @@ class DisplayController extends BaseController
|
||||
{
|
||||
protected $default_view = 'dashboard';
|
||||
|
||||
/**
|
||||
* ACL map: view name => required permission.
|
||||
*/
|
||||
private const VIEW_ACL = [
|
||||
'dashboard' => 'mokowaas.dashboard',
|
||||
'extensions' => 'mokowaas.extensions',
|
||||
'htaccess' => 'mokowaas.htaccess',
|
||||
'tickets' => 'mokowaas.tickets',
|
||||
'ticket' => 'mokowaas.tickets',
|
||||
'privacy' => 'core.admin',
|
||||
'waflog' => 'core.admin',
|
||||
'categories' => 'mokowaas.tickets',
|
||||
'canned' => 'mokowaas.tickets',
|
||||
'automation' => 'core.admin',
|
||||
'database' => 'core.admin',
|
||||
'cleanup' => 'mokowaas.cache',
|
||||
];
|
||||
|
||||
public function display($cachable = false, $urlparams = [])
|
||||
{
|
||||
$view = $this->input->get('view', $this->default_view);
|
||||
$acl = self::VIEW_ACL[$view] ?? 'core.manage';
|
||||
|
||||
if (!$this->checkAcl($acl))
|
||||
{
|
||||
Factory::getApplication()->enqueueMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
||||
Factory::getApplication()->redirect(Route::_('index.php', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return parent::display($cachable, $urlparams);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a MokoWaaS feature plugin on or off.
|
||||
*
|
||||
* Expects POST with extension_id and enabled (0 or 1).
|
||||
* Returns JSON response for AJAX calls.
|
||||
*/
|
||||
// ==================================================================
|
||||
// Plugin toggle
|
||||
// ==================================================================
|
||||
|
||||
public function togglePlugin()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('mokowaas.plugins.toggle'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$app = Factory::getApplication();
|
||||
$input = $app->getInput();
|
||||
$model = $this->getModel('Dashboard');
|
||||
|
||||
$user = $app->getIdentity();
|
||||
if (!$user->authorise('core.manage', 'com_plugins'))
|
||||
{
|
||||
$app->setHeader('Content-Type', 'application/json');
|
||||
echo json_encode(['success' => false, 'message' => Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN')]);
|
||||
$app->close();
|
||||
}
|
||||
$result = $model->togglePlugin(
|
||||
$app->getInput()->getInt('extension_id', 0),
|
||||
$app->getInput()->getInt('enabled', 0)
|
||||
);
|
||||
|
||||
$extensionId = $input->getInt('extension_id', 0);
|
||||
$enabled = $input->getInt('enabled', 0);
|
||||
|
||||
if (!$extensionId)
|
||||
{
|
||||
$app->setHeader('Content-Type', 'application/json');
|
||||
echo json_encode(['success' => false, 'message' => 'Missing extension_id']);
|
||||
$app->close();
|
||||
}
|
||||
|
||||
$model = $this->getModel('Dashboard');
|
||||
$result = $model->togglePlugin($extensionId, $enabled);
|
||||
|
||||
$app->setHeader('Content-Type', 'application/json');
|
||||
echo json_encode($result);
|
||||
$app->close();
|
||||
$this->jsonResponse($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the Joomla cache.
|
||||
*/
|
||||
// ==================================================================
|
||||
// Cache
|
||||
// ==================================================================
|
||||
|
||||
public function clearCache()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
$app = Factory::getApplication();
|
||||
$user = $app->getIdentity();
|
||||
|
||||
if (!$user->authorise('core.admin'))
|
||||
if (!$this->checkAcl('mokowaas.cache'))
|
||||
{
|
||||
$app->setHeader('Content-Type', 'application/json');
|
||||
echo json_encode(['success' => false, 'message' => Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN')]);
|
||||
$app->close();
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$model = $this->getModel('Dashboard');
|
||||
$result = $model->clearCache();
|
||||
$this->jsonResponse($this->getModel('Dashboard')->clearCache());
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Extensions
|
||||
// ==================================================================
|
||||
|
||||
public function installExtension()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('mokowaas.extensions'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$downloadUrl = Factory::getApplication()->getInput()->getString('download_url', '');
|
||||
|
||||
if (empty($downloadUrl))
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Missing download URL.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->jsonResponse($this->getModel('Extensions')->installFromUrl($downloadUrl));
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// .htaccess
|
||||
// ==================================================================
|
||||
|
||||
public function saveHtaccess()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('mokowaas.htaccess'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$app = Factory::getApplication();
|
||||
$input = $app->getInput();
|
||||
$model = $this->getModel('Htaccess');
|
||||
|
||||
$options = [];
|
||||
|
||||
foreach ($input->getArray() as $key => $value)
|
||||
{
|
||||
if (str_starts_with($key, 'opt_'))
|
||||
{
|
||||
$options[substr($key, 4)] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($options))
|
||||
{
|
||||
$model->saveOptions($options);
|
||||
}
|
||||
|
||||
$this->jsonResponse($model->saveHtaccess($input->getRaw('content', '')));
|
||||
}
|
||||
|
||||
public function generateHtaccess()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('mokowaas.htaccess'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$model = $this->getModel('Htaccess');
|
||||
$options = Factory::getApplication()->getInput()->getArray();
|
||||
|
||||
$model->saveOptions($options);
|
||||
|
||||
$app = Factory::getApplication();
|
||||
$app->setHeader('Content-Type', 'application/json');
|
||||
echo json_encode($result);
|
||||
echo json_encode([
|
||||
'htaccess' => $model->generateHtaccess($options),
|
||||
'nginx' => $model->generateNginx($options),
|
||||
]);
|
||||
$app->close();
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Tickets
|
||||
// ==================================================================
|
||||
|
||||
public function createTicket()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('mokowaas.tickets.create'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
|
||||
$this->jsonResponse($this->getModel('Tickets')->createTicket([
|
||||
'subject' => $input->getString('subject', ''),
|
||||
'body' => $input->getRaw('body', ''),
|
||||
'priority' => $input->getString('priority', 'normal'),
|
||||
'category_id' => $input->getInt('category_id', 0),
|
||||
]));
|
||||
}
|
||||
|
||||
public function addTicketReply()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('mokowaas.tickets'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
|
||||
$this->jsonResponse($this->getModel('Tickets')->addReply(
|
||||
$input->getInt('ticket_id', 0),
|
||||
$input->getRaw('body', ''),
|
||||
(bool) $input->getInt('is_internal', 0)
|
||||
));
|
||||
}
|
||||
|
||||
public function updateTicketStatus()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('mokowaas.tickets'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
|
||||
$this->jsonResponse($this->getModel('Tickets')->updateStatus(
|
||||
$input->getInt('ticket_id', 0),
|
||||
$input->getString('status', '')
|
||||
));
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// KB Search
|
||||
// ==================================================================
|
||||
|
||||
public function searchKb()
|
||||
{
|
||||
$query = Factory::getApplication()->getInput()->getString('q', '');
|
||||
|
||||
if (strlen($query) < 3)
|
||||
{
|
||||
$this->jsonResponse(['results' => []]);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$escaped = $db->quote('%' . $db->escape($query, true) . '%');
|
||||
|
||||
$results = $db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select([$db->quoteName('l.title'), $db->quoteName('l.url'), $db->quoteName('l.description')])
|
||||
->from($db->quoteName('#__finder_links', 'l'))
|
||||
->where($db->quoteName('l.published') . ' = 1')
|
||||
->where('(' . $db->quoteName('l.title') . ' LIKE ' . $escaped
|
||||
. ' OR ' . $db->quoteName('l.description') . ' LIKE ' . $escaped . ')')
|
||||
->order($db->quoteName('l.title') . ' ASC')
|
||||
->setLimit(8)
|
||||
)->loadObjectList() ?: [];
|
||||
|
||||
foreach ($results as $r)
|
||||
{
|
||||
$r->description = mb_substr(strip_tags($r->description ?? ''), 0, 150);
|
||||
}
|
||||
|
||||
$this->jsonResponse(['results' => $results]);
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$this->jsonResponse(['results' => []]);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Maintenance (#127, #128)
|
||||
// ==================================================================
|
||||
|
||||
public function optimizeDb()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
|
||||
$model = new \Moko\Component\MokoWaaS\Administrator\Model\MaintenanceModel();
|
||||
$this->jsonResponse($model->optimizeTables());
|
||||
}
|
||||
|
||||
public function repairDb()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
|
||||
$model = new \Moko\Component\MokoWaaS\Administrator\Model\MaintenanceModel();
|
||||
$this->jsonResponse($model->repairTables());
|
||||
}
|
||||
|
||||
public function purgeSessions()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
|
||||
$model = new \Moko\Component\MokoWaaS\Administrator\Model\MaintenanceModel();
|
||||
$this->jsonResponse($model->purgeSessions());
|
||||
}
|
||||
|
||||
public function cleanDirectory()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('mokowaas.cache')) { $this->jsonForbidden(); return; }
|
||||
$dirKey = Factory::getApplication()->getInput()->getString('dir_key', '');
|
||||
$model = new \Moko\Component\MokoWaaS\Administrator\Model\MaintenanceModel();
|
||||
$this->jsonResponse($model->cleanDirectory($dirKey));
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Helpdesk CRUD (#137, #138, #139)
|
||||
// ==================================================================
|
||||
|
||||
public function saveCategory()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('mokowaas.tickets')) { $this->jsonForbidden(); }
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$db = Factory::getDbo();
|
||||
$id = $input->getInt('id', 0);
|
||||
$data = (object) [
|
||||
'title' => $input->getString('title', ''),
|
||||
'alias' => \Joomla\CMS\Filter\OutputFilter::stringURLSafe($input->getString('title', '')),
|
||||
'sla_response_minutes' => $input->getInt('sla_response_minutes', 480),
|
||||
'sla_resolution_minutes' => $input->getInt('sla_resolution_minutes', 2880),
|
||||
'auto_assign_user' => $input->getInt('auto_assign_user', 0) ?: null,
|
||||
'published' => $input->getInt('published', 1),
|
||||
];
|
||||
if ($id) {
|
||||
$data->id = $id;
|
||||
$db->updateObject('#__mokowaas_ticket_categories', $data, 'id');
|
||||
} else {
|
||||
$data->ordering = 0;
|
||||
$db->insertObject('#__mokowaas_ticket_categories', $data, 'id');
|
||||
}
|
||||
$this->jsonResponse(['success' => true, 'message' => 'Category saved.', 'id' => (int) $data->id]);
|
||||
}
|
||||
|
||||
public function deleteCategory()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('mokowaas.tickets')) { $this->jsonForbidden(); }
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery($db->getQuery(true)->delete('#__mokowaas_ticket_categories')->where('id = ' . Factory::getApplication()->getInput()->getInt('id', 0)))->execute();
|
||||
$this->jsonResponse(['success' => true, 'message' => 'Category deleted.']);
|
||||
}
|
||||
|
||||
public function saveCanned()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('mokowaas.tickets')) { $this->jsonForbidden(); }
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$db = Factory::getDbo();
|
||||
$data = (object) [
|
||||
'title' => $input->getString('title', ''),
|
||||
'body' => $input->getRaw('body', ''),
|
||||
'category_id' => $input->getInt('category_id', 0) ?: null,
|
||||
'ordering' => 0,
|
||||
];
|
||||
$id = $input->getInt('id', 0);
|
||||
if ($id) { $data->id = $id; $db->updateObject('#__mokowaas_ticket_canned', $data, 'id'); }
|
||||
else { $db->insertObject('#__mokowaas_ticket_canned', $data, 'id'); }
|
||||
$this->jsonResponse(['success' => true, 'message' => 'Canned response saved.', 'id' => (int) $data->id]);
|
||||
}
|
||||
|
||||
public function deleteCanned()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('mokowaas.tickets')) { $this->jsonForbidden(); }
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery($db->getQuery(true)->delete('#__mokowaas_ticket_canned')->where('id = ' . Factory::getApplication()->getInput()->getInt('id', 0)))->execute();
|
||||
$this->jsonResponse(['success' => true, 'message' => 'Canned response deleted.']);
|
||||
}
|
||||
|
||||
public function saveAutomation()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); }
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$db = Factory::getDbo();
|
||||
$data = (object) [
|
||||
'title' => $input->getString('title', ''),
|
||||
'trigger_event' => $input->getString('trigger_event', 'ticket_created'),
|
||||
'conditions' => $input->getRaw('conditions', '[]'),
|
||||
'actions' => $input->getRaw('actions', '[]'),
|
||||
'enabled' => 1,
|
||||
'ordering' => 0,
|
||||
];
|
||||
$id = $input->getInt('id', 0);
|
||||
if ($id) { $data->id = $id; $db->updateObject('#__mokowaas_ticket_automation', $data, 'id'); }
|
||||
else { $db->insertObject('#__mokowaas_ticket_automation', $data, 'id'); }
|
||||
$this->jsonResponse(['success' => true, 'message' => 'Rule saved.', 'id' => (int) $data->id]);
|
||||
}
|
||||
|
||||
public function deleteAutomation()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); }
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery($db->getQuery(true)->delete('#__mokowaas_ticket_automation')->where('id = ' . Factory::getApplication()->getInput()->getInt('id', 0)))->execute();
|
||||
$this->jsonResponse(['success' => true, 'message' => 'Rule deleted.']);
|
||||
}
|
||||
|
||||
public function toggleAutomation()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); }
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery($db->getQuery(true)->update('#__mokowaas_ticket_automation')
|
||||
->set('enabled = ' . $input->getInt('enabled', 0))
|
||||
->where('id = ' . $input->getInt('id', 0)))->execute();
|
||||
$this->jsonResponse(['success' => true, 'message' => 'Rule updated.']);
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Settings Import/Export (#132)
|
||||
// ==================================================================
|
||||
|
||||
public function exportSettings()
|
||||
{
|
||||
Session::checkToken('get') or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('core.admin'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$settings = [];
|
||||
|
||||
// Export all MokoWaaS plugin params
|
||||
$plugins = ['mokowaas', 'mokowaas_firewall', 'mokowaas_tenant', 'mokowaas_devtools', 'mokowaas_offline'];
|
||||
|
||||
foreach ($plugins as $element)
|
||||
{
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote($element))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
|
||||
);
|
||||
$settings['plugins'][$element] = json_decode($db->loadResult() ?? '{}', true);
|
||||
}
|
||||
|
||||
// Export component params
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
|
||||
);
|
||||
$settings['component'] = json_decode($db->loadResult() ?? '{}', true);
|
||||
$settings['exported'] = gmdate('Y-m-d\TH:i:s\Z');
|
||||
$settings['site'] = Factory::getConfig()->get('sitename', '');
|
||||
|
||||
$this->jsonResponse(['success' => true, 'settings' => $settings]);
|
||||
}
|
||||
|
||||
public function importSettings()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('core.admin'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$json = Factory::getApplication()->getInput()->getRaw('settings_json', '');
|
||||
$data = json_decode($json, true);
|
||||
|
||||
if (empty($data) || empty($data['plugins']))
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Invalid settings JSON.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$count = 0;
|
||||
|
||||
foreach ($data['plugins'] ?? [] as $element => $params)
|
||||
{
|
||||
if (!is_array($params))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($params)))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote($element))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
|
||||
)->execute();
|
||||
$count++;
|
||||
}
|
||||
|
||||
if (!empty($data['component']) && is_array($data['component']))
|
||||
{
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($data['component'])))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
|
||||
)->execute();
|
||||
$count++;
|
||||
}
|
||||
|
||||
$this->jsonResponse(['success' => true, 'message' => "Imported settings for {$count} extensions."]);
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// WAF Log
|
||||
// ==================================================================
|
||||
|
||||
public function purgeWafLog()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('core.admin'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$days = Factory::getApplication()->getInput()->getInt('days', 30);
|
||||
$model = new \Moko\Component\MokoWaaS\Administrator\Model\WaflogModel();
|
||||
|
||||
$this->jsonResponse($model->purgeLogs($days));
|
||||
}
|
||||
|
||||
public function banIpFromLog()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('core.admin'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$ip = Factory::getApplication()->getInput()->getString('ip', '');
|
||||
$model = new \Moko\Component\MokoWaaS\Administrator\Model\WaflogModel();
|
||||
|
||||
$this->jsonResponse($model->banIp($ip));
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Privacy Guard
|
||||
// ==================================================================
|
||||
|
||||
public function processDataRequest()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('core.admin'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$model = new \Moko\Component\MokoWaaS\Administrator\Model\PrivacyModel();
|
||||
$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
|
||||
// ==================================================================
|
||||
|
||||
public function importAts()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('mokowaas.tickets'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$this->jsonResponse($this->getModel('Import')->importAts());
|
||||
}
|
||||
|
||||
public function importAdminTools()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('core.admin'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
return;
|
||||
}
|
||||
|
||||
$this->jsonResponse($this->getModel('Import')->importAdminTools());
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Helpers
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* Check a MokoWaaS ACL permission for the current user.
|
||||
*/
|
||||
private function checkAcl(string $action): bool
|
||||
{
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
|
||||
// Super admins always pass
|
||||
if ($user->authorise('core.admin', 'com_mokowaas'))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user->authorise($action, 'com_mokowaas');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JSON response and close.
|
||||
*/
|
||||
private function jsonResponse(array $data): void
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
$app->setHeader('Content-Type', 'application/json');
|
||||
echo json_encode($data);
|
||||
$app->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a 403 JSON response and close.
|
||||
*/
|
||||
private function jsonForbidden(): void
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN')]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,39 +22,60 @@ class DashboardModel extends BaseDatabaseModel
|
||||
*/
|
||||
private const PLUGIN_META = [
|
||||
'mokowaas' => [
|
||||
'icon' => 'icon-shield-alt',
|
||||
'category' => 'core',
|
||||
'label' => 'Core — Branding & Identity',
|
||||
'description' => 'White-label branding, master user enforcement, emergency access, and plugin protection.',
|
||||
'protected' => true,
|
||||
'icon' => 'icon-shield-alt',
|
||||
'category' => 'core',
|
||||
'label' => 'Core — Branding & Identity',
|
||||
'description' => 'White-label branding, master user enforcement, emergency access, and plugin protection.',
|
||||
'protected' => true,
|
||||
'configure_only' => false,
|
||||
],
|
||||
'mokowaas_firewall' => [
|
||||
'icon' => 'icon-lock',
|
||||
'category' => 'security',
|
||||
'label' => 'Firewall',
|
||||
'description' => 'HTTPS enforcement, trusted IPs, session timeout, upload restrictions, and password policy.',
|
||||
'protected' => false,
|
||||
'icon' => 'icon-lock',
|
||||
'category' => 'security',
|
||||
'label' => 'Firewall',
|
||||
'description' => 'Web Application Firewall — SQLi, XSS, RFI, DFI shields, IP blocklist, admin secret URL, file protection.',
|
||||
'protected' => false,
|
||||
'configure_only' => false,
|
||||
],
|
||||
'mokowaas_tenant' => [
|
||||
'icon' => 'icon-users',
|
||||
'category' => 'security',
|
||||
'label' => 'Tenant Restrictions',
|
||||
'description' => 'Installer, sysinfo, config, and template access restrictions for non-master users.',
|
||||
'protected' => false,
|
||||
'icon' => 'icon-users',
|
||||
'category' => 'security',
|
||||
'label' => 'Tenant Restrictions',
|
||||
'description' => 'Installer, sysinfo, config, and template access restrictions for non-master users.',
|
||||
'protected' => false,
|
||||
'configure_only' => false,
|
||||
],
|
||||
'mokowaas_offline' => [
|
||||
'icon' => 'icon-globe',
|
||||
'category' => 'security',
|
||||
'label' => 'Offline Bypass',
|
||||
'description' => 'Keep selected pages (TOS, Privacy Policy) accessible during offline mode.',
|
||||
'protected' => false,
|
||||
'configure_only' => true,
|
||||
],
|
||||
'mokowaas_devtools' => [
|
||||
'icon' => 'icon-wrench',
|
||||
'category' => 'tools',
|
||||
'label' => 'Developer Tools',
|
||||
'description' => 'Dev mode, hit counter reset, content version cleanup.',
|
||||
'protected' => false,
|
||||
'icon' => 'icon-wrench',
|
||||
'category' => 'tools',
|
||||
'label' => 'Developer Tools',
|
||||
'description' => 'Dev mode, hit counter reset, content version cleanup. Features are controlled inside the plugin settings.',
|
||||
'protected' => false,
|
||||
'configure_only' => true,
|
||||
],
|
||||
'mokowaas_monitor' => [
|
||||
'icon' => 'icon-heartbeat',
|
||||
'category' => 'monitoring',
|
||||
'label' => 'Health Monitor',
|
||||
'description' => 'Site health checks, Grafana heartbeat integration, and diagnostics.',
|
||||
'protected' => false,
|
||||
'mokowaasdemo' => [
|
||||
'icon' => 'icon-undo',
|
||||
'category' => 'content',
|
||||
'label' => 'Demo Reset Task',
|
||||
'description' => 'Scheduled demo site reset with content snapshots.',
|
||||
'protected' => false,
|
||||
'configure_only' => true,
|
||||
],
|
||||
'mokowaassync' => [
|
||||
'icon' => 'icon-sync',
|
||||
'category' => 'content',
|
||||
'label' => 'Content Sync Task',
|
||||
'description' => 'Scheduled content synchronisation to remote MokoWaaS sites.',
|
||||
'protected' => false,
|
||||
'configure_only' => true,
|
||||
],
|
||||
];
|
||||
|
||||
@@ -97,7 +118,8 @@ class DashboardModel extends BaseDatabaseModel
|
||||
'(' . $db->quoteName('type') . ' = ' . $db->quote('plugin')
|
||||
. ' AND ' . $db->quoteName('folder') . ' = ' . $db->quote('system')
|
||||
. ' AND (' . $db->quoteName('element') . ' = ' . $db->quote('mokowaas')
|
||||
. ' OR ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mokowaas\_%') . '))'
|
||||
. ' OR ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mokowaas\_%') . ')'
|
||||
. ' AND ' . $db->quoteName('element') . ' != ' . $db->quote('mokowaas_monitor') . ')'
|
||||
// Webservices plugins
|
||||
. ' OR (' . $db->quoteName('type') . ' = ' . $db->quote('plugin')
|
||||
. ' AND ' . $db->quoteName('folder') . ' = ' . $db->quote('webservices')
|
||||
@@ -120,8 +142,10 @@ class DashboardModel extends BaseDatabaseModel
|
||||
$manifest = json_decode($row->manifest_cache ?? '{}');
|
||||
$version = $manifest->version ?? '';
|
||||
|
||||
// Build a lookup key: system plugins use element, others use folder_element
|
||||
$metaKey = $row->element;
|
||||
// Only system plugins and task plugins match PLUGIN_META by element
|
||||
$metaKey = ($row->folder === 'system' || $row->folder === 'task')
|
||||
? $row->element
|
||||
: $row->folder . '_' . $row->element;
|
||||
|
||||
$meta = self::PLUGIN_META[$metaKey] ?? null;
|
||||
|
||||
@@ -135,19 +159,20 @@ class DashboardModel extends BaseDatabaseModel
|
||||
$categoryInfo = self::CATEGORIES[$categoryKey] ?? self::CATEGORIES['tools'];
|
||||
|
||||
$plugins[] = (object) [
|
||||
'extension_id' => (int) $row->extension_id,
|
||||
'name' => $meta['label'] ?? $row->name,
|
||||
'element' => $row->element,
|
||||
'folder' => $row->folder,
|
||||
'type' => $row->type,
|
||||
'enabled' => (int) $row->enabled,
|
||||
'protected' => (int) $row->protected || ($meta['protected'] ?? false),
|
||||
'version' => $version,
|
||||
'icon' => $meta['icon'] ?? 'icon-puzzle-piece',
|
||||
'category' => $categoryKey,
|
||||
'extension_id' => (int) $row->extension_id,
|
||||
'name' => $meta['label'] ?? $row->name,
|
||||
'element' => $row->element,
|
||||
'folder' => $row->folder,
|
||||
'type' => $row->type,
|
||||
'enabled' => (int) $row->enabled,
|
||||
'protected' => (bool) ($meta['protected'] ?? false),
|
||||
'configure_only' => (bool) ($meta['configure_only'] ?? false),
|
||||
'version' => $version,
|
||||
'icon' => $meta['icon'] ?? 'icon-puzzle-piece',
|
||||
'category' => $categoryKey,
|
||||
'categoryLabel' => $categoryInfo['label'],
|
||||
'categoryBadge' => $categoryInfo['badge'],
|
||||
'description' => $meta['description'] ?? '',
|
||||
'description' => $meta['description'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -242,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'))
|
||||
@@ -256,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)
|
||||
{
|
||||
@@ -302,4 +332,204 @@ class DashboardModel extends BaseDatabaseModel
|
||||
|
||||
return $meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent admin login attempts from action logs.
|
||||
*/
|
||||
public function getRecentLogins(int $limit = 10): array
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('a.message'),
|
||||
$db->quoteName('a.log_date'),
|
||||
$db->quoteName('a.ip_address'),
|
||||
$db->quoteName('u.username'),
|
||||
])
|
||||
->from($db->quoteName('#__action_logs', 'a'))
|
||||
->leftJoin($db->quoteName('#__users', 'u') . ' ON ' . $db->quoteName('u.id') . ' = ' . $db->quoteName('a.user_id'))
|
||||
->where($db->quoteName('a.message_language_key') . ' LIKE ' . $db->quote('%LOGIN%'))
|
||||
->order($db->quoteName('a.log_date') . ' DESC')
|
||||
->setLimit($limit);
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending extension updates.
|
||||
*/
|
||||
public function getPendingUpdates(): array
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('u.name'),
|
||||
$db->quoteName('u.version'),
|
||||
$db->quoteName('u.type'),
|
||||
$db->quoteName('e.manifest_cache'),
|
||||
])
|
||||
->from($db->quoteName('#__updates', 'u'))
|
||||
->leftJoin($db->quoteName('#__extensions', 'e') . ' ON ' . $db->quoteName('e.extension_id') . ' = ' . $db->quoteName('u.extension_id'))
|
||||
->where($db->quoteName('u.extension_id') . ' != 0')
|
||||
->order($db->quoteName('u.name') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
$rows = $db->loadObjectList() ?: [];
|
||||
|
||||
foreach ($rows as $row)
|
||||
{
|
||||
$mc = json_decode($row->manifest_cache ?? '{}');
|
||||
$row->current_version = $mc->version ?? '';
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get checked-out items count and details.
|
||||
*/
|
||||
public function getCheckedOutItems(): array
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('c.title'),
|
||||
$db->quoteName('c.checked_out'),
|
||||
$db->quoteName('c.checked_out_time'),
|
||||
$db->quoteName('u.username'),
|
||||
])
|
||||
->from($db->quoteName('#__content', 'c'))
|
||||
->leftJoin($db->quoteName('#__users', 'u') . ' ON ' . $db->quoteName('u.id') . ' = ' . $db->quoteName('c.checked_out'))
|
||||
->where($db->quoteName('c.checked_out') . ' IS NOT NULL')
|
||||
->where($db->quoteName('c.checked_out') . ' != 0')
|
||||
->order($db->quoteName('c.checked_out_time') . ' DESC')
|
||||
->setLimit(10);
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent WAF blocks from the log table.
|
||||
*/
|
||||
public function getRecentWafBlocks(int $limit = 10): array
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokowaas_waf_log'))
|
||||
->order($db->quoteName('created') . ' DESC')
|
||||
->setLimit($limit);
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* Extension manager model — fetches Moko Consulting Joomla packages
|
||||
* from the Gitea API and checks local install status.
|
||||
*
|
||||
* @since 02.32.00
|
||||
*/
|
||||
class ExtensionsModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* Curated catalog of Moko Consulting Joomla packages.
|
||||
* Each entry maps a Gitea repo name to local extension metadata.
|
||||
*/
|
||||
private const CATALOG = [
|
||||
'MokoWaaS' => [
|
||||
'label' => 'MokoWaaS',
|
||||
'description' => 'Admin dashboard, security firewall, tenant restrictions, health monitoring, and REST API.',
|
||||
'element' => 'pkg_mokowaas',
|
||||
'type' => 'package',
|
||||
'icon' => 'icon-shield-alt',
|
||||
'category' => 'Platform',
|
||||
'article' => 'https://mokoconsulting.tech/support/products/mokowaas-platform',
|
||||
'protected' => true,
|
||||
],
|
||||
'MokoOnyx' => [
|
||||
'label' => 'MokoOnyx',
|
||||
'description' => 'Modern Joomla site template with dark mode, custom layouts, and MokoWaaS integration.',
|
||||
'element' => 'mokoonyx',
|
||||
'type' => 'template',
|
||||
'icon' => 'icon-paint-brush',
|
||||
'category' => 'Templates',
|
||||
'article' => 'https://mokoconsulting.tech/support/products/mokoonyx-template',
|
||||
'protected' => false,
|
||||
],
|
||||
'MokoJoomTOS' => [
|
||||
'label' => 'MokoJoomTOS',
|
||||
'description' => 'Terms of Service and privacy policy component with consent tracking.',
|
||||
'element' => 'com_mokojoomtos',
|
||||
'type' => 'component',
|
||||
'icon' => 'icon-file-contract',
|
||||
'category' => 'Components',
|
||||
'article' => 'https://mokoconsulting.tech/support/products/mokojoomtos',
|
||||
'protected' => false,
|
||||
],
|
||||
'MokoJoomHero' => [
|
||||
'label' => 'MokoJoomHero',
|
||||
'description' => 'Random hero image module from a configurable folder.',
|
||||
'element' => 'mod_mokojoomhero',
|
||||
'type' => 'module',
|
||||
'icon' => 'icon-image',
|
||||
'category' => 'Modules',
|
||||
'article' => 'https://mokoconsulting.tech/support/products/mokojoomhero',
|
||||
'protected' => false,
|
||||
],
|
||||
'MokoWaaSAnnounce' => [
|
||||
'label' => 'MokoWaaS Announce',
|
||||
'description' => 'Centralized announcement system via admin module.',
|
||||
'element' => 'mod_mokowaas_announce',
|
||||
'type' => 'module',
|
||||
'icon' => 'icon-bullhorn',
|
||||
'category' => 'Modules',
|
||||
'article' => 'https://mokoconsulting.tech/support/products/mokowaas-announce',
|
||||
'protected' => false,
|
||||
],
|
||||
'MokoDPCalendarAPI' => [
|
||||
'label' => 'DPCalendar API',
|
||||
'description' => 'Web Services plugin exposing DPCalendar events and calendars via REST API.',
|
||||
'element' => 'mokodpcalendarapi',
|
||||
'type' => 'plugin',
|
||||
'icon' => 'icon-calendar',
|
||||
'category' => 'Plugins',
|
||||
'article' => 'https://mokoconsulting.tech/support/products/mokodpcalendarapi',
|
||||
'protected' => false,
|
||||
],
|
||||
'MokoGalleryCalendar' => [
|
||||
'label' => 'Gallery Calendar',
|
||||
'description' => 'JoomGallery and DPCalendar integration — link galleries to events.',
|
||||
'element' => 'mokogallerycalendar',
|
||||
'type' => 'plugin',
|
||||
'icon' => 'icon-images',
|
||||
'category' => 'Plugins',
|
||||
'article' => 'https://mokoconsulting.tech/support/products/mokogallerycalendar',
|
||||
'protected' => false,
|
||||
],
|
||||
'MokoJoomOpenGraph' => [
|
||||
'label' => 'MokoJoomOpenGraph',
|
||||
'description' => 'Open Graph meta tags for articles, categories, and pages. Controls Facebook, Twitter, and LinkedIn link previews.',
|
||||
'element' => 'pkg_mokoog',
|
||||
'type' => 'package',
|
||||
'icon' => 'icon-share-alt',
|
||||
'category' => 'Components',
|
||||
'article' => 'https://mokoconsulting.tech/support/products/mokojoomopengraph',
|
||||
'protected' => false,
|
||||
],
|
||||
];
|
||||
|
||||
private const GITEA_URL = 'https://git.mokoconsulting.tech';
|
||||
private const GITEA_ORG = 'MokoConsulting';
|
||||
|
||||
/**
|
||||
* Get the full catalog with install status and release info.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getCatalog(): array
|
||||
{
|
||||
$installed = $this->getInstalledVersions();
|
||||
$packages = [];
|
||||
|
||||
foreach (self::CATALOG as $repo => $meta)
|
||||
{
|
||||
$release = $this->fetchLatestRelease($repo);
|
||||
|
||||
$localVersion = $installed[$meta['element']] ?? null;
|
||||
$remoteVersion = $release['version'] ?? '';
|
||||
$downloadUrl = $release['download_url'] ?? '';
|
||||
|
||||
$status = ($localVersion !== null) ? 'installed' : 'not_installed';
|
||||
|
||||
// Get extension_id for uninstall link
|
||||
$extensionId = $this->getExtensionId($meta['element']);
|
||||
|
||||
$packages[] = (object) [
|
||||
'repo' => $repo,
|
||||
'label' => $meta['label'],
|
||||
'description' => $meta['description'],
|
||||
'element' => $meta['element'],
|
||||
'type' => $meta['type'],
|
||||
'icon' => $meta['icon'],
|
||||
'category' => $meta['category'],
|
||||
'local_version' => $localVersion ?? '',
|
||||
'remote_version' => $remoteVersion,
|
||||
'download_url' => $downloadUrl,
|
||||
'status' => $status,
|
||||
'article_url' => $meta['article'] ?? '',
|
||||
'protected' => $meta['protected'] ?? false,
|
||||
'extension_id' => $extensionId,
|
||||
];
|
||||
}
|
||||
|
||||
return $packages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install an extension from a remote ZIP URL.
|
||||
*
|
||||
* @param string $url The download URL.
|
||||
*
|
||||
* @return array Result with success, message, and extension info.
|
||||
*/
|
||||
public function installFromUrl(string $url): array
|
||||
{
|
||||
$tmpPath = Factory::getConfig()->get('tmp_path', JPATH_ROOT . '/tmp');
|
||||
$tmpFile = $tmpPath . '/mokowaas_install_' . md5($url) . '.zip';
|
||||
|
||||
try
|
||||
{
|
||||
// Download
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 120);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
$data = curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($error || $code !== 200 || empty($data))
|
||||
{
|
||||
return ['success' => false, 'message' => 'Download failed: ' . ($error ?: "HTTP {$code}")];
|
||||
}
|
||||
|
||||
file_put_contents($tmpFile, $data);
|
||||
|
||||
// Install via Joomla Installer
|
||||
$installer = new \Joomla\CMS\Installer\Installer();
|
||||
$result = $installer->install($tmpFile);
|
||||
|
||||
@unlink($tmpFile);
|
||||
|
||||
if (!$result)
|
||||
{
|
||||
return ['success' => false, 'message' => 'Installation failed.'];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'Installed successfully.',
|
||||
];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
@unlink($tmpFile);
|
||||
|
||||
return ['success' => false, 'message' => 'Error: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get installed versions of all Moko extensions.
|
||||
*
|
||||
* @return array element => version
|
||||
*/
|
||||
private function getInstalledVersions(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$elements = [];
|
||||
|
||||
foreach (self::CATALOG as $meta)
|
||||
{
|
||||
$elements[] = $db->quote($meta['element']);
|
||||
}
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select([$db->quoteName('element'), $db->quoteName('manifest_cache')])
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' IN (' . implode(',', $elements) . ')');
|
||||
$db->setQuery($query);
|
||||
$rows = $db->loadObjectList() ?: [];
|
||||
|
||||
$versions = [];
|
||||
|
||||
foreach ($rows as $row)
|
||||
{
|
||||
$mc = json_decode($row->manifest_cache ?? '{}');
|
||||
$versions[$row->element] = $mc->version ?? '0.0.0';
|
||||
}
|
||||
|
||||
return $versions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the latest release from Gitea for a repo.
|
||||
*
|
||||
* @param string $repo Repository name.
|
||||
*
|
||||
* @return array [version, download_url] or empty.
|
||||
*/
|
||||
private function fetchLatestRelease(string $repo): array
|
||||
{
|
||||
$url = self::GITEA_URL . '/api/v1/repos/' . self::GITEA_ORG . '/' . $repo . '/releases?limit=1';
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Accept: application/json']);
|
||||
$response = curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($code !== 200 || empty($response))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
$releases = json_decode($response, true);
|
||||
|
||||
if (empty($releases[0]))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
$release = $releases[0];
|
||||
$version = $release['tag_name'] ?? '';
|
||||
|
||||
// Find the first .zip asset
|
||||
$downloadUrl = '';
|
||||
|
||||
foreach ($release['assets'] ?? [] as $asset)
|
||||
{
|
||||
if (str_ends_with(strtolower($asset['name'] ?? ''), '.zip'))
|
||||
{
|
||||
$downloadUrl = $asset['browser_download_url'] ?? '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'version' => $version,
|
||||
'download_url' => $downloadUrl,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the extension_id for an element (for uninstall links).
|
||||
*/
|
||||
private function getExtensionId(string $element): int
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('extension_id'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote($element))
|
||||
->setLimit(1);
|
||||
$db->setQuery($query);
|
||||
|
||||
return (int) $db->loadResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
use Joomla\Registry\Registry;
|
||||
|
||||
/**
|
||||
* .htaccess / NginX configuration generator.
|
||||
*
|
||||
* @since 02.32.00
|
||||
*/
|
||||
class HtaccessModel extends BaseDatabaseModel
|
||||
{
|
||||
private const DEFAULTS = [
|
||||
// Security
|
||||
'disable_directory_listing' => 1,
|
||||
'block_sensitive_files' => 1,
|
||||
'block_php_in_uploads' => 1,
|
||||
'disable_server_signature' => 1,
|
||||
'prevent_clickjacking' => 1,
|
||||
'prevent_mime_sniffing' => 1,
|
||||
'xss_protection' => 1,
|
||||
'disable_trace_track' => 1,
|
||||
'referrer_policy' => 'strict-origin-when-cross-origin',
|
||||
'hsts_enabled' => 0,
|
||||
'hsts_max_age' => 31536000,
|
||||
'hsts_subdomains' => 0,
|
||||
'csp_enabled' => 0,
|
||||
'csp_value' => '',
|
||||
'permissions_policy' => 0,
|
||||
'permissions_value' => '',
|
||||
// Performance
|
||||
'enable_gzip' => 1,
|
||||
'enable_expires' => 1,
|
||||
'expires_html' => 3600,
|
||||
'expires_css_js' => 2592000,
|
||||
'expires_images' => 31536000,
|
||||
'etag_control' => 0,
|
||||
// SEO
|
||||
'www_redirect' => 'off',
|
||||
'redirect_index_php' => 1,
|
||||
'force_trailing_slash' => 0,
|
||||
// Custom
|
||||
'custom_rules' => '',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get saved options or defaults.
|
||||
*/
|
||||
public function getOptions(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'));
|
||||
$db->setQuery($query);
|
||||
$params = new Registry($db->loadResult() ?? '{}');
|
||||
|
||||
$htaccess = $params->get('htaccess', null);
|
||||
|
||||
if ($htaccess)
|
||||
{
|
||||
return array_merge(self::DEFAULTS, (array) json_decode(json_encode($htaccess), true));
|
||||
}
|
||||
|
||||
return self::DEFAULTS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save options to component params.
|
||||
*/
|
||||
public function saveOptions(array $options): array
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'));
|
||||
$db->setQuery($query);
|
||||
$params = new Registry($db->loadResult() ?? '{}');
|
||||
|
||||
$clean = [];
|
||||
|
||||
foreach (self::DEFAULTS as $key => $default)
|
||||
{
|
||||
$clean[$key] = $options[$key] ?? $default;
|
||||
}
|
||||
|
||||
$params->set('htaccess', $clean);
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('params') . ' = ' . $db->quote($params->toString()))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
|
||||
)->execute();
|
||||
|
||||
return ['success' => true, 'message' => 'Options saved.'];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return ['success' => false, 'message' => 'Save failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current .htaccess file.
|
||||
*/
|
||||
public function readCurrentHtaccess(): string
|
||||
{
|
||||
$path = JPATH_ROOT . '/.htaccess';
|
||||
|
||||
return file_exists($path) ? file_get_contents($path) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Write .htaccess to disk with backup.
|
||||
*/
|
||||
public function saveHtaccess(string $content): array
|
||||
{
|
||||
$path = JPATH_ROOT . '/.htaccess';
|
||||
$backup = JPATH_ROOT . '/.htaccess.mokowaas.bak';
|
||||
|
||||
try
|
||||
{
|
||||
// Backup existing
|
||||
if (file_exists($path))
|
||||
{
|
||||
copy($path, $backup);
|
||||
}
|
||||
|
||||
$result = file_put_contents($path, $content);
|
||||
|
||||
if ($result === false)
|
||||
{
|
||||
// Restore backup
|
||||
if (file_exists($backup))
|
||||
{
|
||||
copy($backup, $path);
|
||||
}
|
||||
|
||||
return ['success' => false, 'message' => '.htaccess is not writable.'];
|
||||
}
|
||||
|
||||
return ['success' => true, 'message' => '.htaccess saved. Backup at .htaccess.mokowaas.bak'];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
if (file_exists($backup))
|
||||
{
|
||||
@copy($backup, $path);
|
||||
}
|
||||
|
||||
return ['success' => false, 'message' => 'Write failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate .htaccess content from options.
|
||||
*/
|
||||
public function generateHtaccess(array $opts): string
|
||||
{
|
||||
$lines = [];
|
||||
$lines[] = '##';
|
||||
$lines[] = '## MokoWaaS Generated .htaccess';
|
||||
$lines[] = '## Generated: ' . gmdate('Y-m-d H:i:s') . ' UTC';
|
||||
$lines[] = '## DO NOT EDIT — regenerate from MokoWaaS > .htaccess Maker';
|
||||
$lines[] = '##';
|
||||
$lines[] = '';
|
||||
|
||||
// --- Security ---
|
||||
if (!empty($opts['disable_directory_listing']))
|
||||
{
|
||||
$lines[] = '## Disable directory listing';
|
||||
$lines[] = 'Options -Indexes';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
if (!empty($opts['disable_server_signature']))
|
||||
{
|
||||
$lines[] = '## Hide server signature';
|
||||
$lines[] = 'ServerSignature Off';
|
||||
$lines[] = '<IfModule mod_headers.c>';
|
||||
$lines[] = ' Header unset X-Powered-By';
|
||||
$lines[] = ' Header unset Server';
|
||||
$lines[] = '</IfModule>';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
if (!empty($opts['block_sensitive_files']))
|
||||
{
|
||||
$lines[] = '## Block access to sensitive files';
|
||||
$lines[] = '<FilesMatch "^(htaccess\.txt|web\.config\.txt|configuration\.php-dist|README\.txt|LICENSE\.txt|joomla\.xml|robots\.txt\.dist)$">';
|
||||
$lines[] = ' <IfModule mod_authz_core.c>';
|
||||
$lines[] = ' Require all denied';
|
||||
$lines[] = ' </IfModule>';
|
||||
$lines[] = '</FilesMatch>';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
if (!empty($opts['block_php_in_uploads']))
|
||||
{
|
||||
$lines[] = '## Block PHP execution in upload directories';
|
||||
$dirs = ['images', 'media', 'tmp', 'cache', 'logs'];
|
||||
|
||||
foreach ($dirs as $dir)
|
||||
{
|
||||
$lines[] = '<Directory "' . $dir . '">';
|
||||
$lines[] = ' <FilesMatch "\.php$">';
|
||||
$lines[] = ' <IfModule mod_authz_core.c>';
|
||||
$lines[] = ' Require all denied';
|
||||
$lines[] = ' </IfModule>';
|
||||
$lines[] = ' </FilesMatch>';
|
||||
$lines[] = '</Directory>';
|
||||
}
|
||||
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
if (!empty($opts['disable_trace_track']))
|
||||
{
|
||||
$lines[] = '## Disable TRACE and TRACK methods';
|
||||
$lines[] = '<IfModule mod_rewrite.c>';
|
||||
$lines[] = ' RewriteEngine On';
|
||||
$lines[] = ' RewriteCond %{REQUEST_METHOD} ^(TRACE|TRACK)';
|
||||
$lines[] = ' RewriteRule .* - [F]';
|
||||
$lines[] = '</IfModule>';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
// Security headers
|
||||
$headers = [];
|
||||
|
||||
if (!empty($opts['prevent_clickjacking']))
|
||||
{
|
||||
$headers[] = ' Header always set X-Frame-Options "SAMEORIGIN"';
|
||||
}
|
||||
|
||||
if (!empty($opts['prevent_mime_sniffing']))
|
||||
{
|
||||
$headers[] = ' Header always set X-Content-Type-Options "nosniff"';
|
||||
}
|
||||
|
||||
if (!empty($opts['xss_protection']))
|
||||
{
|
||||
$headers[] = ' Header always set X-XSS-Protection "1; mode=block"';
|
||||
}
|
||||
|
||||
$referrer = $opts['referrer_policy'] ?? '';
|
||||
|
||||
if (!empty($referrer) && $referrer !== 'off')
|
||||
{
|
||||
$headers[] = ' Header always set Referrer-Policy "' . $referrer . '"';
|
||||
}
|
||||
|
||||
if (!empty($opts['hsts_enabled']))
|
||||
{
|
||||
$maxAge = (int) ($opts['hsts_max_age'] ?? 31536000);
|
||||
$hsts = 'max-age=' . $maxAge;
|
||||
|
||||
if (!empty($opts['hsts_subdomains']))
|
||||
{
|
||||
$hsts .= '; includeSubDomains';
|
||||
}
|
||||
|
||||
$headers[] = ' Header always set Strict-Transport-Security "' . $hsts . '"';
|
||||
}
|
||||
|
||||
if (!empty($opts['csp_enabled']) && !empty($opts['csp_value']))
|
||||
{
|
||||
$headers[] = ' Header always set Content-Security-Policy "' . str_replace('"', '', $opts['csp_value']) . '"';
|
||||
}
|
||||
|
||||
if (!empty($opts['permissions_policy']) && !empty($opts['permissions_value']))
|
||||
{
|
||||
$headers[] = ' Header always set Permissions-Policy "' . str_replace('"', '', $opts['permissions_value']) . '"';
|
||||
}
|
||||
|
||||
if (!empty($headers))
|
||||
{
|
||||
$lines[] = '## Security headers';
|
||||
$lines[] = '<IfModule mod_headers.c>';
|
||||
$lines = array_merge($lines, $headers);
|
||||
$lines[] = '</IfModule>';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
// --- Performance ---
|
||||
if (!empty($opts['enable_gzip']))
|
||||
{
|
||||
$lines[] = '## GZip compression';
|
||||
$lines[] = '<IfModule mod_deflate.c>';
|
||||
$lines[] = ' AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css';
|
||||
$lines[] = ' AddOutputFilterByType DEFLATE text/javascript application/javascript application/x-javascript';
|
||||
$lines[] = ' AddOutputFilterByType DEFLATE application/json application/xml application/rss+xml';
|
||||
$lines[] = ' AddOutputFilterByType DEFLATE image/svg+xml application/font-woff application/font-woff2';
|
||||
$lines[] = '</IfModule>';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
if (!empty($opts['enable_expires']))
|
||||
{
|
||||
$html = (int) ($opts['expires_html'] ?? 3600);
|
||||
$cssJs = (int) ($opts['expires_css_js'] ?? 2592000);
|
||||
$images = (int) ($opts['expires_images'] ?? 31536000);
|
||||
|
||||
$lines[] = '## Browser caching';
|
||||
$lines[] = '<IfModule mod_expires.c>';
|
||||
$lines[] = ' ExpiresActive On';
|
||||
$lines[] = ' ExpiresDefault "access plus ' . $html . ' seconds"';
|
||||
$lines[] = ' ExpiresByType text/html "access plus ' . $html . ' seconds"';
|
||||
$lines[] = ' ExpiresByType text/css "access plus ' . $cssJs . ' seconds"';
|
||||
$lines[] = ' ExpiresByType text/javascript "access plus ' . $cssJs . ' seconds"';
|
||||
$lines[] = ' ExpiresByType application/javascript "access plus ' . $cssJs . ' seconds"';
|
||||
$lines[] = ' ExpiresByType image/jpeg "access plus ' . $images . ' seconds"';
|
||||
$lines[] = ' ExpiresByType image/png "access plus ' . $images . ' seconds"';
|
||||
$lines[] = ' ExpiresByType image/gif "access plus ' . $images . ' seconds"';
|
||||
$lines[] = ' ExpiresByType image/webp "access plus ' . $images . ' seconds"';
|
||||
$lines[] = ' ExpiresByType image/svg+xml "access plus ' . $images . ' seconds"';
|
||||
$lines[] = ' ExpiresByType font/woff2 "access plus ' . $images . ' seconds"';
|
||||
$lines[] = '</IfModule>';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
if (!empty($opts['etag_control']))
|
||||
{
|
||||
$lines[] = '## Disable ETags (for load-balanced environments)';
|
||||
$lines[] = '<IfModule mod_headers.c>';
|
||||
$lines[] = ' Header unset ETag';
|
||||
$lines[] = '</IfModule>';
|
||||
$lines[] = 'FileETag None';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
// --- SEO / Redirects ---
|
||||
$wwwRedirect = $opts['www_redirect'] ?? 'off';
|
||||
|
||||
if ($wwwRedirect !== 'off' || !empty($opts['redirect_index_php']) || !empty($opts['force_trailing_slash']))
|
||||
{
|
||||
$lines[] = '## SEO redirects';
|
||||
$lines[] = '<IfModule mod_rewrite.c>';
|
||||
$lines[] = ' RewriteEngine On';
|
||||
|
||||
if ($wwwRedirect === 'www')
|
||||
{
|
||||
$lines[] = '';
|
||||
$lines[] = ' ## Force www';
|
||||
$lines[] = ' RewriteCond %{HTTP_HOST} !^www\. [NC]';
|
||||
$lines[] = ' RewriteRule ^(.*)$ https://www.%{HTTP_HOST}/$1 [R=301,L]';
|
||||
}
|
||||
elseif ($wwwRedirect === 'non-www')
|
||||
{
|
||||
$lines[] = '';
|
||||
$lines[] = ' ## Force non-www';
|
||||
$lines[] = ' RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]';
|
||||
$lines[] = ' RewriteRule ^(.*)$ https://%1/$1 [R=301,L]';
|
||||
}
|
||||
|
||||
if (!empty($opts['redirect_index_php']))
|
||||
{
|
||||
$lines[] = '';
|
||||
$lines[] = ' ## Redirect /index.php to root';
|
||||
$lines[] = ' RewriteCond %{THE_REQUEST} ^[A-Z]{3,}\s/+index\.php\s [NC]';
|
||||
$lines[] = ' RewriteRule ^index\.php/?(.*)$ /$1 [R=301,L]';
|
||||
}
|
||||
|
||||
if (!empty($opts['force_trailing_slash']))
|
||||
{
|
||||
$lines[] = '';
|
||||
$lines[] = ' ## Force trailing slash';
|
||||
$lines[] = ' RewriteCond %{REQUEST_FILENAME} !-f';
|
||||
$lines[] = ' RewriteCond %{REQUEST_URI} !(.*)/$';
|
||||
$lines[] = ' RewriteRule ^(.*)$ /$1/ [R=301,L]';
|
||||
}
|
||||
|
||||
$lines[] = '</IfModule>';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
// --- Custom rules ---
|
||||
$custom = trim($opts['custom_rules'] ?? '');
|
||||
|
||||
if (!empty($custom))
|
||||
{
|
||||
$lines[] = '## Custom rules';
|
||||
$lines[] = $custom;
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate equivalent NginX configuration snippet.
|
||||
*/
|
||||
public function generateNginx(array $opts): string
|
||||
{
|
||||
$lines = [];
|
||||
$lines[] = '## MokoWaaS Generated NginX Configuration';
|
||||
$lines[] = '## Add these directives inside your server { } block';
|
||||
$lines[] = '';
|
||||
|
||||
if (!empty($opts['disable_directory_listing']))
|
||||
{
|
||||
$lines[] = '# Disable directory listing';
|
||||
$lines[] = 'autoindex off;';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
if (!empty($opts['disable_server_signature']))
|
||||
{
|
||||
$lines[] = '# Hide server version';
|
||||
$lines[] = 'server_tokens off;';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
if (!empty($opts['block_sensitive_files']))
|
||||
{
|
||||
$lines[] = '# Block sensitive files';
|
||||
$lines[] = 'location ~* (htaccess\.txt|web\.config\.txt|configuration\.php-dist|README\.txt|LICENSE\.txt)$ {';
|
||||
$lines[] = ' deny all;';
|
||||
$lines[] = '}';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
if (!empty($opts['block_php_in_uploads']))
|
||||
{
|
||||
$lines[] = '# Block PHP in upload directories';
|
||||
$lines[] = 'location ~* ^/(images|media|tmp|cache|logs)/.*\.php$ {';
|
||||
$lines[] = ' deny all;';
|
||||
$lines[] = '}';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
// Headers
|
||||
$hdrs = [];
|
||||
|
||||
if (!empty($opts['prevent_clickjacking']))
|
||||
{
|
||||
$hdrs[] = 'add_header X-Frame-Options "SAMEORIGIN" always;';
|
||||
}
|
||||
|
||||
if (!empty($opts['prevent_mime_sniffing']))
|
||||
{
|
||||
$hdrs[] = 'add_header X-Content-Type-Options "nosniff" always;';
|
||||
}
|
||||
|
||||
if (!empty($opts['xss_protection']))
|
||||
{
|
||||
$hdrs[] = 'add_header X-XSS-Protection "1; mode=block" always;';
|
||||
}
|
||||
|
||||
$referrer = $opts['referrer_policy'] ?? '';
|
||||
|
||||
if (!empty($referrer) && $referrer !== 'off')
|
||||
{
|
||||
$hdrs[] = 'add_header Referrer-Policy "' . $referrer . '" always;';
|
||||
}
|
||||
|
||||
if (!empty($opts['hsts_enabled']))
|
||||
{
|
||||
$maxAge = (int) ($opts['hsts_max_age'] ?? 31536000);
|
||||
$hsts = 'max-age=' . $maxAge;
|
||||
|
||||
if (!empty($opts['hsts_subdomains']))
|
||||
{
|
||||
$hsts .= '; includeSubDomains';
|
||||
}
|
||||
|
||||
$hdrs[] = 'add_header Strict-Transport-Security "' . $hsts . '" always;';
|
||||
}
|
||||
|
||||
if (!empty($hdrs))
|
||||
{
|
||||
$lines[] = '# Security headers';
|
||||
$lines = array_merge($lines, $hdrs);
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
if (!empty($opts['enable_gzip']))
|
||||
{
|
||||
$lines[] = '# GZip compression';
|
||||
$lines[] = 'gzip on;';
|
||||
$lines[] = 'gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;';
|
||||
$lines[] = 'gzip_min_length 256;';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
if (!empty($opts['enable_expires']))
|
||||
{
|
||||
$cssJs = (int) ($opts['expires_css_js'] ?? 2592000);
|
||||
$images = (int) ($opts['expires_images'] ?? 31536000);
|
||||
|
||||
$lines[] = '# Browser caching';
|
||||
$lines[] = 'location ~* \.(css|js)$ {';
|
||||
$lines[] = ' expires ' . round($cssJs / 86400) . 'd;';
|
||||
$lines[] = '}';
|
||||
$lines[] = 'location ~* \.(jpg|jpeg|png|gif|webp|svg|ico|woff2)$ {';
|
||||
$lines[] = ' expires ' . round($images / 86400) . 'd;';
|
||||
$lines[] = '}';
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
use Joomla\Registry\Registry;
|
||||
|
||||
/**
|
||||
* Importer for migrating from Akeeba Admin Tools to MokoWaaS.
|
||||
*
|
||||
* Reads Admin Tools WAF config, htaccess settings, IP blocklists,
|
||||
* and security headers — maps them to MokoWaaS firewall plugin params
|
||||
* and htaccess maker options.
|
||||
*
|
||||
* @since 02.32.00
|
||||
*/
|
||||
class ImportModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* Check if Admin Tools data is available for import.
|
||||
* Returns null if already imported or no data found.
|
||||
*/
|
||||
public function checkAdminToolsAvailable(): ?object
|
||||
{
|
||||
if ($this->wasImported('admintools'))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
$db = $this->getDatabase();
|
||||
|
||||
try
|
||||
{
|
||||
$result = (object) [
|
||||
'component' => false,
|
||||
'waf_config' => false,
|
||||
'storage' => false,
|
||||
'ip_blocks' => 0,
|
||||
];
|
||||
|
||||
// Check component
|
||||
$db->setQuery("SELECT COUNT(*) FROM #__extensions WHERE element = 'com_admintools' AND type = 'component'");
|
||||
$result->component = (int) $db->loadResult() > 0;
|
||||
|
||||
// Check WAF config table
|
||||
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_wafconfig%'));
|
||||
|
||||
if ($db->loadResult())
|
||||
{
|
||||
$result->waf_config = true;
|
||||
$db->setQuery('SELECT COUNT(*) FROM #__admintools_wafconfig');
|
||||
$result->waf_settings = (int) $db->loadResult();
|
||||
}
|
||||
|
||||
// Check storage table
|
||||
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_storage%'));
|
||||
|
||||
if ($db->loadResult())
|
||||
{
|
||||
$result->storage = true;
|
||||
}
|
||||
|
||||
// Check IP blocklist
|
||||
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_ipblock%'));
|
||||
|
||||
if ($db->loadResult())
|
||||
{
|
||||
$db->setQuery('SELECT COUNT(*) FROM #__admintools_ipblock');
|
||||
$result->ip_blocks = (int) $db->loadResult();
|
||||
}
|
||||
|
||||
// Only available if at least one data source exists
|
||||
if (!$result->component && !$result->waf_config && !$result->storage)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import Admin Tools settings into MokoWaaS.
|
||||
*/
|
||||
public function importAdminTools(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$results = ['firewall' => 0, 'htaccess' => 0, 'ip_blocks' => 0, 'disabled' => false];
|
||||
|
||||
try
|
||||
{
|
||||
// ============================================================
|
||||
// 1. Import WAF Config → Firewall plugin params
|
||||
// ============================================================
|
||||
$wafSettings = $this->readWafConfig($db);
|
||||
$firewallParams = $this->mapWafToFirewall($wafSettings);
|
||||
|
||||
if (!empty($firewallParams))
|
||||
{
|
||||
$this->mergePluginParams('mokowaas_firewall', 'system', $firewallParams);
|
||||
$results['firewall'] = \count($firewallParams);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 2. Import htaccess settings → component htaccess options
|
||||
// ============================================================
|
||||
$htaccessSettings = $this->readHtaccessConfig($db);
|
||||
$htaccessOptions = $this->mapToHtaccess($htaccessSettings, $wafSettings);
|
||||
|
||||
if (!empty($htaccessOptions))
|
||||
{
|
||||
$this->mergeComponentHtaccessOptions($htaccessOptions);
|
||||
$results['htaccess'] = \count($htaccessOptions);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 3. Import IP blocklist → Firewall IP deny list
|
||||
// ============================================================
|
||||
$ipBlocks = $this->readIpBlocklist($db);
|
||||
|
||||
if (!empty($ipBlocks))
|
||||
{
|
||||
$this->mergeIpBlocklist($ipBlocks);
|
||||
$results['ip_blocks'] = \count($ipBlocks);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 4. Disable Admin Tools
|
||||
// ============================================================
|
||||
$this->disableAdminTools($db);
|
||||
$results['disabled'] = true;
|
||||
|
||||
$this->markImported('admintools');
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => \sprintf(
|
||||
'Imported %d firewall settings, %d htaccess options, %d blocked IPs from Admin Tools. Admin Tools has been disabled.',
|
||||
$results['firewall'], $results['htaccess'], $results['ip_blocks']
|
||||
),
|
||||
'counts' => $results,
|
||||
];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return ['success' => false, 'message' => 'Import failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read WAF config from #__admintools_wafconfig.
|
||||
*/
|
||||
private function readWafConfig($db): array
|
||||
{
|
||||
try
|
||||
{
|
||||
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_wafconfig%'));
|
||||
|
||||
if (!$db->loadResult())
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
$db->setQuery('SELECT * FROM #__admintools_wafconfig');
|
||||
$rows = $db->loadObjectList() ?: [];
|
||||
|
||||
$config = [];
|
||||
|
||||
foreach ($rows as $row)
|
||||
{
|
||||
$key = $row->key ?? $row->option ?? '';
|
||||
|
||||
if (!empty($key))
|
||||
{
|
||||
$config[$key] = $row->value ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read htaccess/server config from #__admintools_storage.
|
||||
*/
|
||||
private function readHtaccessConfig($db): array
|
||||
{
|
||||
try
|
||||
{
|
||||
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_storage%'));
|
||||
|
||||
if (!$db->loadResult())
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
$db->setQuery('SELECT * FROM #__admintools_storage');
|
||||
$rows = $db->loadObjectList() ?: [];
|
||||
|
||||
$config = [];
|
||||
|
||||
foreach ($rows as $row)
|
||||
{
|
||||
$key = $row->key ?? '';
|
||||
|
||||
if (!empty($key))
|
||||
{
|
||||
$config[$key] = $row->value ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read IP blocklist from #__admintools_ipblock.
|
||||
*/
|
||||
private function readIpBlocklist($db): array
|
||||
{
|
||||
try
|
||||
{
|
||||
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_ipblock%'));
|
||||
|
||||
if (!$db->loadResult())
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
$db->setQuery('SELECT ip FROM #__admintools_ipblock');
|
||||
|
||||
return $db->loadColumn() ?: [];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Admin Tools WAF config to MokoWaaS firewall plugin params.
|
||||
*/
|
||||
private function mapWafToFirewall(array $waf): array
|
||||
{
|
||||
$params = [];
|
||||
|
||||
// WAF shields
|
||||
if (isset($waf['sqlishield']))
|
||||
{
|
||||
$params['waf_sqli'] = (int) $waf['sqlishield'] ? 1 : 0;
|
||||
}
|
||||
|
||||
if (isset($waf['antispam']))
|
||||
{
|
||||
$params['waf_xss'] = (int) $waf['antispam'] ? 1 : 0;
|
||||
}
|
||||
|
||||
if (isset($waf['muashield']))
|
||||
{
|
||||
$params['waf_mua'] = (int) $waf['muashield'] ? 1 : 0;
|
||||
}
|
||||
|
||||
if (isset($waf['rfishield']))
|
||||
{
|
||||
$params['waf_rfi'] = (int) $waf['rfishield'] ? 1 : 0;
|
||||
}
|
||||
|
||||
if (isset($waf['dfishield']))
|
||||
{
|
||||
$params['waf_dfi'] = (int) $waf['dfishield'] ? 1 : 0;
|
||||
}
|
||||
|
||||
if (isset($waf['uploadshield']))
|
||||
{
|
||||
// Map to our block_direct_php
|
||||
$params['block_direct_php'] = (int) $waf['uploadshield'] ? 1 : 0;
|
||||
}
|
||||
|
||||
// Admin secret URL
|
||||
if (!empty($waf['adminpw']))
|
||||
{
|
||||
$params['admin_secret'] = $waf['adminpw'];
|
||||
}
|
||||
|
||||
// Block frontend super user login
|
||||
if (isset($waf['nofesalogin']))
|
||||
{
|
||||
$params['block_frontend_superuser'] = (int) $waf['nofesalogin'] ? 1 : 0;
|
||||
}
|
||||
|
||||
// Session timeout
|
||||
if (!empty($waf['sessionshield']) && !empty($waf['session_timeout']))
|
||||
{
|
||||
$params['admin_session_timeout'] = (int) $waf['session_timeout'];
|
||||
}
|
||||
|
||||
// Template switch blocking
|
||||
if (isset($waf['tmpl']))
|
||||
{
|
||||
$params['block_template_switch'] = (int) $waf['tmpl'] ? 1 : 0;
|
||||
}
|
||||
|
||||
// Blocked sensitive files
|
||||
if (isset($waf['hogfiles']))
|
||||
{
|
||||
$params['block_sensitive_files'] = (int) $waf['hogfiles'] ? 1 : 0;
|
||||
}
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Admin Tools config to MokoWaaS htaccess maker options.
|
||||
*/
|
||||
private function mapToHtaccess(array $storage, array $waf): array
|
||||
{
|
||||
$opts = [];
|
||||
|
||||
// Server signature
|
||||
if (isset($waf['serversignature']) || isset($storage['serversignature']))
|
||||
{
|
||||
$opts['disable_server_signature'] = 1;
|
||||
}
|
||||
|
||||
// Clickjacking
|
||||
if (isset($waf['clickjacking']) || isset($storage['xframeoptions']))
|
||||
{
|
||||
$opts['prevent_clickjacking'] = 1;
|
||||
}
|
||||
|
||||
// HSTS
|
||||
if (!empty($storage['hstsheader']) || !empty($waf['hstsheader']))
|
||||
{
|
||||
$opts['hsts_enabled'] = 1;
|
||||
|
||||
if (!empty($storage['hstsmaxage']))
|
||||
{
|
||||
$opts['hsts_max_age'] = (int) $storage['hstsmaxage'];
|
||||
}
|
||||
}
|
||||
|
||||
// GZip
|
||||
if (isset($storage['gzipcompression']))
|
||||
{
|
||||
$opts['enable_gzip'] = (int) $storage['gzipcompression'] ? 1 : 0;
|
||||
}
|
||||
|
||||
// Expiration
|
||||
if (isset($storage['exptime']))
|
||||
{
|
||||
$opts['enable_expires'] = (int) $storage['exptime'] ? 1 : 0;
|
||||
}
|
||||
|
||||
// ETag
|
||||
if (isset($storage['etagtype']))
|
||||
{
|
||||
$opts['etag_control'] = ($storage['etagtype'] === 'none') ? 1 : 0;
|
||||
}
|
||||
|
||||
// Redirect www / non-www
|
||||
if (!empty($storage['wwwredir']))
|
||||
{
|
||||
$map = ['www' => 'www', 'nowww' => 'non-www'];
|
||||
$opts['www_redirect'] = $map[$storage['wwwredir']] ?? 'off';
|
||||
}
|
||||
|
||||
// Directory listing
|
||||
if (isset($storage['nodirlisting']))
|
||||
{
|
||||
$opts['disable_directory_listing'] = (int) $storage['nodirlisting'] ? 1 : 0;
|
||||
}
|
||||
|
||||
// Block PHP in uploads
|
||||
if (isset($storage['phpuploadexec']))
|
||||
{
|
||||
$opts['block_php_in_uploads'] = (int) $storage['phpuploadexec'] ? 1 : 0;
|
||||
}
|
||||
|
||||
// Sensitive files
|
||||
if (isset($storage['hogfiles']))
|
||||
{
|
||||
$opts['block_sensitive_files'] = (int) $storage['hogfiles'] ? 1 : 0;
|
||||
}
|
||||
|
||||
return $opts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge params into a plugin's existing params.
|
||||
*/
|
||||
private function mergePluginParams(string $element, string $folder, array $newParams): void
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote($element))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote($folder));
|
||||
$db->setQuery($query);
|
||||
$current = new Registry($db->loadResult() ?? '{}');
|
||||
|
||||
foreach ($newParams as $key => $value)
|
||||
{
|
||||
$current->set($key, $value);
|
||||
}
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('params') . ' = ' . $db->quote($current->toString()))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote($element))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote($folder))
|
||||
)->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge htaccess options into the component params.
|
||||
*/
|
||||
private function mergeComponentHtaccessOptions(array $options): void
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'));
|
||||
$db->setQuery($query);
|
||||
$params = new Registry($db->loadResult() ?? '{}');
|
||||
|
||||
$htaccess = (array) json_decode(json_encode($params->get('htaccess', new \stdClass())), true);
|
||||
|
||||
foreach ($options as $key => $value)
|
||||
{
|
||||
$htaccess[$key] = $value;
|
||||
}
|
||||
|
||||
$params->set('htaccess', $htaccess);
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('params') . ' = ' . $db->quote($params->toString()))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
|
||||
)->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge imported IPs into the firewall IP blocklist.
|
||||
*/
|
||||
private function mergeIpBlocklist(array $ips): void
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'));
|
||||
$db->setQuery($query);
|
||||
$params = new Registry($db->loadResult() ?? '{}');
|
||||
|
||||
$blocklist = json_decode($params->get('ip_blocklist', '[]'), true) ?: [];
|
||||
|
||||
$existingIps = array_column($blocklist, 'ip');
|
||||
|
||||
foreach ($ips as $ip)
|
||||
{
|
||||
$ip = trim($ip);
|
||||
|
||||
if (empty($ip) || \in_array($ip, $existingIps, true))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$blocklist[] = [
|
||||
'ip' => $ip,
|
||||
'enabled' => '1',
|
||||
'label' => 'Imported from Admin Tools',
|
||||
];
|
||||
}
|
||||
|
||||
$params->set('ip_blocklist', json_encode($blocklist));
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('params') . ' = ' . $db->quote($params->toString()))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
|
||||
)->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable Admin Tools component and plugins.
|
||||
*/
|
||||
private function disableAdminTools($db): void
|
||||
{
|
||||
// Disable component
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('enabled') . ' = 0')
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_admintools'))
|
||||
)->execute();
|
||||
|
||||
// Disable all Admin Tools plugins
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('enabled') . ' = 0')
|
||||
->where($db->quoteName('element') . ' LIKE ' . $db->quote('admintools%'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
)->execute();
|
||||
|
||||
Log::add('Admin Tools component and plugins disabled after MokoWaaS import', Log::INFO, 'mokowaas');
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Akeeba Ticket System Import
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* Check if ATS tables exist.
|
||||
* Returns null if already imported or no data found.
|
||||
*/
|
||||
public function checkAtsAvailable(): ?object
|
||||
{
|
||||
if ($this->wasImported('ats'))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
$db = $this->getDatabase();
|
||||
|
||||
try
|
||||
{
|
||||
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%ats_tickets%'));
|
||||
|
||||
if (!$db->loadResult())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
$db->setQuery('SELECT COUNT(*) FROM #__ats_tickets');
|
||||
$tickets = (int) $db->loadResult();
|
||||
|
||||
$db->setQuery('SELECT COUNT(*) FROM #__ats_posts');
|
||||
$posts = (int) $db->loadResult();
|
||||
|
||||
return (object) ['tickets' => $tickets, 'posts' => $posts];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import from Akeeba Ticket System and disable it.
|
||||
*/
|
||||
public function importAts(): array
|
||||
{
|
||||
// Delegate to TicketsModel for the actual import
|
||||
$ticketsModel = new TicketsModel();
|
||||
$result = $ticketsModel->importFromAts();
|
||||
|
||||
if (!$result['success'])
|
||||
{
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Disable ATS after successful import
|
||||
try
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('enabled') . ' = 0')
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_ats'))
|
||||
)->execute();
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('enabled') . ' = 0')
|
||||
->where($db->quoteName('element') . ' LIKE ' . $db->quote('ats%'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
)->execute();
|
||||
|
||||
$result['message'] .= ' Akeeba Ticket System has been disabled.';
|
||||
Log::add('Akeeba Ticket System disabled after MokoWaaS import', Log::INFO, 'mokowaas');
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$result['message'] .= ' Warning: could not disable ATS: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
$this->markImported('ats');
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Import markers (stored in component params)
|
||||
// ==================================================================
|
||||
|
||||
private function wasImported(string $key): bool
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
|
||||
);
|
||||
$params = new Registry($db->loadResult() ?? '{}');
|
||||
|
||||
return (bool) $params->get('imported_' . $key, false);
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function markImported(string $key): void
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
|
||||
);
|
||||
$params = new Registry($db->loadResult() ?? '{}');
|
||||
$params->set('imported_' . $key, 1);
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('params') . ' = ' . $db->quote($params->toString()))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
|
||||
)->execute();
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Import marker error: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoWaaS\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
|
||||
class MaintenanceModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* Get database table status (size, rows, engine, overhead).
|
||||
*/
|
||||
public function getTableStatus(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$prefix = $db->getPrefix();
|
||||
|
||||
$db->setQuery('SHOW TABLE STATUS');
|
||||
$tables = $db->loadObjectList() ?: [];
|
||||
|
||||
$results = [];
|
||||
$totalSize = 0;
|
||||
$totalOverhead = 0;
|
||||
|
||||
foreach ($tables as $t)
|
||||
{
|
||||
$sizeMb = round(($t->Data_length + $t->Index_length) / 1048576, 2);
|
||||
$overheadKb = round(($t->Data_free ?? 0) / 1024, 1);
|
||||
$totalSize += $sizeMb;
|
||||
$totalOverhead += $overheadKb;
|
||||
|
||||
$results[] = (object) [
|
||||
'name' => $t->Name,
|
||||
'rows' => (int) $t->Rows,
|
||||
'engine' => $t->Engine,
|
||||
'size_mb' => $sizeMb,
|
||||
'overhead_kb' => $overheadKb,
|
||||
'is_moko' => str_contains($t->Name, 'mokowaas'),
|
||||
];
|
||||
}
|
||||
|
||||
usort($results, fn($a, $b) => $b->size_mb <=> $a->size_mb);
|
||||
|
||||
return ['tables' => $results, 'total_size_mb' => round($totalSize, 2), 'total_overhead_kb' => round($totalOverhead, 1), 'count' => \count($results)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize all tables or specific ones.
|
||||
*/
|
||||
public function optimizeTables(array $tableNames = []): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$count = 0;
|
||||
|
||||
try
|
||||
{
|
||||
if (empty($tableNames))
|
||||
{
|
||||
$db->setQuery('SHOW TABLE STATUS WHERE Data_free > 0');
|
||||
$tables = $db->loadObjectList() ?: [];
|
||||
$tableNames = array_column($tables, 'Name');
|
||||
}
|
||||
|
||||
foreach ($tableNames as $name)
|
||||
{
|
||||
$db->setQuery('OPTIMIZE TABLE ' . $db->quoteName($name));
|
||||
$db->execute();
|
||||
$count++;
|
||||
}
|
||||
|
||||
return ['success' => true, 'message' => "Optimized {$count} tables."];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return ['success' => false, 'message' => 'Optimize failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Repair all tables.
|
||||
*/
|
||||
public function repairTables(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
|
||||
try
|
||||
{
|
||||
$db->setQuery('SHOW TABLE STATUS');
|
||||
$tables = $db->loadObjectList() ?: [];
|
||||
$count = 0;
|
||||
|
||||
foreach ($tables as $t)
|
||||
{
|
||||
if ($t->Engine === 'InnoDB' || $t->Engine === 'MyISAM')
|
||||
{
|
||||
$db->setQuery('REPAIR TABLE ' . $db->quoteName($t->Name));
|
||||
$db->execute();
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return ['success' => true, 'message' => "Repaired {$count} tables."];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return ['success' => false, 'message' => 'Repair failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge expired sessions.
|
||||
*/
|
||||
public function purgeSessions(): array
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete($db->quoteName('#__session'))
|
||||
->where($db->quoteName('time') . ' < ' . (time() - 86400))
|
||||
)->execute();
|
||||
|
||||
return ['success' => true, 'message' => 'Expired sessions purged. ' . $db->getAffectedRows() . ' removed.'];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return ['success' => false, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Temp/Cache Cleanup (#128)
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* Get directory sizes for cleanup.
|
||||
*/
|
||||
public function getCleanupInfo(): array
|
||||
{
|
||||
$dirs = [
|
||||
['path' => JPATH_ROOT . '/cache', 'label' => 'Site Cache'],
|
||||
['path' => JPATH_ADMINISTRATOR . '/cache', 'label' => 'Admin Cache'],
|
||||
['path' => JPATH_ROOT . '/tmp', 'label' => 'Temp Directory'],
|
||||
['path' => JPATH_ADMINISTRATOR . '/logs', 'label' => 'Log Files'],
|
||||
];
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($dirs as $dir)
|
||||
{
|
||||
$size = 0;
|
||||
$files = 0;
|
||||
|
||||
if (is_dir($dir['path']))
|
||||
{
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($dir['path'], \RecursiveDirectoryIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
foreach ($iterator as $file)
|
||||
{
|
||||
if ($file->isFile())
|
||||
{
|
||||
$size += $file->getSize();
|
||||
$files++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$results[] = (object) [
|
||||
'label' => $dir['label'],
|
||||
'path' => $dir['path'],
|
||||
'size_mb' => round($size / 1048576, 2),
|
||||
'files' => $files,
|
||||
'writable' => is_writable($dir['path']),
|
||||
];
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean a specific directory.
|
||||
*/
|
||||
public function cleanDirectory(string $dirKey): array
|
||||
{
|
||||
$allowed = [
|
||||
'site_cache' => JPATH_ROOT . '/cache',
|
||||
'admin_cache' => JPATH_ADMINISTRATOR . '/cache',
|
||||
'tmp' => JPATH_ROOT . '/tmp',
|
||||
'logs' => JPATH_ADMINISTRATOR . '/logs',
|
||||
];
|
||||
|
||||
if (!isset($allowed[$dirKey]))
|
||||
{
|
||||
return ['success' => false, 'message' => 'Invalid directory.'];
|
||||
}
|
||||
|
||||
$dir = $allowed[$dirKey];
|
||||
|
||||
if (!is_dir($dir))
|
||||
{
|
||||
return ['success' => false, 'message' => 'Directory not found.'];
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
|
||||
try
|
||||
{
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
|
||||
foreach ($iterator as $item)
|
||||
{
|
||||
// Keep index.html and .htaccess files
|
||||
$name = $item->getFilename();
|
||||
|
||||
if ($name === 'index.html' || $name === '.htaccess')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($item->isDir())
|
||||
{
|
||||
@rmdir($item->getPathname());
|
||||
}
|
||||
else
|
||||
{
|
||||
@unlink($item->getPathname());
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
// Also clear opcache
|
||||
if (\function_exists('opcache_reset'))
|
||||
{
|
||||
\opcache_reset();
|
||||
}
|
||||
|
||||
return ['success' => true, 'message' => "Cleaned {$count} files from {$dirKey}."];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return ['success' => false, 'message' => 'Cleanup failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
|
||||
class PrivacyModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* Get all pending data requests.
|
||||
*/
|
||||
public function getDataRequests(string $filterStatus = ''): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('r') . '.*',
|
||||
$db->quoteName('u.name', 'user_name'),
|
||||
$db->quoteName('u.email', 'user_email'),
|
||||
$db->quoteName('u.username'),
|
||||
$db->quoteName('p.name', 'processed_by_name'),
|
||||
])
|
||||
->from($db->quoteName('#__mokowaas_data_requests', 'r'))
|
||||
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id')
|
||||
->leftJoin($db->quoteName('#__users', 'p') . ' ON p.id = r.processed_by');
|
||||
|
||||
if ($filterStatus)
|
||||
{
|
||||
$query->where($db->quoteName('r.status') . ' = ' . $db->quote($filterStatus));
|
||||
}
|
||||
|
||||
$query->order($db->quoteName('r.created') . ' DESC')->setLimit(50);
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a data request (from admin or user self-service).
|
||||
*/
|
||||
public function createRequest(int $userId, string $type, string $notes = ''): array
|
||||
{
|
||||
$validTypes = ['export', 'delete', 'anonymize'];
|
||||
|
||||
if (!\in_array($type, $validTypes, true))
|
||||
{
|
||||
return ['success' => false, 'message' => 'Invalid request type.'];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$row = (object) [
|
||||
'user_id' => $userId,
|
||||
'type' => $type,
|
||||
'status' => 'pending',
|
||||
'notes' => $notes,
|
||||
'created' => Factory::getDate()->toSql(),
|
||||
];
|
||||
|
||||
$db->insertObject('#__mokowaas_data_requests', $row, 'id');
|
||||
|
||||
return ['success' => true, 'message' => ucfirst($type) . ' request #' . $row->id . ' created.', 'id' => (int) $row->id];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a data request (approve and execute).
|
||||
*/
|
||||
public function processRequest(int $requestId, string $action): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
|
||||
try
|
||||
{
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokowaas_data_requests'))
|
||||
->where($db->quoteName('id') . ' = ' . $requestId)
|
||||
);
|
||||
$request = $db->loadObject();
|
||||
|
||||
if (!$request)
|
||||
{
|
||||
return ['success' => false, 'message' => 'Request not found.'];
|
||||
}
|
||||
|
||||
if ($action === 'deny')
|
||||
{
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__mokowaas_data_requests'))
|
||||
->set($db->quoteName('status') . ' = ' . $db->quote('denied'))
|
||||
->set($db->quoteName('processed_by') . ' = ' . (int) Factory::getApplication()->getIdentity()->id)
|
||||
->set($db->quoteName('processed') . ' = ' . $db->quote(Factory::getDate()->toSql()))
|
||||
->where($db->quoteName('id') . ' = ' . $requestId)
|
||||
)->execute();
|
||||
|
||||
return ['success' => true, 'message' => 'Request denied.'];
|
||||
}
|
||||
|
||||
// Mark as processing
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__mokowaas_data_requests'))
|
||||
->set($db->quoteName('status') . ' = ' . $db->quote('processing'))
|
||||
->where($db->quoteName('id') . ' = ' . $requestId)
|
||||
)->execute();
|
||||
|
||||
// Execute the request
|
||||
$result = null;
|
||||
|
||||
switch ($request->type)
|
||||
{
|
||||
case 'export':
|
||||
$result = $this->exportUserData((int) $request->user_id);
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
$result = $this->deleteUserData((int) $request->user_id);
|
||||
break;
|
||||
|
||||
case 'anonymize':
|
||||
$result = $this->anonymizeUserData((int) $request->user_id);
|
||||
break;
|
||||
}
|
||||
|
||||
// Mark completed
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__mokowaas_data_requests'))
|
||||
->set($db->quoteName('status') . ' = ' . $db->quote('completed'))
|
||||
->set($db->quoteName('processed_by') . ' = ' . (int) Factory::getApplication()->getIdentity()->id)
|
||||
->set($db->quoteName('processed') . ' = ' . $db->quote(Factory::getDate()->toSql()))
|
||||
->where($db->quoteName('id') . ' = ' . $requestId)
|
||||
)->execute();
|
||||
|
||||
return $result ?? ['success' => true, 'message' => 'Request processed.'];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return ['success' => false, 'message' => 'Processing failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export all data for a user as a structured array.
|
||||
*/
|
||||
public function exportUserData(int $userId): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$data = ['user_id' => $userId, 'exported' => gmdate('Y-m-d\TH:i:s\Z')];
|
||||
|
||||
try
|
||||
{
|
||||
// User profile
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select(['id', 'name', 'username', 'email', 'registerDate', 'lastvisitDate', 'params'])
|
||||
->from($db->quoteName('#__users'))
|
||||
->where($db->quoteName('id') . ' = ' . $userId)
|
||||
);
|
||||
$data['profile'] = $db->loadObject();
|
||||
|
||||
// Content (articles)
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select(['id', 'title', 'alias', 'created', 'modified', 'hits'])
|
||||
->from($db->quoteName('#__content'))
|
||||
->where($db->quoteName('created_by') . ' = ' . $userId)
|
||||
);
|
||||
$data['articles'] = $db->loadObjectList() ?: [];
|
||||
|
||||
// Action logs
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select(['message', 'log_date', 'ip_address'])
|
||||
->from($db->quoteName('#__action_logs'))
|
||||
->where($db->quoteName('user_id') . ' = ' . $userId)
|
||||
->order('log_date DESC')
|
||||
->setLimit(100)
|
||||
);
|
||||
$data['action_logs'] = $db->loadObjectList() ?: [];
|
||||
|
||||
// Support tickets
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select(['id', 'subject', 'body', 'status', 'priority', 'created'])
|
||||
->from($db->quoteName('#__mokowaas_tickets'))
|
||||
->where($db->quoteName('created_by') . ' = ' . $userId)
|
||||
);
|
||||
$data['tickets'] = $db->loadObjectList() ?: [];
|
||||
|
||||
// Ticket replies
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select(['r.id', 'r.ticket_id', 'r.body', 'r.created'])
|
||||
->from($db->quoteName('#__mokowaas_ticket_replies', 'r'))
|
||||
->where($db->quoteName('r.user_id') . ' = ' . $userId)
|
||||
);
|
||||
$data['ticket_replies'] = $db->loadObjectList() ?: [];
|
||||
|
||||
// Consent log
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokowaas_consent_log'))
|
||||
->where($db->quoteName('user_id') . ' = ' . $userId)
|
||||
->order('created ASC')
|
||||
);
|
||||
$data['consent_history'] = $db->loadObjectList() ?: [];
|
||||
|
||||
// Community Builder profile (if table exists)
|
||||
try
|
||||
{
|
||||
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%comprofiler%'));
|
||||
|
||||
if ($db->loadResult())
|
||||
{
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__comprofiler'))
|
||||
->where($db->quoteName('user_id') . ' = ' . $userId)
|
||||
);
|
||||
$data['community_builder'] = $db->loadObject();
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e) {}
|
||||
|
||||
return ['success' => true, 'message' => 'Data exported.', 'data' => $data];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return ['success' => false, 'message' => 'Export failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Anonymize a user's data (GDPR right to be forgotten — soft).
|
||||
*/
|
||||
public function anonymizeUserData(int $userId): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$now = Factory::getDate()->toSql();
|
||||
$anon = 'Anonymous User #' . $userId;
|
||||
|
||||
try
|
||||
{
|
||||
// Anonymize user record
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__users'))
|
||||
->set([
|
||||
$db->quoteName('name') . ' = ' . $db->quote($anon),
|
||||
$db->quoteName('username') . ' = ' . $db->quote('anon_' . $userId),
|
||||
$db->quoteName('email') . ' = ' . $db->quote('anon_' . $userId . '@deleted.local'),
|
||||
$db->quoteName('password') . ' = ' . $db->quote(''),
|
||||
$db->quoteName('block') . ' = 1',
|
||||
$db->quoteName('params') . ' = ' . $db->quote('{}'),
|
||||
])
|
||||
->where($db->quoteName('id') . ' = ' . $userId)
|
||||
)->execute();
|
||||
|
||||
// Anonymize article authorship
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__content'))
|
||||
->set($db->quoteName('created_by_alias') . ' = ' . $db->quote($anon))
|
||||
->where($db->quoteName('created_by') . ' = ' . $userId)
|
||||
)->execute();
|
||||
|
||||
// Delete action logs
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete($db->quoteName('#__action_logs'))
|
||||
->where($db->quoteName('user_id') . ' = ' . $userId)
|
||||
)->execute();
|
||||
|
||||
// Anonymize ticket replies
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__mokowaas_ticket_replies'))
|
||||
->set($db->quoteName('body') . ' = ' . $db->quote('[Content removed per data request]'))
|
||||
->where($db->quoteName('user_id') . ' = ' . $userId)
|
||||
)->execute();
|
||||
|
||||
// Community Builder
|
||||
try
|
||||
{
|
||||
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%comprofiler%'));
|
||||
|
||||
if ($db->loadResult())
|
||||
{
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__comprofiler'))
|
||||
->set([
|
||||
$db->quoteName('firstname') . ' = ' . $db->quote('Anonymous'),
|
||||
$db->quoteName('lastname') . ' = ' . $db->quote('User'),
|
||||
$db->quoteName('middlename') . ' = ' . $db->quote(''),
|
||||
])
|
||||
->where($db->quoteName('user_id') . ' = ' . $userId)
|
||||
)->execute();
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e) {}
|
||||
|
||||
// Clear Joomla user profile fields (#7)
|
||||
try
|
||||
{
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete($db->quoteName('#__user_profiles'))
|
||||
->where($db->quoteName('user_id') . ' = ' . $userId)
|
||||
)->execute();
|
||||
}
|
||||
catch (\Throwable $e) {}
|
||||
|
||||
// Clear contact details if linked
|
||||
try
|
||||
{
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete($db->quoteName('#__contact_details'))
|
||||
->where($db->quoteName('user_id') . ' = ' . $userId)
|
||||
)->execute();
|
||||
}
|
||||
catch (\Throwable $e) {}
|
||||
|
||||
// Log the anonymization
|
||||
$this->logConsent($userId, 'account_anonymized', 'granted');
|
||||
|
||||
return ['success' => true, 'message' => 'User #' . $userId . ' data anonymized.'];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return ['success' => false, 'message' => 'Anonymization failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user's data completely (hard delete).
|
||||
*/
|
||||
public function deleteUserData(int $userId): array
|
||||
{
|
||||
$result = $this->anonymizeUserData($userId);
|
||||
|
||||
if (!$result['success'])
|
||||
{
|
||||
return $result;
|
||||
}
|
||||
|
||||
$db = $this->getDatabase();
|
||||
|
||||
try
|
||||
{
|
||||
// Delete tickets and replies
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select($db->quoteName('id'))
|
||||
->from($db->quoteName('#__mokowaas_tickets'))
|
||||
->where($db->quoteName('created_by') . ' = ' . $userId)
|
||||
);
|
||||
$ticketIds = $db->loadColumn() ?: [];
|
||||
|
||||
if (!empty($ticketIds))
|
||||
{
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete($db->quoteName('#__mokowaas_ticket_replies'))
|
||||
->where($db->quoteName('ticket_id') . ' IN (' . implode(',', $ticketIds) . ')')
|
||||
)->execute();
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete($db->quoteName('#__mokowaas_tickets'))
|
||||
->where($db->quoteName('created_by') . ' = ' . $userId)
|
||||
)->execute();
|
||||
}
|
||||
|
||||
// Delete consent log
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete($db->quoteName('#__mokowaas_consent_log'))
|
||||
->where($db->quoteName('user_id') . ' = ' . $userId)
|
||||
)->execute();
|
||||
|
||||
// Delete user record
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete($db->quoteName('#__users'))
|
||||
->where($db->quoteName('id') . ' = ' . $userId)
|
||||
)->execute();
|
||||
|
||||
return ['success' => true, 'message' => 'User #' . $userId . ' data permanently deleted.'];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return ['success' => false, 'message' => 'Deletion failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Consent Management
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* Get consent status for a user.
|
||||
*/
|
||||
public function getUserConsent(int $userId): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokowaas_consent_log'))
|
||||
->where($db->quoteName('user_id') . ' = ' . $userId)
|
||||
->order($db->quoteName('created') . ' DESC')
|
||||
);
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a consent action.
|
||||
*/
|
||||
public function logConsent(int $userId, string $category, string $action): void
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$row = (object) [
|
||||
'user_id' => $userId,
|
||||
'category' => $category,
|
||||
'action' => $action === 'revoked' ? 'revoked' : 'granted',
|
||||
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? '',
|
||||
'created' => Factory::getDate()->toSql(),
|
||||
];
|
||||
$db->insertObject('#__mokowaas_consent_log', $row, 'id');
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Retention Policy Enforcement
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* Get all retention policies.
|
||||
*/
|
||||
public function getRetentionPolicies(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokowaas_retention_policies'))
|
||||
->order($db->quoteName('id') . ' ASC')
|
||||
);
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run retention policy enforcement (called by scheduled task).
|
||||
*/
|
||||
public function enforceRetentionPolicies(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$results = ['policies_run' => 0, 'items_affected' => 0];
|
||||
$policies = $this->getRetentionPolicies();
|
||||
|
||||
foreach ($policies as $policy)
|
||||
{
|
||||
if (!(int) $policy->enabled)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$cutoff = Factory::getDate('-' . (int) $policy->retention_days . ' days')->toSql();
|
||||
$count = 0;
|
||||
|
||||
try
|
||||
{
|
||||
switch ($policy->content_type)
|
||||
{
|
||||
case 'action_logs':
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete($db->quoteName('#__action_logs'))
|
||||
->where($db->quoteName('log_date') . ' < ' . $db->quote($cutoff))
|
||||
)->execute();
|
||||
$count = $db->getAffectedRows();
|
||||
break;
|
||||
|
||||
case 'waf_logs':
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete($db->quoteName('#__mokowaas_waf_log'))
|
||||
->where($db->quoteName('created') . ' < ' . $db->quote($cutoff))
|
||||
)->execute();
|
||||
$count = $db->getAffectedRows();
|
||||
break;
|
||||
|
||||
case 'sessions':
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete($db->quoteName('#__session'))
|
||||
->where($db->quoteName('time') . ' < ' . (int) strtotime($cutoff))
|
||||
)->execute();
|
||||
$count = $db->getAffectedRows();
|
||||
break;
|
||||
|
||||
case 'closed_tickets':
|
||||
if ($policy->action === 'anonymize')
|
||||
{
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__mokowaas_tickets'))
|
||||
->set($db->quoteName('body') . ' = ' . $db->quote('[Removed per retention policy]'))
|
||||
->where($db->quoteName('status') . ' = ' . $db->quote('closed'))
|
||||
->where($db->quoteName('closed') . ' < ' . $db->quote($cutoff))
|
||||
->where($db->quoteName('body') . ' != ' . $db->quote('[Removed per retention policy]'))
|
||||
)->execute();
|
||||
$count = $db->getAffectedRows();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'inactive_users':
|
||||
if ($policy->action === 'anonymize')
|
||||
{
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select($db->quoteName('id'))
|
||||
->from($db->quoteName('#__users'))
|
||||
->where($db->quoteName('lastvisitDate') . ' < ' . $db->quote($cutoff))
|
||||
->where($db->quoteName('lastvisitDate') . ' != ' . $db->quote('0000-00-00 00:00:00'))
|
||||
->where($db->quoteName('block') . ' = 0')
|
||||
->where($db->quoteName('username') . ' NOT LIKE ' . $db->quote('anon_%'))
|
||||
);
|
||||
$userIds = $db->loadColumn() ?: [];
|
||||
|
||||
foreach ($userIds as $uid)
|
||||
{
|
||||
$this->anonymizeUserData((int) $uid);
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if ($count > 0)
|
||||
{
|
||||
$results['policies_run']++;
|
||||
$results['items_affected'] += $count;
|
||||
Log::add(\sprintf('Retention: %s — %d items affected', $policy->content_type, $count), Log::INFO, 'mokowaas');
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Retention policy error (' . $policy->content_type . '): ' . $e->getMessage(), Log::WARNING, 'mokowaas');
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get privacy dashboard summary counts.
|
||||
*/
|
||||
public function getDashboardSummary(): object
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
|
||||
$summary = (object) [
|
||||
'pending_requests' => 0,
|
||||
'total_requests' => 0,
|
||||
'consent_entries' => 0,
|
||||
'policies_active' => 0,
|
||||
];
|
||||
|
||||
try
|
||||
{
|
||||
$db->setQuery('SELECT COUNT(*) FROM #__mokowaas_data_requests WHERE status = ' . $db->quote('pending'));
|
||||
$summary->pending_requests = (int) $db->loadResult();
|
||||
|
||||
$db->setQuery('SELECT COUNT(*) FROM #__mokowaas_data_requests');
|
||||
$summary->total_requests = (int) $db->loadResult();
|
||||
|
||||
$db->setQuery('SELECT COUNT(*) FROM #__mokowaas_consent_log');
|
||||
$summary->consent_entries = (int) $db->loadResult();
|
||||
|
||||
$db->setQuery('SELECT COUNT(*) FROM #__mokowaas_retention_policies WHERE enabled = 1');
|
||||
$summary->policies_active = (int) $db->loadResult();
|
||||
}
|
||||
catch (\Throwable $e) {}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,945 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
use Moko\Component\MokoWaaS\Administrator\Service\NotificationService;
|
||||
|
||||
class TicketsModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* Get ticket list with filters.
|
||||
*/
|
||||
public function getTickets(array $filters = []): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('t.id'),
|
||||
$db->quoteName('t.subject'),
|
||||
$db->quoteName('t.status'),
|
||||
$db->quoteName('t.priority'),
|
||||
$db->quoteName('t.created'),
|
||||
$db->quoteName('t.modified'),
|
||||
$db->quoteName('t.sla_response_due'),
|
||||
$db->quoteName('t.sla_resolution_due'),
|
||||
$db->quoteName('t.sla_responded'),
|
||||
$db->quoteName('c.title', 'category_title'),
|
||||
$db->quoteName('u.name', 'created_by_name'),
|
||||
$db->quoteName('a.name', 'assigned_to_name'),
|
||||
])
|
||||
->from($db->quoteName('#__mokowaas_tickets', 't'))
|
||||
->leftJoin($db->quoteName('#__mokowaas_ticket_categories', 'c') . ' ON c.id = t.category_id')
|
||||
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
|
||||
->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to');
|
||||
|
||||
if (!empty($filters['status']))
|
||||
{
|
||||
$query->where($db->quoteName('t.status') . ' = ' . $db->quote($filters['status']));
|
||||
}
|
||||
|
||||
if (!empty($filters['priority']))
|
||||
{
|
||||
$query->where($db->quoteName('t.priority') . ' = ' . $db->quote($filters['priority']));
|
||||
}
|
||||
|
||||
if (!empty($filters['assigned_to']))
|
||||
{
|
||||
$query->where($db->quoteName('t.assigned_to') . ' = ' . (int) $filters['assigned_to']);
|
||||
}
|
||||
|
||||
if (!empty($filters['category_id']))
|
||||
{
|
||||
$query->where($db->quoteName('t.category_id') . ' = ' . (int) $filters['category_id']);
|
||||
}
|
||||
|
||||
$query->order($db->quoteName('t.created') . ' DESC');
|
||||
$query->setLimit(50);
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single ticket with all replies.
|
||||
*/
|
||||
public function getTicket(int $id): ?object
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('t') . '.*',
|
||||
$db->quoteName('c.title', 'category_title'),
|
||||
$db->quoteName('u.name', 'created_by_name'),
|
||||
$db->quoteName('u.email', 'created_by_email'),
|
||||
$db->quoteName('a.name', 'assigned_to_name'),
|
||||
])
|
||||
->from($db->quoteName('#__mokowaas_tickets', 't'))
|
||||
->leftJoin($db->quoteName('#__mokowaas_ticket_categories', 'c') . ' ON c.id = t.category_id')
|
||||
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
|
||||
->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to')
|
||||
->where($db->quoteName('t.id') . ' = ' . $id);
|
||||
$db->setQuery($query);
|
||||
$ticket = $db->loadObject();
|
||||
|
||||
if (!$ticket)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Load replies
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('r') . '.*',
|
||||
$db->quoteName('u.name', 'user_name'),
|
||||
])
|
||||
->from($db->quoteName('#__mokowaas_ticket_replies', 'r'))
|
||||
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id')
|
||||
->where($db->quoteName('r.ticket_id') . ' = ' . $id)
|
||||
->order($db->quoteName('r.created') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
$ticket->replies = $db->loadObjectList() ?: [];
|
||||
|
||||
// Reply count
|
||||
$ticket->reply_count = \count($ticket->replies);
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new ticket.
|
||||
*/
|
||||
public function createTicket(array $data): array
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
$now = Factory::getDate()->toSql();
|
||||
|
||||
$ticket = (object) [
|
||||
'subject' => $data['subject'] ?? '',
|
||||
'body' => $data['body'] ?? '',
|
||||
'status' => 'open',
|
||||
'priority' => $data['priority'] ?? 'normal',
|
||||
'category_id' => (int) ($data['category_id'] ?? 0) ?: null,
|
||||
'created_by' => $user->id,
|
||||
'assigned_to' => (int) ($data['assigned_to'] ?? 0) ?: null,
|
||||
'created' => $now,
|
||||
'modified' => $now,
|
||||
];
|
||||
|
||||
// Auto-assign from category
|
||||
if (!$ticket->assigned_to && $ticket->category_id)
|
||||
{
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('auto_assign_user'))
|
||||
->from($db->quoteName('#__mokowaas_ticket_categories'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $ticket->category_id);
|
||||
$db->setQuery($query);
|
||||
$autoAssign = (int) $db->loadResult();
|
||||
|
||||
if ($autoAssign)
|
||||
{
|
||||
$ticket->assigned_to = $autoAssign;
|
||||
}
|
||||
}
|
||||
|
||||
// SLA deadlines from category
|
||||
if ($ticket->category_id)
|
||||
{
|
||||
$query = $db->getQuery(true)
|
||||
->select([$db->quoteName('sla_response_minutes'), $db->quoteName('sla_resolution_minutes')])
|
||||
->from($db->quoteName('#__mokowaas_ticket_categories'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $ticket->category_id);
|
||||
$db->setQuery($query);
|
||||
$sla = $db->loadObject();
|
||||
|
||||
if ($sla)
|
||||
{
|
||||
$ticket->sla_response_due = Factory::getDate($now)->modify('+' . (int) $sla->sla_response_minutes . ' minutes')->toSql();
|
||||
$ticket->sla_resolution_due = Factory::getDate($now)->modify('+' . (int) $sla->sla_resolution_minutes . ' minutes')->toSql();
|
||||
}
|
||||
}
|
||||
|
||||
$db->insertObject('#__mokowaas_tickets', $ticket, 'id');
|
||||
|
||||
// Run automation + notifications
|
||||
$this->runAutomation('ticket_created', (int) $ticket->id);
|
||||
NotificationService::notify('ticket_created', $this->getTicket((int) $ticket->id));
|
||||
|
||||
return ['success' => true, 'message' => 'Ticket #' . $ticket->id . ' created.', 'id' => (int) $ticket->id];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a reply to a ticket.
|
||||
*/
|
||||
public function addReply(int $ticketId, string $body, bool $isInternal = false): array
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
$now = Factory::getDate()->toSql();
|
||||
|
||||
$reply = (object) [
|
||||
'ticket_id' => $ticketId,
|
||||
'user_id' => $user->id,
|
||||
'body' => $body,
|
||||
'is_internal' => $isInternal ? 1 : 0,
|
||||
'created' => $now,
|
||||
];
|
||||
|
||||
$db->insertObject('#__mokowaas_ticket_replies', $reply, 'id');
|
||||
|
||||
// Mark SLA as responded only for staff replies (not customer self-replies)
|
||||
$ticket = $this->getTicket($ticketId);
|
||||
$isStaffReply = $ticket && (int) $user->id !== (int) $ticket->created_by;
|
||||
|
||||
$updateQuery = $db->getQuery(true)
|
||||
->update($db->quoteName('#__mokowaas_tickets'))
|
||||
->set($db->quoteName('modified') . ' = ' . $db->quote($now))
|
||||
->where($db->quoteName('id') . ' = ' . $ticketId);
|
||||
|
||||
if ($isStaffReply)
|
||||
{
|
||||
$updateQuery->set($db->quoteName('sla_responded') . ' = 1')
|
||||
->where($db->quoteName('sla_responded') . ' = 0');
|
||||
}
|
||||
|
||||
$db->setQuery($updateQuery)->execute();
|
||||
|
||||
// Run automation + notifications (skip internal notes)
|
||||
$this->runAutomation('ticket_replied', $ticketId);
|
||||
|
||||
if (!$isInternal)
|
||||
{
|
||||
NotificationService::notify('ticket_replied', $this->getTicket($ticketId), ['reply_body' => $body]);
|
||||
}
|
||||
|
||||
return ['success' => true, 'message' => 'Reply added.'];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update ticket status.
|
||||
*/
|
||||
public function updateStatus(int $ticketId, string $status): array
|
||||
{
|
||||
$valid = ['open', 'in_progress', 'waiting', 'resolved', 'closed'];
|
||||
|
||||
if (!\in_array($status, $valid, true))
|
||||
{
|
||||
return ['success' => false, 'message' => 'Invalid status.'];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$now = Factory::getDate()->toSql();
|
||||
|
||||
// Capture old status for notification
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select($db->quoteName('status'))
|
||||
->from($db->quoteName('#__mokowaas_tickets'))
|
||||
->where($db->quoteName('id') . ' = ' . $ticketId)
|
||||
);
|
||||
$oldStatus = $db->loadResult() ?? '';
|
||||
|
||||
$sets = [
|
||||
$db->quoteName('status') . ' = ' . $db->quote($status),
|
||||
$db->quoteName('modified') . ' = ' . $db->quote($now),
|
||||
];
|
||||
|
||||
if ($status === 'resolved')
|
||||
{
|
||||
$sets[] = $db->quoteName('resolved') . ' = ' . $db->quote($now);
|
||||
}
|
||||
|
||||
if ($status === 'closed')
|
||||
{
|
||||
$sets[] = $db->quoteName('closed') . ' = ' . $db->quote($now);
|
||||
}
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__mokowaas_tickets'))
|
||||
->set($sets)
|
||||
->where($db->quoteName('id') . ' = ' . $ticketId)
|
||||
)->execute();
|
||||
|
||||
// Run automation + notifications
|
||||
$this->runAutomation('status_changed', $ticketId);
|
||||
NotificationService::notify('status_changed', $this->getTicket($ticketId), ['old_status' => $oldStatus]);
|
||||
|
||||
return ['success' => true, 'message' => 'Status updated to ' . $status . '.'];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all ticket categories.
|
||||
*/
|
||||
public function getCategories(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokowaas_ticket_categories'))
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->order($db->quoteName('ordering') . ' ASC')
|
||||
);
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get canned responses, optionally filtered by category.
|
||||
*/
|
||||
public function getCannedResponses(int $categoryId = 0): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokowaas_ticket_canned'))
|
||||
->order($db->quoteName('ordering') . ' ASC');
|
||||
|
||||
if ($categoryId)
|
||||
{
|
||||
$query->where('(' . $db->quoteName('category_id') . ' = ' . $categoryId
|
||||
. ' OR ' . $db->quoteName('category_id') . ' IS NULL)');
|
||||
}
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ticket counts by status for dashboard.
|
||||
*/
|
||||
public function getStatusCounts(): object
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select([$db->quoteName('status'), 'COUNT(*) AS ' . $db->quoteName('cnt')])
|
||||
->from($db->quoteName('#__mokowaas_tickets'))
|
||||
->group($db->quoteName('status'))
|
||||
);
|
||||
$rows = $db->loadObjectList('status') ?: [];
|
||||
|
||||
return (object) [
|
||||
'open' => (int) ($rows['open']->cnt ?? 0),
|
||||
'in_progress' => (int) ($rows['in_progress']->cnt ?? 0),
|
||||
'waiting' => (int) ($rows['waiting']->cnt ?? 0),
|
||||
'resolved' => (int) ($rows['resolved']->cnt ?? 0),
|
||||
'closed' => (int) ($rows['closed']->cnt ?? 0),
|
||||
'total' => array_sum(array_map(fn($r) => (int) $r->cnt, $rows)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overdue tickets (SLA breached).
|
||||
*/
|
||||
public function getOverdueTickets(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$now = Factory::getDate()->toSql();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select([$db->quoteName('id'), $db->quoteName('subject'), $db->quoteName('priority'),
|
||||
$db->quoteName('sla_response_due'), $db->quoteName('sla_resolution_due'), $db->quoteName('sla_responded')])
|
||||
->from($db->quoteName('#__mokowaas_tickets'))
|
||||
->where($db->quoteName('status') . ' NOT IN (' . $db->quote('resolved') . ',' . $db->quote('closed') . ')')
|
||||
->where('((' . $db->quoteName('sla_response_due') . ' < ' . $db->quote($now) . ' AND ' . $db->quoteName('sla_responded') . ' = 0)'
|
||||
. ' OR ' . $db->quoteName('sla_resolution_due') . ' < ' . $db->quote($now) . ')')
|
||||
->order($db->quoteName('sla_resolution_due') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Automation Engine
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* Run automation rules for a specific trigger event against a ticket.
|
||||
*
|
||||
* @param string $event trigger_event: ticket_created, ticket_replied, status_changed, scheduled
|
||||
* @param int $ticketId The ticket to evaluate
|
||||
*/
|
||||
public function runAutomation(string $event, int $ticketId): void
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
|
||||
// Load enabled rules for this event
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokowaas_ticket_automation'))
|
||||
->where($db->quoteName('trigger_event') . ' = ' . $db->quote($event))
|
||||
->where($db->quoteName('enabled') . ' = 1')
|
||||
->order($db->quoteName('ordering') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
$rules = $db->loadObjectList() ?: [];
|
||||
|
||||
if (empty($rules))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Load the ticket
|
||||
$ticket = $this->getTicket($ticketId);
|
||||
|
||||
if (!$ticket)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate age in hours
|
||||
$ticket->age_hours = (time() - strtotime($ticket->created)) / 3600;
|
||||
|
||||
foreach ($rules as $rule)
|
||||
{
|
||||
$conditions = json_decode($rule->conditions, true) ?: [];
|
||||
$actions = json_decode($rule->actions, true) ?: [];
|
||||
|
||||
if ($this->evaluateConditions($conditions, $ticket))
|
||||
{
|
||||
$this->executeActions($actions, $ticketId, $ticket);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
\Joomla\CMS\Log\Log::add('Automation error: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokowaas');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all scheduled automation rules against all open tickets.
|
||||
*/
|
||||
public function runScheduledAutomation(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$results = ['evaluated' => 0, 'acted' => 0];
|
||||
|
||||
// Load scheduled rules
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokowaas_ticket_automation'))
|
||||
->where($db->quoteName('trigger_event') . ' = ' . $db->quote('scheduled'))
|
||||
->where($db->quoteName('enabled') . ' = 1')
|
||||
->order($db->quoteName('ordering') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
$rules = $db->loadObjectList() ?: [];
|
||||
|
||||
if (empty($rules))
|
||||
{
|
||||
return $results;
|
||||
}
|
||||
|
||||
// Load all non-closed tickets
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokowaas_tickets'))
|
||||
->where($db->quoteName('status') . ' != ' . $db->quote('closed'));
|
||||
$db->setQuery($query);
|
||||
$tickets = $db->loadObjectList() ?: [];
|
||||
|
||||
foreach ($tickets as $ticket)
|
||||
{
|
||||
$ticket->age_hours = (time() - strtotime($ticket->created)) / 3600;
|
||||
$ticket->replies = [];
|
||||
$results['evaluated']++;
|
||||
|
||||
foreach ($rules as $rule)
|
||||
{
|
||||
$conditions = json_decode($rule->conditions, true) ?: [];
|
||||
$actions = json_decode($rule->actions, true) ?: [];
|
||||
|
||||
if ($this->evaluateConditions($conditions, $ticket))
|
||||
{
|
||||
$this->executeActions($actions, (int) $ticket->id, $ticket);
|
||||
$results['acted']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a set of conditions against a ticket (all must match).
|
||||
*/
|
||||
private function evaluateConditions(array $conditions, object $ticket): bool
|
||||
{
|
||||
foreach ($conditions as $cond)
|
||||
{
|
||||
$field = $cond['field'] ?? '';
|
||||
$op = $cond['op'] ?? 'eq';
|
||||
$value = $cond['value'] ?? '';
|
||||
|
||||
$ticketValue = $ticket->{$field} ?? null;
|
||||
|
||||
if ($ticketValue === null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
switch ($op)
|
||||
{
|
||||
case 'eq':
|
||||
if ((string) $ticketValue !== (string) $value) return false;
|
||||
break;
|
||||
case 'neq':
|
||||
if ((string) $ticketValue === (string) $value) return false;
|
||||
break;
|
||||
case 'gt':
|
||||
if ((float) $ticketValue <= (float) $value) return false;
|
||||
break;
|
||||
case 'lt':
|
||||
if ((float) $ticketValue >= (float) $value) return false;
|
||||
break;
|
||||
case 'in':
|
||||
$list = array_map('trim', explode(',', $value));
|
||||
if (!\in_array((string) $ticketValue, $list, true)) return false;
|
||||
break;
|
||||
case 'not_in':
|
||||
$list = array_map('trim', explode(',', $value));
|
||||
if (\in_array((string) $ticketValue, $list, true)) return false;
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a set of actions on a ticket.
|
||||
*/
|
||||
private function executeActions(array $actions, int $ticketId, object $ticket): void
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$now = Factory::getDate()->toSql();
|
||||
|
||||
foreach ($actions as $action)
|
||||
{
|
||||
$type = $action['type'] ?? '';
|
||||
$value = $action['value'] ?? '';
|
||||
|
||||
switch ($type)
|
||||
{
|
||||
case 'set_status':
|
||||
$this->updateStatus($ticketId, $value);
|
||||
break;
|
||||
|
||||
case 'set_priority':
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__mokowaas_tickets'))
|
||||
->set($db->quoteName('priority') . ' = ' . $db->quote($value))
|
||||
->set($db->quoteName('modified') . ' = ' . $db->quote($now))
|
||||
->where($db->quoteName('id') . ' = ' . $ticketId)
|
||||
)->execute();
|
||||
break;
|
||||
|
||||
case 'assign':
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__mokowaas_tickets'))
|
||||
->set($db->quoteName('assigned_to') . ' = ' . (int) $value)
|
||||
->set($db->quoteName('modified') . ' = ' . $db->quote($now))
|
||||
->where($db->quoteName('id') . ' = ' . $ticketId)
|
||||
)->execute();
|
||||
break;
|
||||
|
||||
case 'add_note':
|
||||
$reply = (object) [
|
||||
'ticket_id' => $ticketId,
|
||||
'user_id' => 0,
|
||||
'body' => $value,
|
||||
'is_internal' => 1,
|
||||
'created' => $now,
|
||||
];
|
||||
$db->insertObject('#__mokowaas_ticket_replies', $reply, 'id');
|
||||
break;
|
||||
|
||||
case 'send_email':
|
||||
// value = email address or comma-separated list
|
||||
$emails = array_filter(array_map('trim', explode(',', $value)));
|
||||
|
||||
foreach ($emails as $email)
|
||||
{
|
||||
try
|
||||
{
|
||||
$mailer = Factory::getMailer();
|
||||
$mailer->addRecipient($email);
|
||||
$mailer->setSubject('[Ticket #' . $ticketId . '] Automation Alert');
|
||||
$mailer->setBody('Automation rule triggered for ticket #' . $ticketId . ': ' . ($ticket->subject ?? ''));
|
||||
$mailer->isHtml(false);
|
||||
$mailer->Send();
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
\Joomla\CMS\Log\Log::add('Automation email failed: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokowaas');
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'create_ticket':
|
||||
// value = JSON: {"subject":"...","body":"...","category_id":1,"priority":"normal","behavior":"append"}
|
||||
$ticketData = json_decode($value, true) ?: [];
|
||||
$behavior = $ticketData['behavior'] ?? 'append';
|
||||
$userId = (int) ($ticket->created_by ?? 0);
|
||||
$catId = (int) ($ticketData['category_id'] ?? 0);
|
||||
|
||||
if ($behavior === 'append' && $userId > 0)
|
||||
{
|
||||
// Check for existing open ticket from this user in this category
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select($db->quoteName('id'))
|
||||
->from($db->quoteName('#__mokowaas_tickets'))
|
||||
->where($db->quoteName('created_by') . ' = ' . $userId)
|
||||
->where($db->quoteName('status') . ' NOT IN (' . $db->quote('resolved') . ',' . $db->quote('closed') . ')')
|
||||
->where($catId ? $db->quoteName('category_id') . ' = ' . $catId : '1=1')
|
||||
->order($db->quoteName('created') . ' DESC')
|
||||
->setLimit(1)
|
||||
);
|
||||
$existingId = (int) $db->loadResult();
|
||||
|
||||
if ($existingId)
|
||||
{
|
||||
$this->addReply($existingId, $ticketData['body'] ?? 'Automation event', true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
elseif ($behavior === 'skip_if_open' && $userId > 0)
|
||||
{
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokowaas_tickets'))
|
||||
->where($db->quoteName('created_by') . ' = ' . $userId)
|
||||
->where($db->quoteName('status') . ' NOT IN (' . $db->quote('resolved') . ',' . $db->quote('closed') . ')')
|
||||
);
|
||||
|
||||
if ((int) $db->loadResult() > 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Create new ticket
|
||||
$this->createTicket([
|
||||
'subject' => $ticketData['subject'] ?? 'Automation: ' . ($ticket->subject ?? 'System event'),
|
||||
'body' => $ticketData['body'] ?? '',
|
||||
'priority' => $ticketData['priority'] ?? 'normal',
|
||||
'category_id' => $catId,
|
||||
]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run automation for a system event (not tied to a specific ticket).
|
||||
* Creates a virtual ticket context from event data.
|
||||
*/
|
||||
public function runSystemEventAutomation(string $event, array $eventData = []): void
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokowaas_ticket_automation'))
|
||||
->where($db->quoteName('trigger_event') . ' = ' . $db->quote($event))
|
||||
->where($db->quoteName('enabled') . ' = 1')
|
||||
->order($db->quoteName('ordering') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
$rules = $db->loadObjectList() ?: [];
|
||||
|
||||
if (empty($rules))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a virtual ticket-like object from event data
|
||||
$context = (object) array_merge([
|
||||
'id' => 0,
|
||||
'subject' => $eventData['subject'] ?? $event,
|
||||
'body' => $eventData['body'] ?? '',
|
||||
'status' => 'open',
|
||||
'priority' => $eventData['priority'] ?? 'normal',
|
||||
'created_by' => $eventData['user_id'] ?? 0,
|
||||
'created' => gmdate('Y-m-d H:i:s'),
|
||||
'age_hours' => 0,
|
||||
], $eventData);
|
||||
|
||||
foreach ($rules as $rule)
|
||||
{
|
||||
$conditions = json_decode($rule->conditions, true) ?: [];
|
||||
$actions = json_decode($rule->actions, true) ?: [];
|
||||
|
||||
if (empty($conditions) || $this->evaluateConditions($conditions, $context))
|
||||
{
|
||||
$this->executeActions($actions, 0, $context);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
\Joomla\CMS\Log\Log::add('System event automation error: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokowaas');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all automation rules.
|
||||
*/
|
||||
public function getAutomationRules(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokowaas_ticket_automation'))
|
||||
->order($db->quoteName('ordering') . ' ASC')
|
||||
);
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Akeeba Ticket System Importer
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* Check if ATS tables exist and return counts.
|
||||
*/
|
||||
public function checkAtsAvailable(): ?object
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
|
||||
try
|
||||
{
|
||||
$db->setQuery('SELECT COUNT(*) FROM #__ats_tickets');
|
||||
$tickets = (int) $db->loadResult();
|
||||
|
||||
$db->setQuery('SELECT COUNT(*) FROM #__ats_posts');
|
||||
$posts = (int) $db->loadResult();
|
||||
|
||||
$db->setQuery('SELECT COUNT(*) FROM #__ats_cannedreplies');
|
||||
$canned = (int) $db->loadResult();
|
||||
|
||||
return (object) ['tickets' => $tickets, 'posts' => $posts, 'canned' => $canned];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import tickets, replies, and canned responses from Akeeba Ticket System.
|
||||
*/
|
||||
public function importFromAts(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$results = ['tickets' => 0, 'replies' => 0, 'canned' => 0, 'errors' => []];
|
||||
|
||||
try
|
||||
{
|
||||
// Status mapping: ATS → MokoWaaS
|
||||
$statusMap = [
|
||||
'O' => 'open', // Open
|
||||
'P' => 'in_progress', // Pending (staff action needed)
|
||||
'C' => 'closed', // Closed
|
||||
];
|
||||
// Numeric statuses 1-99 are custom — map to open
|
||||
for ($i = 1; $i <= 99; $i++)
|
||||
{
|
||||
$statusMap[(string) $i] = 'open';
|
||||
}
|
||||
|
||||
// Priority mapping: ATS uses 1-5, we use enum
|
||||
$priorityMap = [
|
||||
1 => 'low',
|
||||
2 => 'low',
|
||||
3 => 'normal',
|
||||
4 => 'high',
|
||||
5 => 'urgent',
|
||||
];
|
||||
|
||||
// Category mapping: ATS uses Joomla categories, map catid to our category
|
||||
// Default all to General Support (1) — admin can reassign later
|
||||
$defaultCategory = 1;
|
||||
|
||||
// Import canned replies first
|
||||
$db->setQuery('SELECT * FROM #__ats_cannedreplies WHERE enabled = 1 ORDER BY ordering');
|
||||
$atsCanned = $db->loadObjectList() ?: [];
|
||||
|
||||
foreach ($atsCanned as $c)
|
||||
{
|
||||
$exists = $db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from('#__mokowaas_ticket_canned')
|
||||
->where($db->quoteName('title') . ' = ' . $db->quote($c->title))
|
||||
)->loadResult();
|
||||
|
||||
if ((int) $exists > 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$row = (object) [
|
||||
'title' => $c->title,
|
||||
'body' => strip_tags($c->reply ?? ''),
|
||||
'category_id' => null,
|
||||
'ordering' => (int) ($c->ordering ?? 0),
|
||||
];
|
||||
$db->insertObject('#__mokowaas_ticket_canned', $row, 'id');
|
||||
$results['canned']++;
|
||||
}
|
||||
|
||||
// Import tickets
|
||||
$db->setQuery('SELECT * FROM #__ats_tickets ORDER BY id');
|
||||
$atsTickets = $db->loadObjectList() ?: [];
|
||||
|
||||
$ticketIdMap = []; // ATS id → MokoWaaS id
|
||||
|
||||
foreach ($atsTickets as $t)
|
||||
{
|
||||
// Skip if already imported (check by subject + created_by + created)
|
||||
$exists = $db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from('#__mokowaas_tickets')
|
||||
->where($db->quoteName('subject') . ' = ' . $db->quote($t->title))
|
||||
->where($db->quoteName('created_by') . ' = ' . (int) $t->created_by)
|
||||
)->loadResult();
|
||||
|
||||
if ((int) $exists > 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = $statusMap[$t->status] ?? 'open';
|
||||
$priority = $priorityMap[(int) $t->priority] ?? 'normal';
|
||||
|
||||
$row = (object) [
|
||||
'subject' => $t->title,
|
||||
'body' => '',
|
||||
'status' => $status,
|
||||
'priority' => $priority,
|
||||
'category_id' => $defaultCategory,
|
||||
'created_by' => (int) $t->created_by,
|
||||
'assigned_to' => (int) $t->assigned_to ?: null,
|
||||
'created' => $t->created ?: Factory::getDate()->toSql(),
|
||||
'modified' => $t->modified,
|
||||
'resolved' => $status === 'closed' ? ($t->modified ?: $t->created) : null,
|
||||
'closed' => $status === 'closed' ? ($t->modified ?: $t->created) : null,
|
||||
'sla_responded' => 1,
|
||||
];
|
||||
|
||||
$db->insertObject('#__mokowaas_tickets', $row, 'id');
|
||||
$ticketIdMap[(int) $t->id] = (int) $row->id;
|
||||
$results['tickets']++;
|
||||
}
|
||||
|
||||
// Import posts (replies)
|
||||
$db->setQuery('SELECT * FROM #__ats_posts ORDER BY id');
|
||||
$atsPosts = $db->loadObjectList() ?: [];
|
||||
|
||||
foreach ($atsPosts as $p)
|
||||
{
|
||||
$newTicketId = $ticketIdMap[(int) $p->ticket_id] ?? null;
|
||||
|
||||
if (!$newTicketId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// First post of a ticket is usually the ticket body — update the ticket
|
||||
if (empty($results['first_post_' . $p->ticket_id]))
|
||||
{
|
||||
$results['first_post_' . $p->ticket_id] = true;
|
||||
$body = strip_tags($p->content_html ?? '');
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update('#__mokowaas_tickets')
|
||||
->set($db->quoteName('body') . ' = ' . $db->quote($body))
|
||||
->where($db->quoteName('id') . ' = ' . $newTicketId)
|
||||
)->execute();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$row = (object) [
|
||||
'ticket_id' => $newTicketId,
|
||||
'user_id' => (int) $p->created_by,
|
||||
'body' => strip_tags($p->content_html ?? ''),
|
||||
'is_internal' => 0,
|
||||
'created' => $p->created ?: Factory::getDate()->toSql(),
|
||||
];
|
||||
|
||||
$db->insertObject('#__mokowaas_ticket_replies', $row, 'id');
|
||||
$results['replies']++;
|
||||
}
|
||||
|
||||
// Clean up temp tracking keys
|
||||
foreach (array_keys($results) as $k)
|
||||
{
|
||||
if (str_starts_with($k, 'first_post_'))
|
||||
{
|
||||
unset($results[$k]);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => sprintf(
|
||||
'Imported %d tickets, %d replies, %d canned responses from Akeeba Ticket System.',
|
||||
$results['tickets'], $results['replies'], $results['canned']
|
||||
),
|
||||
'counts' => $results,
|
||||
];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return ['success' => false, 'message' => 'Import failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Administrator\Model;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
|
||||
class WaflogModel extends BaseDatabaseModel
|
||||
{
|
||||
/**
|
||||
* Get WAF log entries with filters and pagination.
|
||||
*/
|
||||
public function getLogs(array $filters = [], int $limit = 50, int $offset = 0): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokowaas_waf_log'));
|
||||
|
||||
if (!empty($filters['rule']))
|
||||
{
|
||||
$query->where($db->quoteName('rule') . ' = ' . $db->quote($filters['rule']));
|
||||
}
|
||||
|
||||
if (!empty($filters['ip']))
|
||||
{
|
||||
$query->where($db->quoteName('ip') . ' LIKE ' . $db->quote('%' . $db->escape($filters['ip'], true) . '%'));
|
||||
}
|
||||
|
||||
if (!empty($filters['search']))
|
||||
{
|
||||
$search = $db->quote('%' . $db->escape($filters['search'], true) . '%');
|
||||
$query->where('(' . $db->quoteName('uri') . ' LIKE ' . $search
|
||||
. ' OR ' . $db->quoteName('detail') . ' LIKE ' . $search
|
||||
. ' OR ' . $db->quoteName('user_agent') . ' LIKE ' . $search . ')');
|
||||
}
|
||||
|
||||
if (!empty($filters['date_from']))
|
||||
{
|
||||
$query->where($db->quoteName('created') . ' >= ' . $db->quote($filters['date_from'] . ' 00:00:00'));
|
||||
}
|
||||
|
||||
if (!empty($filters['date_to']))
|
||||
{
|
||||
$query->where($db->quoteName('created') . ' <= ' . $db->quote($filters['date_to'] . ' 23:59:59'));
|
||||
}
|
||||
|
||||
$query->order($db->quoteName('created') . ' DESC');
|
||||
$query->setLimit($limit, $offset);
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total count for pagination.
|
||||
*/
|
||||
public function getTotal(array $filters = []): int
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokowaas_waf_log'));
|
||||
|
||||
if (!empty($filters['rule']))
|
||||
{
|
||||
$query->where($db->quoteName('rule') . ' = ' . $db->quote($filters['rule']));
|
||||
}
|
||||
|
||||
if (!empty($filters['ip']))
|
||||
{
|
||||
$query->where($db->quoteName('ip') . ' LIKE ' . $db->quote('%' . $db->escape($filters['ip'], true) . '%'));
|
||||
}
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
return (int) $db->loadResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get block counts grouped by rule for the summary bar.
|
||||
*/
|
||||
public function getRuleCounts(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select([$db->quoteName('rule'), 'COUNT(*) AS ' . $db->quoteName('cnt')])
|
||||
->from($db->quoteName('#__mokowaas_waf_log'))
|
||||
->group($db->quoteName('rule'))
|
||||
->order($db->quoteName('cnt') . ' DESC')
|
||||
);
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top blocked IPs.
|
||||
*/
|
||||
public function getTopIps(int $limit = 10): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select([$db->quoteName('ip'), 'COUNT(*) AS ' . $db->quoteName('cnt'),
|
||||
'MAX(' . $db->quoteName('created') . ') AS ' . $db->quoteName('last_seen')])
|
||||
->from($db->quoteName('#__mokowaas_waf_log'))
|
||||
->group($db->quoteName('ip'))
|
||||
->order($db->quoteName('cnt') . ' DESC')
|
||||
->setLimit($limit)
|
||||
);
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get distinct rule names for the filter dropdown.
|
||||
*/
|
||||
public function getRuleNames(): array
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('DISTINCT ' . $db->quoteName('rule'))
|
||||
->from($db->quoteName('#__mokowaas_waf_log'))
|
||||
->order($db->quoteName('rule') . ' ASC')
|
||||
);
|
||||
|
||||
return $db->loadColumn() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete logs older than N days.
|
||||
*/
|
||||
public function purgeLogs(int $days): array
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$cutoff = Factory::getDate('-' . $days . ' days')->toSql();
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete($db->quoteName('#__mokowaas_waf_log'))
|
||||
->where($db->quoteName('created') . ' < ' . $db->quote($cutoff))
|
||||
)->execute();
|
||||
|
||||
$count = $db->getAffectedRows();
|
||||
|
||||
return ['success' => true, 'message' => "Purged {$count} log entries older than {$days} days."];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return ['success' => false, 'message' => 'Purge failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an IP to the firewall blocklist.
|
||||
*/
|
||||
public function banIp(string $ip, string $reason = 'Banned from WAF log'): array
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'));
|
||||
$db->setQuery($query);
|
||||
|
||||
$params = new \Joomla\Registry\Registry($db->loadResult() ?? '{}');
|
||||
$blocklist = json_decode($params->get('ip_blocklist', '[]'), true) ?: [];
|
||||
|
||||
// Check if already blocked
|
||||
foreach ($blocklist as $entry)
|
||||
{
|
||||
if (($entry['ip'] ?? '') === $ip)
|
||||
{
|
||||
return ['success' => false, 'message' => $ip . ' is already blocked.'];
|
||||
}
|
||||
}
|
||||
|
||||
$blocklist[] = ['ip' => $ip, 'enabled' => '1', 'label' => $reason];
|
||||
$params->set('ip_blocklist', json_encode($blocklist));
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('params') . ' = ' . $db->quote($params->toString()))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
|
||||
)->execute();
|
||||
|
||||
return ['success' => true, 'message' => $ip . ' has been added to the IP blocklist.'];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return ['success' => false, 'message' => 'Ban failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Administrator\Service;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
|
||||
/**
|
||||
* Helpdesk email notification service.
|
||||
*
|
||||
* Sends emails for ticket events to Joomla users (by ID) and/or
|
||||
* raw email addresses. Uses Joomla's configured mailer.
|
||||
*
|
||||
* @since 02.32.00
|
||||
*/
|
||||
class NotificationService
|
||||
{
|
||||
/**
|
||||
* Send a ticket notification email.
|
||||
*
|
||||
* @param string $event Event name (ticket_created, ticket_replied, status_changed, ticket_assigned)
|
||||
* @param object $ticket Ticket object with id, subject, status, priority, created_by, assigned_to
|
||||
* @param array $extra Extra context (reply body, old status, etc.)
|
||||
*/
|
||||
public static function notify(string $event, object $ticket, array $extra = []): void
|
||||
{
|
||||
try
|
||||
{
|
||||
$recipients = self::getRecipients($event, $ticket);
|
||||
|
||||
if (empty($recipients))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$subject = self::buildSubject($event, $ticket);
|
||||
$body = self::buildBody($event, $ticket, $extra);
|
||||
|
||||
$mailer = Factory::getMailer();
|
||||
$mailer->isHtml(false);
|
||||
$mailer->setSubject($subject);
|
||||
$mailer->setBody($body);
|
||||
|
||||
foreach ($recipients as $email)
|
||||
{
|
||||
$email = trim($email);
|
||||
|
||||
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
$mailer->clearAddresses();
|
||||
$mailer->addRecipient($email);
|
||||
$mailer->Send();
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Notification send failed to ' . $email . ': ' . $e->getMessage(), Log::WARNING, 'mokowaas');
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Notification error: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine recipients based on event type and ticket data.
|
||||
*/
|
||||
private static function getRecipients(string $event, object $ticket): array
|
||||
{
|
||||
$emails = [];
|
||||
|
||||
// Get notification config from component params
|
||||
$config = self::getNotificationConfig();
|
||||
|
||||
// Always notify configured admin emails
|
||||
$adminEmails = array_filter(array_map('trim', explode(',', $config['admin_emails'] ?? '')));
|
||||
$emails = array_merge($emails, $adminEmails);
|
||||
|
||||
// Always notify configured admin user IDs
|
||||
$adminUserIds = array_filter(array_map('intval', explode(',', $config['admin_user_ids'] ?? '')));
|
||||
|
||||
foreach ($adminUserIds as $uid)
|
||||
{
|
||||
$email = self::getUserEmail($uid);
|
||||
|
||||
if ($email)
|
||||
{
|
||||
$emails[] = $email;
|
||||
}
|
||||
}
|
||||
|
||||
switch ($event)
|
||||
{
|
||||
case 'ticket_created':
|
||||
// Notify assigned user if any
|
||||
if (!empty($ticket->assigned_to))
|
||||
{
|
||||
$email = self::getUserEmail((int) $ticket->assigned_to);
|
||||
|
||||
if ($email)
|
||||
{
|
||||
$emails[] = $email;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ticket_replied':
|
||||
// Notify ticket creator (customer gets notified of staff reply)
|
||||
if (!empty($ticket->created_by))
|
||||
{
|
||||
$email = self::getUserEmail((int) $ticket->created_by);
|
||||
|
||||
if ($email)
|
||||
{
|
||||
$emails[] = $email;
|
||||
}
|
||||
}
|
||||
|
||||
// Notify assigned user
|
||||
if (!empty($ticket->assigned_to))
|
||||
{
|
||||
$email = self::getUserEmail((int) $ticket->assigned_to);
|
||||
|
||||
if ($email)
|
||||
{
|
||||
$emails[] = $email;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'status_changed':
|
||||
// Notify ticket creator
|
||||
if (!empty($ticket->created_by))
|
||||
{
|
||||
$email = self::getUserEmail((int) $ticket->created_by);
|
||||
|
||||
if ($email)
|
||||
{
|
||||
$emails[] = $email;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ticket_assigned':
|
||||
// Notify newly assigned user
|
||||
if (!empty($ticket->assigned_to))
|
||||
{
|
||||
$email = self::getUserEmail((int) $ticket->assigned_to);
|
||||
|
||||
if ($email)
|
||||
{
|
||||
$emails[] = $email;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return array_unique($emails);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build email subject line.
|
||||
*/
|
||||
private static function buildSubject(string $event, object $ticket): string
|
||||
{
|
||||
$siteName = Factory::getConfig()->get('sitename', 'Support');
|
||||
$prefix = '[' . $siteName . ' #' . $ticket->id . '] ';
|
||||
|
||||
switch ($event)
|
||||
{
|
||||
case 'ticket_created':
|
||||
return $prefix . 'New Ticket: ' . ($ticket->subject ?? '');
|
||||
|
||||
case 'ticket_replied':
|
||||
return $prefix . 'Reply: ' . ($ticket->subject ?? '');
|
||||
|
||||
case 'status_changed':
|
||||
return $prefix . 'Status Changed: ' . ($ticket->subject ?? '');
|
||||
|
||||
case 'ticket_assigned':
|
||||
return $prefix . 'Assigned: ' . ($ticket->subject ?? '');
|
||||
|
||||
default:
|
||||
return $prefix . ($ticket->subject ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build email body.
|
||||
*/
|
||||
private static function buildBody(string $event, object $ticket, array $extra): string
|
||||
{
|
||||
$siteName = Factory::getConfig()->get('sitename', 'Support');
|
||||
$siteUrl = rtrim(Uri::root(), '/');
|
||||
$ticketUrl = $siteUrl . '/index.php?option=com_mokowaas&view=ticket&id=' . $ticket->id;
|
||||
|
||||
$lines = [];
|
||||
$lines[] = $siteName . ' Support';
|
||||
$lines[] = str_repeat('-', 40);
|
||||
$lines[] = '';
|
||||
|
||||
switch ($event)
|
||||
{
|
||||
case 'ticket_created':
|
||||
$lines[] = 'A new support ticket has been created.';
|
||||
$lines[] = '';
|
||||
$lines[] = 'Subject: ' . ($ticket->subject ?? '');
|
||||
$lines[] = 'Priority: ' . ucfirst($ticket->priority ?? 'normal');
|
||||
$lines[] = 'Category: ' . ($ticket->category_title ?? 'General');
|
||||
$lines[] = '';
|
||||
|
||||
if (!empty($ticket->body))
|
||||
{
|
||||
$lines[] = 'Description:';
|
||||
$lines[] = strip_tags($ticket->body);
|
||||
$lines[] = '';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ticket_replied':
|
||||
$lines[] = 'A new reply has been added to your ticket.';
|
||||
$lines[] = '';
|
||||
$lines[] = 'Subject: ' . ($ticket->subject ?? '');
|
||||
$lines[] = 'Status: ' . ucwords(str_replace('_', ' ', $ticket->status ?? ''));
|
||||
$lines[] = '';
|
||||
|
||||
if (!empty($extra['reply_body']))
|
||||
{
|
||||
$lines[] = 'Reply:';
|
||||
$lines[] = strip_tags($extra['reply_body']);
|
||||
$lines[] = '';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'status_changed':
|
||||
$lines[] = 'Your ticket status has been updated.';
|
||||
$lines[] = '';
|
||||
$lines[] = 'Subject: ' . ($ticket->subject ?? '');
|
||||
$lines[] = 'New Status: ' . ucwords(str_replace('_', ' ', $ticket->status ?? ''));
|
||||
|
||||
if (!empty($extra['old_status']))
|
||||
{
|
||||
$lines[] = 'Old Status: ' . ucwords(str_replace('_', ' ', $extra['old_status']));
|
||||
}
|
||||
|
||||
$lines[] = '';
|
||||
break;
|
||||
|
||||
case 'ticket_assigned':
|
||||
$lines[] = 'A ticket has been assigned to you.';
|
||||
$lines[] = '';
|
||||
$lines[] = 'Subject: ' . ($ticket->subject ?? '');
|
||||
$lines[] = 'Priority: ' . ucfirst($ticket->priority ?? 'normal');
|
||||
$lines[] = '';
|
||||
break;
|
||||
}
|
||||
|
||||
$lines[] = 'View ticket: ' . $ticketUrl;
|
||||
$lines[] = '';
|
||||
$lines[] = '-- ';
|
||||
$lines[] = $siteName . ' | Powered by MokoWaaS';
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get email address for a Joomla user ID.
|
||||
*/
|
||||
private static function getUserEmail(int $userId): ?string
|
||||
{
|
||||
if ($userId <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select($db->quoteName('email'))
|
||||
->from($db->quoteName('#__users'))
|
||||
->where($db->quoteName('id') . ' = ' . $userId)
|
||||
);
|
||||
|
||||
return $db->loadResult() ?: null;
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notification configuration from component params.
|
||||
*/
|
||||
private static function getNotificationConfig(): array
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select($db->quoteName('params'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
|
||||
);
|
||||
|
||||
$params = json_decode($db->loadResult() ?? '{}', true);
|
||||
|
||||
return $params['notifications'] ?? [];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Security Event Notifications (#131)
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* Send a security alert to admin emails.
|
||||
*/
|
||||
public static function securityAlert(string $event, string $subject, string $body): void
|
||||
{
|
||||
try
|
||||
{
|
||||
$config = self::getNotificationConfig();
|
||||
$enabled = $config['security_alerts'] ?? '1';
|
||||
|
||||
if (!$enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$adminEmails = array_filter(array_map('trim', explode(',', $config['admin_emails'] ?? '')));
|
||||
$adminUserIds = array_filter(array_map('intval', explode(',', $config['admin_user_ids'] ?? '')));
|
||||
|
||||
$recipients = $adminEmails;
|
||||
|
||||
foreach ($adminUserIds as $uid)
|
||||
{
|
||||
$email = self::getUserEmail($uid);
|
||||
|
||||
if ($email)
|
||||
{
|
||||
$recipients[] = $email;
|
||||
}
|
||||
}
|
||||
|
||||
$recipients = array_unique($recipients);
|
||||
|
||||
if (empty($recipients))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$siteName = Factory::getConfig()->get('sitename', 'Site');
|
||||
$fullSubject = '[' . $siteName . ' Security] ' . $subject;
|
||||
|
||||
$lines = [
|
||||
$siteName . ' Security Alert',
|
||||
str_repeat('-', 40),
|
||||
'',
|
||||
'Event: ' . $event,
|
||||
'Time: ' . gmdate('Y-m-d H:i:s') . ' UTC',
|
||||
'',
|
||||
$body,
|
||||
'',
|
||||
'-- ',
|
||||
$siteName . ' | MokoWaaS Security',
|
||||
];
|
||||
|
||||
$mailer = Factory::getMailer();
|
||||
$mailer->isHtml(false);
|
||||
$mailer->setSubject($fullSubject);
|
||||
$mailer->setBody(implode("\n", $lines));
|
||||
|
||||
foreach ($recipients as $email)
|
||||
{
|
||||
try
|
||||
{
|
||||
$mailer->clearAddresses();
|
||||
$mailer->addRecipient(trim($email));
|
||||
$mailer->Send();
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Security alert send failed: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Security alert error: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoWaaS\Administrator\View\Automation;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $rules = [];
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$model = new \Moko\Component\MokoWaaS\Administrator\Model\TicketsModel();
|
||||
$this->rules = $model->getAutomationRules();
|
||||
|
||||
ToolbarHelper::title('Automation Rules', 'cogs');
|
||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas&view=tickets');
|
||||
|
||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoWaaS\Administrator\View\Canned;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $responses = [];
|
||||
protected $categories = [];
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$db = Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
|
||||
|
||||
$db->setQuery('SELECT * FROM #__mokowaas_ticket_canned ORDER BY ordering ASC');
|
||||
$this->responses = $db->loadObjectList() ?: [];
|
||||
|
||||
$db->setQuery('SELECT id, title FROM #__mokowaas_ticket_categories WHERE published = 1 ORDER BY ordering');
|
||||
$this->categories = $db->loadObjectList() ?: [];
|
||||
|
||||
ToolbarHelper::title('Canned Responses', 'comment');
|
||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas&view=tickets');
|
||||
|
||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoWaaS\Administrator\View\Categories;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $categories = [];
|
||||
protected $users = [];
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$db = Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
|
||||
|
||||
$db->setQuery('SELECT * FROM #__mokowaas_ticket_categories ORDER BY ordering ASC');
|
||||
$this->categories = $db->loadObjectList() ?: [];
|
||||
|
||||
// Get admin users for auto-assign dropdown
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select([$db->quoteName('id'), $db->quoteName('name')])
|
||||
->from($db->quoteName('#__users'))
|
||||
->where($db->quoteName('block') . ' = 0')
|
||||
->order($db->quoteName('name') . ' ASC')
|
||||
->setLimit(100)
|
||||
);
|
||||
$this->users = $db->loadObjectList() ?: [];
|
||||
|
||||
ToolbarHelper::title('Ticket Categories', 'folder');
|
||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas&view=tickets');
|
||||
|
||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoWaaS\Administrator\View\Cleanup;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $dirs = [];
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$model = new \Moko\Component\MokoWaaS\Administrator\Model\MaintenanceModel();
|
||||
$this->dirs = $model->getCleanupInfo();
|
||||
|
||||
ToolbarHelper::title('Cache & Temp Cleanup', 'trash');
|
||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas');
|
||||
|
||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -17,26 +17,43 @@ use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
/**
|
||||
* @var array Discovered MokoWaaS feature plugins.
|
||||
*/
|
||||
protected $plugins = [];
|
||||
|
||||
/**
|
||||
* @var object Site info (Joomla version, PHP version, etc.).
|
||||
*/
|
||||
protected $siteInfo;
|
||||
protected $recentLogins = [];
|
||||
protected $pendingUpdates = [];
|
||||
protected $checkedOutItems = [];
|
||||
protected $wafBlocks = [];
|
||||
protected $wafChartData = [];
|
||||
protected $loginChartData = [];
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$model = $this->getModel();
|
||||
|
||||
$this->plugins = $model->getFeaturePlugins();
|
||||
$this->siteInfo = $model->getSiteInfo();
|
||||
$this->plugins = $model->getFeaturePlugins();
|
||||
$this->siteInfo = $model->getSiteInfo();
|
||||
$this->recentLogins = $model->getRecentLogins(5);
|
||||
$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
|
||||
{
|
||||
$importModel = new \Moko\Component\MokoWaaS\Administrator\Model\ImportModel();
|
||||
$this->adminToolsAvailable = $importModel->checkAdminToolsAvailable();
|
||||
$this->atsAvailable = $importModel->checkAtsAvailable();
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$this->adminToolsAvailable = null;
|
||||
$this->atsAvailable = null;
|
||||
}
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
// Load dashboard assets
|
||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
|
||||
$wa->registerAndUseScript('com_mokowaas.dashboard', 'com_mokowaas/dashboard.js', [], ['defer' => true]);
|
||||
|
||||
@@ -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,41 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Administrator\View\Extensions;
|
||||
|
||||
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 $packages = [];
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$model = $this->getModel();
|
||||
|
||||
$this->packages = $model->getCatalog();
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
ToolbarHelper::title(Text::_('COM_MOKOWAAS_EXTENSIONS_TITLE'), 'puzzle-piece');
|
||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Administrator\View\Htaccess;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $options = [];
|
||||
protected $preview = '';
|
||||
protected $nginxPreview = '';
|
||||
protected $currentHtaccess = '';
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$model = $this->getModel();
|
||||
|
||||
$this->options = $model->getOptions();
|
||||
$this->preview = $model->generateHtaccess($this->options);
|
||||
$this->nginxPreview = $model->generateNginx($this->options);
|
||||
$this->currentHtaccess = $model->readCurrentHtaccess();
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
ToolbarHelper::title(Text::_('COM_MOKOWAAS_HTACCESS_TITLE'), 'file-code');
|
||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoWaaS\Administrator\View\Privacy;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $requests = [];
|
||||
protected $policies = [];
|
||||
protected $summary;
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$model = new \Moko\Component\MokoWaaS\Administrator\Model\PrivacyModel();
|
||||
|
||||
$filterStatus = Factory::getApplication()->getInput()->getString('filter_status', '');
|
||||
$this->requests = $model->getDataRequests($filterStatus);
|
||||
$this->policies = $model->getRetentionPolicies();
|
||||
$this->summary = $model->getDashboardSummary();
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
ToolbarHelper::title('Privacy Guard', 'lock');
|
||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Administrator\View\Ticket;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $ticket;
|
||||
protected $cannedResponses = [];
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$model = $this->getModel('Tickets');
|
||||
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||
|
||||
$this->ticket = $model->getTicket($id);
|
||||
$this->cannedResponses = $model->getCannedResponses((int) ($this->ticket->category_id ?? 0));
|
||||
|
||||
if (!$this->ticket)
|
||||
{
|
||||
Factory::getApplication()->enqueueMessage('Ticket not found.', 'error');
|
||||
Factory::getApplication()->redirect('index.php?option=com_mokowaas&view=tickets');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
$title = $this->ticket ? 'Ticket #' . $this->ticket->id . ' — ' . $this->ticket->subject : 'Ticket';
|
||||
ToolbarHelper::title($title, 'headphones');
|
||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas&view=tickets');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Administrator\View\Tickets;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $tickets = [];
|
||||
protected $categories = [];
|
||||
protected $statusCounts;
|
||||
protected $overdue = [];
|
||||
protected $atsAvailable = null;
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$model = $this->getModel();
|
||||
$app = Factory::getApplication();
|
||||
|
||||
$filters = [
|
||||
'status' => $app->getInput()->getString('filter_status', ''),
|
||||
'priority' => $app->getInput()->getString('filter_priority', ''),
|
||||
'category_id' => $app->getInput()->getInt('filter_category', 0),
|
||||
];
|
||||
|
||||
$this->tickets = $model->getTickets($filters);
|
||||
$this->categories = $model->getCategories();
|
||||
$this->statusCounts = $model->getStatusCounts();
|
||||
$this->overdue = $model->getOverdueTickets();
|
||||
$this->atsAvailable = $model->checkAtsAvailable();
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
ToolbarHelper::title(Text::_('COM_MOKOWAAS_TICKETS_TITLE'), 'headphones');
|
||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoWaaS\Administrator\View\Waflog;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $logs = [];
|
||||
protected $ruleCounts = [];
|
||||
protected $topIps = [];
|
||||
protected $ruleNames = [];
|
||||
protected $total = 0;
|
||||
protected $filters = [];
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$model = new \Moko\Component\MokoWaaS\Administrator\Model\WaflogModel();
|
||||
$input = Factory::getApplication()->getInput();
|
||||
|
||||
$this->filters = [
|
||||
'rule' => $input->getString('filter_rule', ''),
|
||||
'ip' => $input->getString('filter_ip', ''),
|
||||
'search' => $input->getString('filter_search', ''),
|
||||
'date_from' => $input->getString('filter_date_from', ''),
|
||||
'date_to' => $input->getString('filter_date_to', ''),
|
||||
];
|
||||
|
||||
$page = max(1, $input->getInt('page', 1));
|
||||
$limit = 50;
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$this->logs = $model->getLogs($this->filters, $limit, $offset);
|
||||
$this->total = $model->getTotal($this->filters);
|
||||
$this->ruleCounts = $model->getRuleCounts();
|
||||
$this->topIps = $model->getTopIps(10);
|
||||
$this->ruleNames = $model->getRuleNames();
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
|
||||
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
ToolbarHelper::title('WAF Log Viewer', 'shield-alt');
|
||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$rules = $this->rules;
|
||||
$token = Session::getFormToken();
|
||||
$saveUrl = Route::_('index.php?option=com_mokowaas&task=display.saveAutomation&format=json');
|
||||
$deleteUrl = Route::_('index.php?option=com_mokowaas&task=display.deleteAutomation&format=json');
|
||||
$toggleUrl = Route::_('index.php?option=com_mokowaas&task=display.toggleAutomation&format=json');
|
||||
|
||||
$triggerLabels = ['ticket_created' => 'On Ticket Created', 'ticket_replied' => 'On Reply', 'status_changed' => 'On Status Change', 'scheduled' => 'Scheduled (Cron)'];
|
||||
?>
|
||||
|
||||
<div id="mokowaas-automation">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4><?php echo count($rules); ?> Automation Rules</h4>
|
||||
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newRuleModal">
|
||||
<span class="icon-plus"></span> Add Rule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<?php foreach ($rules as $r): ?>
|
||||
<?php $conditions = json_decode($r->conditions, true) ?: []; $actions = json_decode($r->actions, true) ?: []; ?>
|
||||
<div class="card mb-2 <?php echo !$r->enabled ? 'opacity-50' : ''; ?>" data-id="<?php echo $r->id; ?>">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" class="form-check-input rule-toggle" data-id="<?php echo $r->id; ?>" <?php echo $r->enabled ? 'checked' : ''; ?>>
|
||||
</div>
|
||||
<strong><?php echo htmlspecialchars($r->title); ?></strong>
|
||||
<span class="badge bg-secondary"><?php echo $triggerLabels[$r->trigger_event] ?? $r->trigger_event; ?></span>
|
||||
</div>
|
||||
<div class="small text-muted mt-1">
|
||||
<span class="text-primary">IF</span>
|
||||
<?php foreach ($conditions as $i => $c): ?>
|
||||
<?php echo $i > 0 ? ' AND ' : ''; ?><?php echo htmlspecialchars($c['field'] ?? ''); ?> <?php echo htmlspecialchars($c['op'] ?? ''); ?> <?php echo htmlspecialchars($c['value'] ?? ''); ?>
|
||||
<?php endforeach; ?>
|
||||
<span class="text-success ms-2">THEN</span>
|
||||
<?php foreach ($actions as $a): ?>
|
||||
<?php echo htmlspecialchars($a['type'] ?? ''); ?>=<?php echo htmlspecialchars(mb_substr($a['value'] ?? '', 0, 30)); ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-rule" data-id="<?php echo $r->id; ?>">
|
||||
<span class="icon-trash"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (empty($rules)): ?>
|
||||
<div class="alert alert-info">No automation rules. Click "Add Rule" to create one.</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- New Rule Modal -->
|
||||
<div class="modal fade" id="newRuleModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header"><h5>Add Automation Rule</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Title</label>
|
||||
<input type="text" id="rule-title" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Trigger</label>
|
||||
<select id="rule-trigger" class="form-select">
|
||||
<?php foreach ($triggerLabels as $k => $v): ?><option value="<?php echo $k; ?>"><?php echo $v; ?></option><?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Conditions (JSON)</label>
|
||||
<textarea id="rule-conditions" class="form-control font-monospace" rows="3" placeholder='[{"field":"status","op":"eq","value":"resolved"}]'></textarea>
|
||||
<small class="text-muted">Fields: status, priority, category_id, assigned_to, sla_responded, age_hours. Ops: eq, neq, gt, lt, in, not_in</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Actions (JSON)</label>
|
||||
<textarea id="rule-actions" class="form-control font-monospace" rows="3" placeholder='[{"type":"set_status","value":"closed"}]'></textarea>
|
||||
<small class="text-muted">Types: set_status, set_priority, assign, add_note, send_email</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="btn-save-rule"><span class="icon-save"></span> Save Rule</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var token = '<?php echo $token; ?>';
|
||||
|
||||
// Save new rule
|
||||
document.getElementById('btn-save-rule').addEventListener('click', function() {
|
||||
var fd = new FormData();
|
||||
fd.append('id', '0');
|
||||
fd.append('title', document.getElementById('rule-title').value);
|
||||
fd.append('trigger_event', document.getElementById('rule-trigger').value);
|
||||
fd.append('conditions', document.getElementById('rule-conditions').value || '[]');
|
||||
fd.append('actions', document.getElementById('rule-actions').value || '[]');
|
||||
fd.append(token, '1');
|
||||
fetch('<?php echo $saveUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){ if (d.success) location.reload(); else Joomla.renderMessages({error:[d.message]}); });
|
||||
});
|
||||
|
||||
// Toggle rule
|
||||
document.querySelectorAll('.rule-toggle').forEach(function(cb) {
|
||||
cb.addEventListener('change', function() {
|
||||
var fd = new FormData();
|
||||
fd.append('id', this.dataset.id);
|
||||
fd.append('enabled', this.checked ? '1' : '0');
|
||||
fd.append(token, '1');
|
||||
fetch('<?php echo $toggleUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){ if (!d.success) Joomla.renderMessages({error:[d.message]}); });
|
||||
});
|
||||
});
|
||||
|
||||
// Delete rule
|
||||
document.querySelectorAll('.btn-delete-rule').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
if (!confirm('Delete this rule?')) return;
|
||||
var card = this.closest('.card');
|
||||
var fd = new FormData();
|
||||
fd.append('id', this.dataset.id);
|
||||
fd.append(token, '1');
|
||||
fetch('<?php echo $deleteUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){ if (d.success) card.remove(); else Joomla.renderMessages({error:[d.message]}); });
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$responses = $this->responses;
|
||||
$categories = $this->categories;
|
||||
$token = Session::getFormToken();
|
||||
$saveUrl = Route::_('index.php?option=com_mokowaas&task=display.saveCanned&format=json');
|
||||
$deleteUrl = Route::_('index.php?option=com_mokowaas&task=display.deleteCanned&format=json');
|
||||
?>
|
||||
|
||||
<div id="mokowaas-canned">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4><?php echo count($responses); ?> Canned Responses</h4>
|
||||
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newCannedModal">
|
||||
<span class="icon-plus"></span> Add Response
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<?php foreach ($responses as $r): ?>
|
||||
<div class="card mb-2" data-id="<?php echo $r->id; ?>">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<strong><?php echo htmlspecialchars($r->title); ?></strong>
|
||||
<p class="text-muted small mb-0 mt-1"><?php echo htmlspecialchars(mb_substr($r->body, 0, 150)); ?></p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-canned" data-id="<?php echo $r->id; ?>">
|
||||
<span class="icon-trash"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (empty($responses)): ?>
|
||||
<div class="alert alert-info">No canned responses yet. Click "Add Response" to create one.</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- New Canned Modal -->
|
||||
<div class="modal fade" id="newCannedModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header"><h5>Add Canned Response</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Title</label>
|
||||
<input type="text" id="canned-title" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Category (optional)</label>
|
||||
<select id="canned-category" class="form-select">
|
||||
<option value="">All categories</option>
|
||||
<?php foreach ($categories as $cat): ?>
|
||||
<option value="<?php echo $cat->id; ?>"><?php echo htmlspecialchars($cat->title); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Response Text</label>
|
||||
<textarea id="canned-body" class="form-control" rows="6" required></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="btn-save-canned"><span class="icon-save"></span> Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var token = '<?php echo $token; ?>';
|
||||
|
||||
document.getElementById('btn-save-canned').addEventListener('click', function() {
|
||||
var fd = new FormData();
|
||||
fd.append('id', '0');
|
||||
fd.append('title', document.getElementById('canned-title').value);
|
||||
fd.append('body', document.getElementById('canned-body').value);
|
||||
fd.append('category_id', document.getElementById('canned-category').value);
|
||||
fd.append(token, '1');
|
||||
fetch('<?php echo $saveUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){
|
||||
if (d.success) location.reload();
|
||||
else Joomla.renderMessages({error:[d.message]});
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.btn-delete-canned').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
if (!confirm('Delete this canned response?')) return;
|
||||
var card = this.closest('.card');
|
||||
var fd = new FormData();
|
||||
fd.append('id', this.dataset.id);
|
||||
fd.append(token, '1');
|
||||
fetch('<?php echo $deleteUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){ if (d.success) card.remove(); else Joomla.renderMessages({error:[d.message]}); });
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$categories = $this->categories;
|
||||
$users = $this->users;
|
||||
$token = Session::getFormToken();
|
||||
$saveUrl = Route::_('index.php?option=com_mokowaas&task=display.saveCategory&format=json');
|
||||
$deleteUrl = Route::_('index.php?option=com_mokowaas&task=display.deleteCategory&format=json');
|
||||
?>
|
||||
|
||||
<div id="mokowaas-categories">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4><?php echo count($categories); ?> Categories</h4>
|
||||
<button type="button" class="btn btn-primary btn-sm" id="btn-add-cat">
|
||||
<span class="icon-plus"></span> Add Category
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped mb-0" id="cat-table">
|
||||
<thead><tr><th>Title</th><th>SLA Response</th><th>SLA Resolution</th><th>Auto-Assign</th><th>Active</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($categories as $c): ?>
|
||||
<tr data-id="<?php echo $c->id; ?>">
|
||||
<td><input type="text" class="form-control form-control-sm cat-field" data-field="title" value="<?php echo htmlspecialchars($c->title); ?>"></td>
|
||||
<td><input type="number" class="form-control form-control-sm cat-field" data-field="sla_response_minutes" value="<?php echo $c->sla_response_minutes; ?>" style="width:80px"> min</td>
|
||||
<td><input type="number" class="form-control form-control-sm cat-field" data-field="sla_resolution_minutes" value="<?php echo $c->sla_resolution_minutes; ?>" style="width:80px"> min</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm cat-field" data-field="auto_assign_user">
|
||||
<option value="">None</option>
|
||||
<?php foreach ($users as $u): ?>
|
||||
<option value="<?php echo $u->id; ?>" <?php echo (int)$c->auto_assign_user === (int)$u->id ? 'selected' : ''; ?>><?php echo htmlspecialchars($u->name); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" class="form-check-input cat-field" data-field="published" <?php echo $c->published ? 'checked' : ''; ?>>
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-sm btn-outline-success btn-save-cat" title="Save"><span class="icon-save"></span></button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-cat" title="Delete"><span class="icon-trash"></span></button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var token = '<?php echo $token; ?>';
|
||||
|
||||
// Save category
|
||||
document.querySelectorAll('.btn-save-cat').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var row = this.closest('tr');
|
||||
var id = row.dataset.id || '0';
|
||||
var fd = new FormData();
|
||||
fd.append('id', id);
|
||||
fd.append(token, '1');
|
||||
row.querySelectorAll('.cat-field').forEach(function(f) {
|
||||
fd.append(f.dataset.field, f.type === 'checkbox' ? (f.checked ? '1' : '0') : f.value);
|
||||
});
|
||||
fetch('<?php echo $saveUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){
|
||||
if (d.success) { Joomla.renderMessages({message:[d.message]}); if (d.id && id === '0') row.dataset.id = d.id; }
|
||||
else Joomla.renderMessages({error:[d.message]});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Delete category
|
||||
document.querySelectorAll('.btn-delete-cat').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
if (!confirm('Delete this category?')) return;
|
||||
var row = this.closest('tr');
|
||||
var fd = new FormData();
|
||||
fd.append('id', row.dataset.id);
|
||||
fd.append(token, '1');
|
||||
fetch('<?php echo $deleteUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){
|
||||
if (d.success) row.remove();
|
||||
else Joomla.renderMessages({error:[d.message]});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Add new row
|
||||
document.getElementById('btn-add-cat').addEventListener('click', function() {
|
||||
var tbody = document.querySelector('#cat-table tbody');
|
||||
var tr = document.createElement('tr');
|
||||
tr.dataset.id = '0';
|
||||
tr.innerHTML = '<td><input type="text" class="form-control form-control-sm cat-field" data-field="title" value=""></td>'
|
||||
+ '<td><input type="number" class="form-control form-control-sm cat-field" data-field="sla_response_minutes" value="480" style="width:80px"> min</td>'
|
||||
+ '<td><input type="number" class="form-control form-control-sm cat-field" data-field="sla_resolution_minutes" value="2880" style="width:80px"> min</td>'
|
||||
+ '<td><select class="form-select form-select-sm cat-field" data-field="auto_assign_user"><option value="">None</option><?php foreach ($users as $u): ?><option value="<?php echo $u->id; ?>"><?php echo htmlspecialchars($u->name); ?></option><?php endforeach; ?></select></td>'
|
||||
+ '<td><input type="checkbox" class="form-check-input cat-field" data-field="published" checked></td>'
|
||||
+ '<td><button type="button" class="btn btn-sm btn-outline-success btn-save-cat"><span class="icon-save"></span></button></td>';
|
||||
tbody.appendChild(tr);
|
||||
tr.querySelector('.btn-save-cat').addEventListener('click', function() {
|
||||
var row = this.closest('tr');
|
||||
var fd = new FormData();
|
||||
fd.append('id', '0');
|
||||
fd.append(token, '1');
|
||||
row.querySelectorAll('.cat-field').forEach(function(f) {
|
||||
fd.append(f.dataset.field, f.type === 'checkbox' ? (f.checked ? '1' : '0') : f.value);
|
||||
});
|
||||
fetch('<?php echo $saveUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){
|
||||
if (d.success) { Joomla.renderMessages({message:[d.message]}); location.reload(); }
|
||||
else Joomla.renderMessages({error:[d.message]});
|
||||
});
|
||||
});
|
||||
tr.querySelector('input').focus();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$dirs = $this->dirs;
|
||||
$token = Session::getFormToken();
|
||||
$cleanUrl = Route::_('index.php?option=com_mokowaas&task=display.cleanDirectory&format=json');
|
||||
|
||||
$dirKeys = ['site_cache', 'admin_cache', 'tmp', 'logs'];
|
||||
$totalMb = 0;
|
||||
$totalFiles = 0;
|
||||
foreach ($dirs as $d) { $totalMb += $d->size_mb; $totalFiles += $d->files; }
|
||||
?>
|
||||
|
||||
<div id="mokowaas-cleanup">
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-3"><div class="card text-center p-3"><span class="fw-bold fs-3"><?php echo number_format($totalMb, 1); ?> MB</span><small class="text-muted">Total Size</small></div></div>
|
||||
<div class="col-6 col-md-3"><div class="card text-center p-3"><span class="fw-bold fs-3"><?php echo number_format($totalFiles); ?></span><small class="text-muted">Total Files</small></div></div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<?php foreach ($dirs as $i => $d): ?>
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<h5><?php echo htmlspecialchars($d->label); ?></h5>
|
||||
<p class="fs-3 fw-bold mb-1 <?php echo $d->size_mb > 50 ? 'text-warning' : ''; ?>"><?php echo number_format($d->size_mb, 1); ?> MB</p>
|
||||
<p class="text-muted small"><?php echo number_format($d->files); ?> files</p>
|
||||
<?php if (!$d->writable): ?>
|
||||
<span class="badge bg-danger">Not writable</span>
|
||||
<?php else: ?>
|
||||
<button type="button" class="btn btn-outline-danger btn-clean" data-key="<?php echo $dirKeys[$i] ?? ''; ?>" data-label="<?php echo htmlspecialchars($d->label); ?>">
|
||||
<span class="icon-trash"></span> Clean
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.btn-clean').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
if (!confirm('Clean all files in ' + this.dataset.label + '?')) return;
|
||||
var el = this;
|
||||
el.disabled = true;
|
||||
var fd = new FormData();
|
||||
fd.append('dir_key', el.dataset.key);
|
||||
fd.append('<?php echo $token; ?>', '1');
|
||||
fetch('<?php echo $cleanUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){
|
||||
if (d.success) { Joomla.renderMessages({message:[d.message]}); setTimeout(function(){location.reload()},1500); }
|
||||
else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; }
|
||||
})
|
||||
.catch(function(){ el.disabled = false; });
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -8,15 +8,22 @@
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
/** @var \Moko\Component\MokoWaaS\Administrator\View\Dashboard\HtmlView $this */
|
||||
|
||||
$siteInfo = $this->siteInfo;
|
||||
$plugins = $this->plugins;
|
||||
$token = Session::getFormToken();
|
||||
$siteInfo = $this->siteInfo;
|
||||
$plugins = $this->plugins;
|
||||
$recentLogins = $this->recentLogins;
|
||||
$pendingUpdates = $this->pendingUpdates;
|
||||
$adminToolsAvail = $this->adminToolsAvailable ?? null;
|
||||
$atsAvail = $this->atsAvailable ?? null;
|
||||
$checkedOut = $this->checkedOutItems;
|
||||
$wafBlocks = $this->wafBlocks;
|
||||
$token = Session::getFormToken();
|
||||
|
||||
// Group plugins by category
|
||||
$grouped = [];
|
||||
@@ -25,7 +32,6 @@ foreach ($plugins as $plugin)
|
||||
$grouped[$plugin->category][] = $plugin;
|
||||
}
|
||||
|
||||
// Category display order
|
||||
$categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
|
||||
?>
|
||||
|
||||
@@ -54,101 +60,366 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
|
||||
<span class="mokowaas-info-value"><span class="badge bg-secondary"><?php echo $this->escape($siteInfo->db_type); ?></span></span>
|
||||
</div>
|
||||
<?php if ($siteInfo->debug): ?>
|
||||
<div class="mokowaas-info-item">
|
||||
<span class="badge bg-warning text-dark"><?php echo Text::_('COM_MOKOWAAS_DEBUG_ON'); ?></span>
|
||||
</div>
|
||||
<span class="badge bg-warning text-dark"><?php echo Text::_('COM_MOKOWAAS_DEBUG_ON'); ?></span>
|
||||
<?php endif; ?>
|
||||
<?php if ($siteInfo->offline): ?>
|
||||
<div class="mokowaas-info-item">
|
||||
<span class="badge bg-danger"><?php echo Text::_('COM_MOKOWAAS_OFFLINE'); ?></span>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="mokowaas-quick-actions mb-4">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" id="mokowaas-btn-cache"
|
||||
<?php if ($adminToolsAvail || $atsAvail): ?>
|
||||
<!-- Akeeba Import Banner -->
|
||||
<div class="alert alert-info d-flex flex-wrap align-items-center gap-3 mb-4">
|
||||
<span class="icon-info-circle" style="font-size:1.25rem"></span>
|
||||
<strong>Akeeba data detected — import into MokoWaaS:</strong>
|
||||
<?php if ($adminToolsAvail): ?>
|
||||
<button type="button" class="btn btn-sm btn-info" id="btn-import-admintools"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.importAdminTools&format=json'); ?>"
|
||||
data-token="<?php echo $token; ?>">
|
||||
<span class="icon-shield-alt"></span> Import Admin Tools Settings
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<?php if ($atsAvail): ?>
|
||||
<button type="button" class="btn btn-sm btn-info" id="btn-import-ats-dash"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.importAts&format=json'); ?>"
|
||||
data-token="<?php echo $token; ?>">
|
||||
<span class="icon-headphones"></span> Import Tickets (<?php echo $atsAvail->tickets; ?> tickets)
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Quick Actions (large buttons) -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-4 col-xl-3">
|
||||
<button type="button" class="btn btn-outline-primary w-100 py-3" id="mokowaas-btn-cache"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.clearCache&format=json'); ?>"
|
||||
data-token="<?php echo $token; ?>">
|
||||
<span class="icon-trash" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOWAAS_CLEAR_CACHE'); ?>
|
||||
<span class="icon-bolt d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
|
||||
Clear Cache
|
||||
</button>
|
||||
<a href="<?php echo Route::_('index.php?option=com_installer&view=update'); ?>" class="btn btn-outline-primary btn-sm">
|
||||
<span class="icon-refresh" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOWAAS_CHECK_UPDATES'); ?>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-xl-3">
|
||||
<a href="<?php echo Route::_('index.php?option=com_installer&view=update'); ?>" class="btn btn-outline-primary w-100 py-3">
|
||||
<span class="icon-refresh d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
|
||||
Check Updates
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-xl-3">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokowaas&view=extensions'); ?>" class="btn btn-outline-primary w-100 py-3">
|
||||
<span class="icon-puzzle-piece d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
|
||||
Moko Extensions
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-xl-3">
|
||||
<a href="<?php echo Route::_('index.php?option=com_checkin'); ?>" class="btn btn-outline-secondary w-100 py-3">
|
||||
<span class="icon-check-square d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
|
||||
Global Check-in
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-xl-3">
|
||||
<a href="<?php echo Route::_('index.php?option=com_actionlogs'); ?>" class="btn btn-outline-secondary w-100 py-3">
|
||||
<span class="icon-list d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
|
||||
View Logs
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-xl-3">
|
||||
<a href="<?php echo Route::_('index.php?option=com_scheduler'); ?>" class="btn btn-outline-secondary w-100 py-3">
|
||||
<span class="icon-clock d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
|
||||
Scheduled Tasks
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-xl-3">
|
||||
<?php
|
||||
// Use 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 ? '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>
|
||||
<?php echo $userLabel; ?>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-xl-3">
|
||||
<a href="<?php echo Route::_('index.php?option=com_redirect'); ?>" class="btn btn-outline-secondary w-100 py-3">
|
||||
<span class="icon-arrow-right d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
|
||||
Redirects
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Feature Plugin Grid -->
|
||||
<?php foreach ($categoryOrder as $catKey): ?>
|
||||
<?php if (empty($grouped[$catKey])) continue; ?>
|
||||
<?php
|
||||
$catPlugins = $grouped[$catKey];
|
||||
$first = $catPlugins[0];
|
||||
?>
|
||||
<h3 class="mokowaas-category-heading mb-3">
|
||||
<span class="badge <?php echo $this->escape($first->categoryBadge); ?>"><?php echo $this->escape($first->categoryLabel); ?></span>
|
||||
</h3>
|
||||
<div class="mokowaas-plugin-grid row g-3 mb-4">
|
||||
<?php foreach ($catPlugins as $plugin): ?>
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<div class="card mokowaas-plugin-card h-100 <?php echo $plugin->enabled ? '' : 'mokowaas-plugin-disabled'; ?>"
|
||||
data-extension-id="<?php echo $plugin->extension_id; ?>">
|
||||
<div class="card-body d-flex flex-column">
|
||||
<div class="d-flex align-items-start justify-content-between mb-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="<?php echo $this->escape($plugin->icon); ?> mokowaas-plugin-icon" aria-hidden="true"></span>
|
||||
<h5 class="card-title mb-0"><?php echo $this->escape($plugin->name); ?></h5>
|
||||
</div>
|
||||
<?php if ($plugin->version): ?>
|
||||
<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>
|
||||
<div class="d-flex align-items-center justify-content-between mt-auto pt-2 border-top">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<?php if ($plugin->protected): ?>
|
||||
<span class="badge bg-dark" title="<?php echo Text::_('COM_MOKOWAAS_PROTECTED'); ?>"><?php echo Text::_('COM_MOKOWAAS_PROTECTED'); ?></span>
|
||||
<?php else: ?>
|
||||
<div class="form-check form-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-check-input mokowaas-toggle"
|
||||
role="switch"
|
||||
id="toggle-<?php echo $plugin->extension_id; ?>"
|
||||
data-extension-id="<?php echo $plugin->extension_id; ?>"
|
||||
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; ?>">
|
||||
<?php echo $plugin->enabled ? Text::_('COM_MOKOWAAS_ENABLED') : Text::_('COM_MOKOWAAS_DISABLED'); ?>
|
||||
</label>
|
||||
<!-- Three-column layout: plugins left, tables right -->
|
||||
<div class="row">
|
||||
<!-- Left: Feature Plugin Grid (8 cols) -->
|
||||
<div class="col-12 col-xl-8">
|
||||
<?php foreach ($categoryOrder as $catKey): ?>
|
||||
<?php if (empty($grouped[$catKey])) continue; ?>
|
||||
<?php
|
||||
$catPlugins = $grouped[$catKey];
|
||||
$first = $catPlugins[0];
|
||||
?>
|
||||
<h3 class="mokowaas-category-heading mb-3">
|
||||
<span class="badge <?php echo $this->escape($first->categoryBadge); ?>"><?php echo $this->escape($first->categoryLabel); ?></span>
|
||||
</h3>
|
||||
<div class="mokowaas-plugin-grid row g-3 mb-4">
|
||||
<?php foreach ($catPlugins as $plugin): ?>
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="card mokowaas-plugin-card h-100 <?php echo $plugin->enabled ? '' : 'mokowaas-plugin-disabled'; ?>"
|
||||
data-extension-id="<?php echo $plugin->extension_id; ?>">
|
||||
<div class="card-body d-flex flex-column">
|
||||
<div class="d-flex align-items-start justify-content-between mb-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="<?php echo $this->escape($plugin->icon); ?> mokowaas-plugin-icon" aria-hidden="true"></span>
|
||||
<h5 class="card-title mb-0"><?php echo $this->escape($plugin->name); ?></h5>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($plugin->version): ?>
|
||||
<span class="badge bg-light text-dark"><?php echo $this->escape($plugin->version); ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<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>
|
||||
<?php elseif ($plugin->configure_only): ?>
|
||||
<span class="badge bg-<?php echo $plugin->enabled ? 'success' : 'secondary'; ?>">
|
||||
<?php echo $plugin->enabled ? Text::_('COM_MOKOWAAS_ENABLED') : Text::_('COM_MOKOWAAS_DISABLED'); ?>
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" class="form-check-input mokowaas-toggle" role="switch"
|
||||
id="toggle-<?php echo $plugin->extension_id; ?>"
|
||||
data-extension-id="<?php echo $plugin->extension_id; ?>"
|
||||
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" for="toggle-<?php echo $plugin->extension_id; ?>">
|
||||
<?php echo $plugin->enabled ? Text::_('COM_MOKOWAAS_ENABLED') : Text::_('COM_MOKOWAAS_DISABLED'); ?>
|
||||
</label>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($plugin->type === 'plugin'): ?>
|
||||
<a href="<?php echo Route::_('index.php?option=com_plugins&task=plugin.edit&extension_id=' . $plugin->extension_id); ?>" class="btn btn-sm btn-outline-secondary">
|
||||
<span class="icon-cog" aria-hidden="true"></span> <?php echo Text::_('COM_MOKOWAAS_CONFIGURE'); ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
// Build configure link
|
||||
$configUrl = '';
|
||||
if ($plugin->type === 'plugin')
|
||||
{
|
||||
$configUrl = Route::_('index.php?option=com_plugins&task=plugin.edit&extension_id=' . $plugin->extension_id);
|
||||
}
|
||||
?>
|
||||
<?php if ($configUrl): ?>
|
||||
<a href="<?php echo $configUrl; ?>" class="btn btn-sm btn-outline-secondary">
|
||||
<span class="icon-cog" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOWAAS_CONFIGURE'); ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<!-- 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">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong><span class="icon-refresh" aria-hidden="true"></span> Pending Updates</strong>
|
||||
<span class="badge bg-<?php echo count($pendingUpdates) > 0 ? 'warning text-dark' : 'success'; ?>"><?php echo count($pendingUpdates); ?></span>
|
||||
</div>
|
||||
<?php if (!empty($pendingUpdates)): ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped mb-0">
|
||||
<thead><tr><th>Extension</th><th>Current</th><th>Available</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($pendingUpdates as $upd): ?>
|
||||
<tr>
|
||||
<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 py-3">
|
||||
<span class="icon-check-circle text-success"></span> All extensions up to date
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Checked Out Items -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong><span class="icon-lock" aria-hidden="true"></span> Checked Out Items</strong>
|
||||
<span class="badge bg-<?php echo count($checkedOut) > 0 ? 'info' : 'success'; ?>"><?php echo count($checkedOut); ?></span>
|
||||
</div>
|
||||
<?php if (!empty($checkedOut)): ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped mb-0">
|
||||
<thead><tr><th>Article</th><th>User</th><th>Since</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($checkedOut as $item): ?>
|
||||
<tr>
|
||||
<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="text-muted">Global Check-in</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<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; ?>
|
||||
</div>
|
||||
|
||||
<!-- WAF Blocks -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong><span class="icon-shield-alt" aria-hidden="true"></span> Recent WAF Blocks</strong>
|
||||
<span class="badge bg-<?php echo count($wafBlocks) > 0 ? 'danger' : 'success'; ?>"><?php echo count($wafBlocks); ?></span>
|
||||
</div>
|
||||
<?php if (!empty($wafBlocks)): ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped mb-0">
|
||||
<thead><tr><th>IP</th><th>Rule</th><th>Time</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($wafBlocks as $block): ?>
|
||||
<tr>
|
||||
<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 py-3">
|
||||
<span class="icon-check-circle text-success"></span> No recent blocks
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Recent Logins -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<strong><span class="icon-user" aria-hidden="true"></span> Recent Logins</strong>
|
||||
</div>
|
||||
<?php if (!empty($recentLogins)): ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped mb-0">
|
||||
<thead><tr><th>User</th><th>IP</th><th>Time</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($recentLogins as $login): ?>
|
||||
<tr>
|
||||
<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 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>
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
/** @var \Moko\Component\MokoWaaS\Administrator\View\Extensions\HtmlView $this */
|
||||
|
||||
$packages = $this->packages;
|
||||
$token = Session::getFormToken();
|
||||
|
||||
// Group by category
|
||||
$grouped = [];
|
||||
foreach ($packages as $pkg)
|
||||
{
|
||||
$grouped[$pkg->category][] = $pkg;
|
||||
}
|
||||
|
||||
$statusBadge = [
|
||||
'installed' => ['bg-success', 'Installed'],
|
||||
'not_installed' => ['bg-secondary', 'Not Installed'],
|
||||
];
|
||||
?>
|
||||
|
||||
<div id="mokowaas-extensions">
|
||||
<div class="alert alert-info">
|
||||
<span class="icon-info-circle" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOWAAS_EXTENSIONS_INFO'); ?>
|
||||
</div>
|
||||
|
||||
<?php foreach ($grouped as $category => $pkgs): ?>
|
||||
<h3 class="mb-3"><?php echo htmlspecialchars($category); ?></h3>
|
||||
<div class="row g-3 mb-4">
|
||||
<?php foreach ($pkgs as $pkg): ?>
|
||||
<?php
|
||||
$badge = $statusBadge[$pkg->status] ?? $statusBadge['not_installed'];
|
||||
?>
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body d-flex flex-column">
|
||||
<div class="d-flex align-items-start justify-content-between mb-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="<?php echo htmlspecialchars($pkg->icon); ?>" aria-hidden="true" style="font-size:1.5rem;color:#1a2744"></span>
|
||||
<div>
|
||||
<h5 class="card-title mb-0"><?php echo htmlspecialchars($pkg->label); ?></h5>
|
||||
<small class="text-muted"><?php echo htmlspecialchars($pkg->type); ?></small>
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge <?php echo $badge[0]; ?>"><?php echo $badge[1]; ?></span>
|
||||
</div>
|
||||
|
||||
<p class="card-text text-muted flex-grow-1"><?php echo htmlspecialchars($pkg->description); ?></p>
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mt-auto pt-2 border-top">
|
||||
<div class="small text-muted">
|
||||
<?php if ($pkg->local_version): ?>
|
||||
v<?php echo htmlspecialchars($pkg->local_version); ?>
|
||||
<?php elseif ($pkg->remote_version): ?>
|
||||
Latest: <?php echo htmlspecialchars($pkg->remote_version); ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="d-flex gap-1">
|
||||
<?php if ($pkg->article_url): ?>
|
||||
<a href="<?php echo htmlspecialchars($pkg->article_url); ?>" target="_blank" class="btn btn-sm btn-outline-secondary" title="Documentation">
|
||||
<span class="icon-book" aria-hidden="true"></span>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<?php if ($pkg->download_url && $pkg->status === 'not_installed'): ?>
|
||||
<button type="button" class="btn btn-sm btn-primary mokowaas-install-btn"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.installExtension&format=json'); ?>"
|
||||
data-download="<?php echo htmlspecialchars($pkg->download_url); ?>"
|
||||
data-token="<?php echo $token; ?>"
|
||||
data-label="<?php echo htmlspecialchars($pkg->label); ?>">
|
||||
<span class="icon-download" aria-hidden="true"></span>
|
||||
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>
|
||||
<?php if (!$pkg->protected && $pkg->extension_id): ?>
|
||||
<a href="<?php echo Route::_('index.php?option=com_installer&task=manage.remove&cid[]=' . $pkg->extension_id . '&' . $token . '=1'); ?>"
|
||||
class="btn btn-sm btn-outline-danger"
|
||||
onclick="return confirm('Uninstall <?php echo htmlspecialchars($pkg->label); ?>?')"
|
||||
title="Uninstall">
|
||||
<span class="icon-times" aria-hidden="true"></span>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<span class="btn btn-sm btn-outline-secondary disabled">No release</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.mokowaas-install-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var el = this;
|
||||
var url = el.dataset.url;
|
||||
var downloadUrl = el.dataset.download;
|
||||
var token = el.dataset.token;
|
||||
var label = el.dataset.label;
|
||||
|
||||
if (!confirm('Install ' + label + '?')) return;
|
||||
|
||||
el.disabled = true;
|
||||
var origHtml = el.textContent;
|
||||
el.textContent = ' Installing...';
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('download_url', downloadUrl);
|
||||
fd.append(token, '1');
|
||||
|
||||
fetch(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: [label + ': ' + d.message]});
|
||||
location.reload();
|
||||
} else {
|
||||
Joomla.renderMessages({error: [label + ': ' + (d.message || 'Failed')]});
|
||||
el.disabled = false;
|
||||
el.textContent = origHtml;
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
Joomla.renderMessages({error: ['Network error']});
|
||||
el.disabled = false;
|
||||
el.textContent = origHtml;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,306 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$opts = $this->options;
|
||||
$preview = $this->preview;
|
||||
$nginx = $this->nginxPreview;
|
||||
$current = $this->currentHtaccess;
|
||||
$token = Session::getFormToken();
|
||||
$saveUrl = Route::_('index.php?option=com_mokowaas&task=display.saveHtaccess&format=json');
|
||||
$genUrl = Route::_('index.php?option=com_mokowaas&task=display.generateHtaccess&format=json');
|
||||
|
||||
// Helper for toggle switch
|
||||
$sw = function($name, $label, $desc = '') use ($opts) {
|
||||
$checked = !empty($opts[$name]) ? 'checked' : '';
|
||||
echo '<div class="d-flex justify-content-between align-items-center py-2 border-bottom">';
|
||||
echo '<div><strong>' . htmlspecialchars($label) . '</strong>';
|
||||
if ($desc) echo '<br><small class="text-muted">' . htmlspecialchars($desc) . '</small>';
|
||||
echo '</div>';
|
||||
echo '<div class="form-check form-switch">';
|
||||
echo '<input type="checkbox" class="form-check-input htaccess-opt" name="' . $name . '" id="htopt-' . $name . '" ' . $checked . '>';
|
||||
echo '</div></div>';
|
||||
};
|
||||
?>
|
||||
|
||||
<div id="mokowaas-htaccess">
|
||||
<ul class="nav nav-tabs mb-3" role="tablist">
|
||||
<li class="nav-item"><a class="nav-link active" data-bs-toggle="tab" href="#tab-htaccess" role="tab">.htaccess</a></li>
|
||||
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-nginx" role="tab">NginX</a></li>
|
||||
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-current" role="tab">Current File</a></li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
<!-- .htaccess Tab -->
|
||||
<div class="tab-pane fade show active" id="tab-htaccess" role="tabpanel">
|
||||
<div class="row">
|
||||
<!-- Left: Options -->
|
||||
<div class="col-12 col-xl-6">
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong><span class="icon-shield-alt"></span> Security</strong></div>
|
||||
<div class="card-body">
|
||||
<?php $sw('disable_directory_listing', 'Disable Directory Listing', 'Options -Indexes'); ?>
|
||||
<?php $sw('block_sensitive_files', 'Block Sensitive Files', 'htaccess.txt, configuration.php-dist, etc.'); ?>
|
||||
<?php $sw('block_php_in_uploads', 'Block PHP in Uploads', 'Prevent .php in images/, media/, tmp/'); ?>
|
||||
<?php $sw('disable_server_signature', 'Hide Server Signature', 'ServerSignature Off, remove X-Powered-By'); ?>
|
||||
<?php $sw('prevent_clickjacking', 'Clickjacking Protection', 'X-Frame-Options: SAMEORIGIN'); ?>
|
||||
<?php $sw('prevent_mime_sniffing', 'MIME Sniffing Prevention', 'X-Content-Type-Options: nosniff'); ?>
|
||||
<?php $sw('xss_protection', 'XSS Protection Header', 'X-XSS-Protection: 1; mode=block'); ?>
|
||||
<?php $sw('disable_trace_track', 'Disable TRACE/TRACK', 'Block HTTP TRACE and TRACK methods'); ?>
|
||||
|
||||
<div class="py-2 border-bottom">
|
||||
<label class="form-label fw-bold" for="htopt-referrer_policy">Referrer Policy</label>
|
||||
<select class="form-select form-select-sm htaccess-opt" name="referrer_policy" id="htopt-referrer_policy">
|
||||
<option value="off" <?php echo ($opts['referrer_policy'] ?? '') === 'off' ? 'selected' : ''; ?>>Off</option>
|
||||
<option value="no-referrer" <?php echo ($opts['referrer_policy'] ?? '') === 'no-referrer' ? 'selected' : ''; ?>>no-referrer</option>
|
||||
<option value="same-origin" <?php echo ($opts['referrer_policy'] ?? '') === 'same-origin' ? 'selected' : ''; ?>>same-origin</option>
|
||||
<option value="strict-origin-when-cross-origin" <?php echo ($opts['referrer_policy'] ?? '') === 'strict-origin-when-cross-origin' ? 'selected' : ''; ?>>strict-origin-when-cross-origin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<?php $sw('hsts_enabled', 'HSTS (Force HTTPS)', 'Strict-Transport-Security header'); ?>
|
||||
<div class="ps-4 <?php echo empty($opts['hsts_enabled']) ? 'd-none' : ''; ?>" id="hsts-options">
|
||||
<div class="row g-2 py-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label small">Max Age (seconds)</label>
|
||||
<input type="number" class="form-control form-control-sm htaccess-opt" name="hsts_max_age" value="<?php echo (int) ($opts['hsts_max_age'] ?? 31536000); ?>">
|
||||
</div>
|
||||
<div class="col-6 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input htaccess-opt" name="hsts_subdomains" id="htopt-hsts_sub" <?php echo !empty($opts['hsts_subdomains']) ? 'checked' : ''; ?>>
|
||||
<label class="form-check-label small" for="htopt-hsts_sub">Include Subdomains</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php $sw('csp_enabled', 'Content Security Policy', 'CSP header'); ?>
|
||||
<div class="ps-4 <?php echo empty($opts['csp_enabled']) ? 'd-none' : ''; ?>" id="csp-options">
|
||||
<textarea class="form-control form-control-sm htaccess-opt mt-1" name="csp_value" rows="2" placeholder="default-src 'self'; script-src 'self' 'unsafe-inline'"><?php echo htmlspecialchars($opts['csp_value'] ?? ''); ?></textarea>
|
||||
</div>
|
||||
|
||||
<?php $sw('permissions_policy', 'Permissions Policy', 'Camera, microphone, geolocation controls'); ?>
|
||||
<div class="ps-4 <?php echo empty($opts['permissions_policy']) ? 'd-none' : ''; ?>" id="perms-options">
|
||||
<textarea class="form-control form-control-sm htaccess-opt mt-1" name="permissions_value" rows="2" placeholder="camera=(), microphone=(), geolocation=()"><?php echo htmlspecialchars($opts['permissions_value'] ?? ''); ?></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong><span class="icon-bolt"></span> Performance</strong></div>
|
||||
<div class="card-body">
|
||||
<?php $sw('enable_gzip', 'GZip Compression', 'Compress CSS, JS, HTML, XML, JSON'); ?>
|
||||
<?php $sw('enable_expires', 'Browser Caching', 'Set expiration headers for static files'); ?>
|
||||
<div class="ps-4 <?php echo empty($opts['enable_expires']) ? 'd-none' : ''; ?>" id="expires-options">
|
||||
<div class="row g-2 py-2">
|
||||
<div class="col-4">
|
||||
<label class="form-label small">HTML (sec)</label>
|
||||
<input type="number" class="form-control form-control-sm htaccess-opt" name="expires_html" value="<?php echo (int) ($opts['expires_html'] ?? 3600); ?>">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label small">CSS/JS (sec)</label>
|
||||
<input type="number" class="form-control form-control-sm htaccess-opt" name="expires_css_js" value="<?php echo (int) ($opts['expires_css_js'] ?? 2592000); ?>">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label small">Images (sec)</label>
|
||||
<input type="number" class="form-control form-control-sm htaccess-opt" name="expires_images" value="<?php echo (int) ($opts['expires_images'] ?? 31536000); ?>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php $sw('etag_control', 'Disable ETags', 'For load-balanced environments'); ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong><span class="icon-search"></span> SEO / Redirects</strong></div>
|
||||
<div class="card-body">
|
||||
<div class="py-2 border-bottom">
|
||||
<label class="form-label fw-bold">WWW Redirect</label>
|
||||
<select class="form-select form-select-sm htaccess-opt" name="www_redirect">
|
||||
<option value="off" <?php echo ($opts['www_redirect'] ?? 'off') === 'off' ? 'selected' : ''; ?>>Off</option>
|
||||
<option value="www" <?php echo ($opts['www_redirect'] ?? '') === 'www' ? 'selected' : ''; ?>>Force www</option>
|
||||
<option value="non-www" <?php echo ($opts['www_redirect'] ?? '') === 'non-www' ? 'selected' : ''; ?>>Force non-www</option>
|
||||
</select>
|
||||
</div>
|
||||
<?php $sw('redirect_index_php', 'Redirect /index.php to /', 'SEO-friendly root redirect'); ?>
|
||||
<?php $sw('force_trailing_slash', 'Force Trailing Slash', 'Append / to URLs without file extension'); ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong><span class="icon-code"></span> Custom Rules</strong></div>
|
||||
<div class="card-body">
|
||||
<textarea class="form-control htaccess-opt" name="custom_rules" rows="4" placeholder="# Add custom Apache directives here"><?php echo htmlspecialchars($opts['custom_rules'] ?? ''); ?></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Preview -->
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card mb-3 sticky-top" style="top:1rem">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>Preview</strong>
|
||||
<span class="badge bg-secondary" id="htaccess-line-count"><?php echo substr_count($preview, "\n"); ?> lines</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<textarea id="htaccess-preview" class="form-control font-monospace border-0" rows="30" readonly style="font-size:0.8rem;resize:none"><?php echo htmlspecialchars($preview); ?></textarea>
|
||||
</div>
|
||||
<div class="card-footer d-flex gap-2">
|
||||
<button type="button" class="btn btn-primary" id="htaccess-save"
|
||||
data-url="<?php echo $saveUrl; ?>" data-token="<?php echo $token; ?>">
|
||||
<span class="icon-save"></span> Save to .htaccess
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" id="htaccess-download">
|
||||
<span class="icon-download"></span> Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NginX Tab -->
|
||||
<div class="tab-pane fade" id="tab-nginx" role="tabpanel">
|
||||
<div class="card">
|
||||
<div class="card-header"><strong>NginX Configuration Snippet</strong></div>
|
||||
<div class="card-body p-0">
|
||||
<textarea id="nginx-preview" class="form-control font-monospace border-0" rows="25" readonly style="font-size:0.8rem;resize:none"><?php echo htmlspecialchars($nginx); ?></textarea>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" id="nginx-download">
|
||||
<span class="icon-download"></span> Download NginX Config
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current File Tab -->
|
||||
<div class="tab-pane fade" id="tab-current" role="tabpanel">
|
||||
<div class="card">
|
||||
<div class="card-header"><strong>Current .htaccess on Disk</strong></div>
|
||||
<div class="card-body p-0">
|
||||
<textarea class="form-control font-monospace border-0" rows="25" readonly style="font-size:0.8rem;resize:none"><?php echo htmlspecialchars($current); ?></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var saveBtn = document.getElementById('htaccess-save');
|
||||
var preview = document.getElementById('htaccess-preview');
|
||||
var lineCount = document.getElementById('htaccess-line-count');
|
||||
|
||||
// Toggle sub-option visibility
|
||||
document.getElementById('htopt-hsts_enabled').addEventListener('change', function() {
|
||||
document.getElementById('hsts-options').classList.toggle('d-none', !this.checked);
|
||||
});
|
||||
document.getElementById('htopt-csp_enabled').addEventListener('change', function() {
|
||||
document.getElementById('csp-options').classList.toggle('d-none', !this.checked);
|
||||
});
|
||||
document.getElementById('htopt-permissions_policy').addEventListener('change', function() {
|
||||
document.getElementById('perms-options').classList.toggle('d-none', !this.checked);
|
||||
});
|
||||
document.getElementById('htopt-enable_expires') && document.getElementById('htopt-enable_expires').addEventListener('change', function() {
|
||||
document.getElementById('expires-options').classList.toggle('d-none', !this.checked);
|
||||
});
|
||||
|
||||
// Regenerate preview on any option change
|
||||
document.querySelectorAll('.htaccess-opt').forEach(function(el) {
|
||||
el.addEventListener('change', regeneratePreview);
|
||||
el.addEventListener('input', regeneratePreview);
|
||||
});
|
||||
|
||||
function collectOptions() {
|
||||
var opts = {};
|
||||
document.querySelectorAll('.htaccess-opt').forEach(function(el) {
|
||||
if (el.type === 'checkbox') {
|
||||
opts[el.name] = el.checked ? 1 : 0;
|
||||
} else {
|
||||
opts[el.name] = el.value;
|
||||
}
|
||||
});
|
||||
return opts;
|
||||
}
|
||||
|
||||
var debounceTimer;
|
||||
function regeneratePreview() {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(function() {
|
||||
var fd = new FormData();
|
||||
var opts = collectOptions();
|
||||
for (var k in opts) fd.append(k, opts[k]);
|
||||
fd.append('<?php echo $token; ?>', '1');
|
||||
|
||||
fetch('<?php echo $genUrl; ?>', {
|
||||
method: 'POST', body: fd,
|
||||
headers: {'X-Requested-With': 'XMLHttpRequest'}
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.htaccess) {
|
||||
preview.value = d.htaccess;
|
||||
lineCount.textContent = d.htaccess.split('\n').length + ' lines';
|
||||
}
|
||||
if (d.nginx) {
|
||||
document.getElementById('nginx-preview').value = d.nginx;
|
||||
}
|
||||
});
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Save to disk
|
||||
saveBtn.addEventListener('click', function() {
|
||||
if (!confirm('This will overwrite your current .htaccess file. A backup will be created at .htaccess.mokowaas.bak. Continue?')) return;
|
||||
var btn = this;
|
||||
btn.disabled = true;
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('content', preview.value);
|
||||
var opts = collectOptions();
|
||||
for (var k in opts) fd.append('opt_' + k, opts[k]);
|
||||
fd.append('<?php echo $token; ?>', '1');
|
||||
|
||||
fetch(btn.dataset.url, {
|
||||
method: 'POST', body: fd,
|
||||
headers: {'X-Requested-With': 'XMLHttpRequest'}
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.success) Joomla.renderMessages({message: [d.message]});
|
||||
else Joomla.renderMessages({error: [d.message]});
|
||||
})
|
||||
.catch(function() { Joomla.renderMessages({error: ['Network error']}); })
|
||||
.finally(function() { btn.disabled = false; });
|
||||
});
|
||||
|
||||
// Download buttons
|
||||
function downloadText(content, filename) {
|
||||
var blob = new Blob([content], {type: 'text/plain'});
|
||||
var a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
|
||||
document.getElementById('htaccess-download').addEventListener('click', function() {
|
||||
downloadText(preview.value, '.htaccess');
|
||||
});
|
||||
document.getElementById('nginx-download').addEventListener('click', function() {
|
||||
downloadText(document.getElementById('nginx-preview').value, 'mokowaas-nginx.conf');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,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>
|
||||
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$t = $this->ticket;
|
||||
$canned = $this->cannedResponses;
|
||||
$token = Session::getFormToken();
|
||||
|
||||
$statusBadge = [
|
||||
'open' => 'bg-primary', 'in_progress' => 'bg-info',
|
||||
'waiting' => 'bg-warning text-dark', 'resolved' => 'bg-success', 'closed' => 'bg-secondary',
|
||||
];
|
||||
$priorityBadge = [
|
||||
'low' => 'bg-secondary', 'normal' => 'bg-primary', 'high' => 'bg-warning text-dark', 'urgent' => 'bg-danger',
|
||||
];
|
||||
?>
|
||||
|
||||
<div id="mokowaas-ticket" class="row">
|
||||
<!-- Left: conversation thread -->
|
||||
<div class="col-12 col-xl-8">
|
||||
<!-- Original ticket -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong><?php echo $this->escape($t->created_by_name); ?></strong>
|
||||
<small class="text-muted ms-2"><?php echo HTMLHelper::_('date', $t->created, 'M d, Y H:i'); ?></small>
|
||||
</div>
|
||||
<span class="badge bg-dark">Original</span>
|
||||
</div>
|
||||
<div class="card-body"><?php echo nl2br($this->escape($t->body)); ?></div>
|
||||
</div>
|
||||
|
||||
<!-- Replies -->
|
||||
<?php foreach ($t->replies as $reply): ?>
|
||||
<div class="card mb-3 <?php echo $reply->is_internal ? 'border-warning' : ''; ?>">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong><?php echo $this->escape($reply->user_name ?? 'System'); ?></strong>
|
||||
<small class="text-muted ms-2"><?php echo HTMLHelper::_('date', $reply->created, 'M d, Y H:i'); ?></small>
|
||||
</div>
|
||||
<?php if ($reply->is_internal): ?>
|
||||
<span class="badge bg-warning text-dark">Internal Note</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="card-body"><?php echo nl2br($this->escape($reply->body)); ?></div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<!-- Reply form -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>Reply</strong></div>
|
||||
<div class="card-body">
|
||||
<?php if (!empty($canned)): ?>
|
||||
<div class="mb-2">
|
||||
<select class="form-select form-select-sm" id="canned-select">
|
||||
<option value="">Insert canned response...</option>
|
||||
<?php foreach ($canned as $c): ?>
|
||||
<option value="<?php echo $this->escape($c->body); ?>"><?php echo $this->escape($c->title); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<textarea id="reply-body" class="form-control mb-2" rows="5" placeholder="Type your reply..."></textarea>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-primary" id="btn-reply"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.addTicketReply&format=json'); ?>"
|
||||
data-ticket="<?php echo $t->id; ?>" data-token="<?php echo $token; ?>">
|
||||
<span class="icon-reply"></span> Send Reply
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-warning" id="btn-internal"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.addTicketReply&format=json'); ?>"
|
||||
data-ticket="<?php echo $t->id; ?>" data-token="<?php echo $token; ?>" data-internal="1">
|
||||
<span class="icon-eye-slash"></span> Internal Note
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: ticket metadata -->
|
||||
<div class="col-12 col-xl-4">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>Details</strong></div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm mb-0">
|
||||
<tr><td class="text-muted">Status</td><td><span class="badge <?php echo $statusBadge[$t->status] ?? ''; ?>"><?php echo ucwords(str_replace('_', ' ', $t->status)); ?></span></td></tr>
|
||||
<tr><td class="text-muted">Priority</td><td><span class="badge <?php echo $priorityBadge[$t->priority] ?? ''; ?>"><?php echo ucfirst($t->priority); ?></span></td></tr>
|
||||
<tr><td class="text-muted">Category</td><td><?php echo $this->escape($t->category_title ?? '—'); ?></td></tr>
|
||||
<tr><td class="text-muted">Created By</td><td><?php echo $this->escape($t->created_by_name); ?><br><small><?php echo $this->escape($t->created_by_email ?? ''); ?></small></td></tr>
|
||||
<tr><td class="text-muted">Assigned To</td><td><?php echo $this->escape($t->assigned_to_name ?? 'Unassigned'); ?></td></tr>
|
||||
<tr><td class="text-muted">Created</td><td><?php echo HTMLHelper::_('date', $t->created, 'M d, Y H:i'); ?></td></tr>
|
||||
<?php if ($t->resolved): ?><tr><td class="text-muted">Resolved</td><td><?php echo HTMLHelper::_('date', $t->resolved, 'M d, Y H:i'); ?></td></tr><?php endif; ?>
|
||||
<?php if ($t->closed): ?><tr><td class="text-muted">Closed</td><td><?php echo HTMLHelper::_('date', $t->closed, 'M d, Y H:i'); ?></td></tr><?php endif; ?>
|
||||
<tr><td class="text-muted">Replies</td><td><?php echo $t->reply_count; ?></td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SLA -->
|
||||
<?php if ($t->sla_response_due || $t->sla_resolution_due): ?>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>SLA</strong></div>
|
||||
<div class="card-body">
|
||||
<?php if ($t->sla_response_due): ?>
|
||||
<div class="mb-2">
|
||||
<small class="text-muted">Response Due</small><br>
|
||||
<?php
|
||||
$responseOverdue = !$t->sla_responded && strtotime($t->sla_response_due) < time();
|
||||
?>
|
||||
<span class="<?php echo $t->sla_responded ? 'text-success' : ($responseOverdue ? 'text-danger fw-bold' : ''); ?>">
|
||||
<?php echo $t->sla_responded ? 'Responded' : HTMLHelper::_('date', $t->sla_response_due, 'M d H:i'); ?>
|
||||
<?php echo $responseOverdue ? ' OVERDUE' : ''; ?>
|
||||
</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($t->sla_resolution_due): ?>
|
||||
<div>
|
||||
<small class="text-muted">Resolution Due</small><br>
|
||||
<?php
|
||||
$resolutionOverdue = !\in_array($t->status, ['resolved','closed']) && strtotime($t->sla_resolution_due) < time();
|
||||
?>
|
||||
<span class="<?php echo \in_array($t->status, ['resolved','closed']) ? 'text-success' : ($resolutionOverdue ? 'text-danger fw-bold' : ''); ?>">
|
||||
<?php echo \in_array($t->status, ['resolved','closed']) ? 'Met' : HTMLHelper::_('date', $t->sla_resolution_due, 'M d H:i'); ?>
|
||||
<?php echo $resolutionOverdue ? ' OVERDUE' : ''; ?>
|
||||
</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Status actions -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>Actions</strong></div>
|
||||
<div class="card-body d-grid gap-2">
|
||||
<?php foreach (['open' => 'Reopen', 'in_progress' => 'In Progress', 'waiting' => 'Waiting', 'resolved' => 'Resolve', 'closed' => 'Close'] as $s => $label): ?>
|
||||
<?php if ($s !== $t->status): ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-<?php echo $s === 'closed' ? 'danger' : ($s === 'resolved' ? 'success' : 'secondary'); ?> btn-status"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.updateTicketStatus&format=json'); ?>"
|
||||
data-ticket="<?php echo $t->id; ?>" data-status="<?php echo $s; ?>" data-token="<?php echo $token; ?>">
|
||||
<?php echo $label; ?>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Canned response insert
|
||||
var cannedSel = document.getElementById('canned-select');
|
||||
if (cannedSel) {
|
||||
cannedSel.addEventListener('change', function() {
|
||||
if (this.value) { document.getElementById('reply-body').value = this.value; this.selectedIndex = 0; }
|
||||
});
|
||||
}
|
||||
|
||||
// Reply buttons
|
||||
document.querySelectorAll('#btn-reply, #btn-internal').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var body = document.getElementById('reply-body').value.trim();
|
||||
if (!body) return;
|
||||
var el = this;
|
||||
el.disabled = true;
|
||||
var fd = new FormData();
|
||||
fd.append('ticket_id', el.dataset.ticket);
|
||||
fd.append('body', body);
|
||||
fd.append('is_internal', el.dataset.internal || '0');
|
||||
fd.append(el.dataset.token, '1');
|
||||
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){ if(d.success) location.reload(); else Joomla.renderMessages({error:[d.message]}); })
|
||||
.finally(function(){ el.disabled = false; });
|
||||
});
|
||||
});
|
||||
|
||||
// Status buttons
|
||||
document.querySelectorAll('.btn-status').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var el = this;
|
||||
el.disabled = true;
|
||||
var fd = new FormData();
|
||||
fd.append('ticket_id', el.dataset.ticket);
|
||||
fd.append('status', el.dataset.status);
|
||||
fd.append(el.dataset.token, '1');
|
||||
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){ if(d.success) location.reload(); else Joomla.renderMessages({error:[d.message]}); })
|
||||
.finally(function(){ el.disabled = false; });
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$tickets = $this->tickets;
|
||||
$categories = $this->categories;
|
||||
$counts = $this->statusCounts;
|
||||
$overdue = $this->overdue;
|
||||
$atsAvailable = $this->atsAvailable;
|
||||
$token = Session::getFormToken();
|
||||
|
||||
$statusBadge = [
|
||||
'open' => 'bg-primary',
|
||||
'in_progress' => 'bg-info',
|
||||
'waiting' => 'bg-warning text-dark',
|
||||
'resolved' => 'bg-success',
|
||||
'closed' => 'bg-secondary',
|
||||
];
|
||||
|
||||
$priorityBadge = [
|
||||
'low' => 'bg-secondary',
|
||||
'normal' => 'bg-primary',
|
||||
'high' => 'bg-warning text-dark',
|
||||
'urgent' => 'bg-danger',
|
||||
];
|
||||
?>
|
||||
|
||||
<div id="mokowaas-tickets">
|
||||
<!-- Status summary cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo $counts->open; ?></span><small class="text-muted">Open</small></div></div>
|
||||
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo $counts->in_progress; ?></span><small class="text-muted">In Progress</small></div></div>
|
||||
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo $counts->waiting; ?></span><small class="text-muted">Waiting</small></div></div>
|
||||
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo $counts->resolved; ?></span><small class="text-muted">Resolved</small></div></div>
|
||||
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo $counts->closed; ?></span><small class="text-muted">Closed</small></div></div>
|
||||
<?php if (\count($overdue) > 0): ?>
|
||||
<div class="col"><div class="card text-center p-2 border-danger"><span class="fw-bold fs-4 text-danger"><?php echo \count($overdue); ?></span><small class="text-danger">SLA Overdue</small></div></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- New ticket + filters -->
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3">
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newTicketModal">
|
||||
<span class="icon-plus"></span> New Ticket
|
||||
</button>
|
||||
<?php if ($atsAvailable): ?>
|
||||
<button type="button" class="btn btn-outline-info" id="btn-import-ats"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.importAts&format=json'); ?>"
|
||||
data-token="<?php echo $token; ?>"
|
||||
data-tickets="<?php echo $atsAvailable->tickets; ?>"
|
||||
data-posts="<?php echo $atsAvailable->posts; ?>">
|
||||
<span class="icon-upload"></span> Import from Akeeba (<?php echo $atsAvailable->tickets; ?> tickets, <?php echo $atsAvailable->posts; ?> posts)
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<form method="get" class="d-flex gap-2">
|
||||
<input type="hidden" name="option" value="com_mokowaas">
|
||||
<input type="hidden" name="view" value="tickets">
|
||||
<select name="filter_status" class="form-select form-select-sm" style="width:auto" onchange="this.form.submit()">
|
||||
<option value="">All Statuses</option>
|
||||
<?php foreach (['open','in_progress','waiting','resolved','closed'] as $s): ?>
|
||||
<option value="<?php echo $s; ?>" <?php echo Factory::getApplication()->getInput()->getString('filter_status') === $s ? 'selected' : ''; ?>><?php echo ucwords(str_replace('_', ' ', $s)); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<select name="filter_priority" class="form-select form-select-sm" style="width:auto" onchange="this.form.submit()">
|
||||
<option value="">All Priorities</option>
|
||||
<?php foreach (['low','normal','high','urgent'] as $p): ?>
|
||||
<option value="<?php echo $p; ?>" <?php echo Factory::getApplication()->getInput()->getString('filter_priority') === $p ? 'selected' : ''; ?>><?php echo ucfirst($p); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Ticket table -->
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Subject</th>
|
||||
<th>Status</th>
|
||||
<th>Priority</th>
|
||||
<th>Category</th>
|
||||
<th>Created By</th>
|
||||
<th>Assigned To</th>
|
||||
<th>Created</th>
|
||||
<th>SLA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($tickets)): ?>
|
||||
<tr><td colspan="9" class="text-center text-muted py-4">No tickets found.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($tickets as $t): ?>
|
||||
<?php
|
||||
$slaClass = '';
|
||||
$now = time();
|
||||
if ($t->sla_response_due && !$t->sla_responded && strtotime($t->sla_response_due) < $now) $slaClass = 'table-danger';
|
||||
elseif ($t->sla_resolution_due && strtotime($t->sla_resolution_due) < $now && !\in_array($t->status, ['resolved','closed'])) $slaClass = 'table-danger';
|
||||
elseif ($t->sla_response_due && !$t->sla_responded && strtotime($t->sla_response_due) < $now + 3600) $slaClass = 'table-warning';
|
||||
?>
|
||||
<tr class="<?php echo $slaClass; ?>">
|
||||
<td><a href="<?php echo Route::_('index.php?option=com_mokowaas&view=ticket&id=' . $t->id); ?>"><?php echo $t->id; ?></a></td>
|
||||
<td><a href="<?php echo Route::_('index.php?option=com_mokowaas&view=ticket&id=' . $t->id); ?>"><?php echo $this->escape(mb_substr($t->subject, 0, 60)); ?></a></td>
|
||||
<td><span class="badge <?php echo $statusBadge[$t->status] ?? 'bg-secondary'; ?>"><?php echo ucwords(str_replace('_', ' ', $t->status)); ?></span></td>
|
||||
<td><span class="badge <?php echo $priorityBadge[$t->priority] ?? 'bg-secondary'; ?>"><?php echo ucfirst($t->priority); ?></span></td>
|
||||
<td><?php echo $this->escape($t->category_title ?? '—'); ?></td>
|
||||
<td><?php echo $this->escape($t->created_by_name ?? ''); ?></td>
|
||||
<td><?php echo $t->assigned_to_name ? $this->escape($t->assigned_to_name) : '<em>Unassigned</em>'; ?></td>
|
||||
<td class="small"><?php echo HTMLHelper::_('date', $t->created, 'M d H:i'); ?></td>
|
||||
<td class="small">
|
||||
<?php if ($t->sla_response_due && !$t->sla_responded): ?>
|
||||
<span title="Response due"><?php echo HTMLHelper::_('date', $t->sla_response_due, 'M d H:i'); ?></span>
|
||||
<?php elseif ($t->sla_resolution_due): ?>
|
||||
<span title="Resolution due"><?php echo HTMLHelper::_('date', $t->sla_resolution_due, 'M d H:i'); ?></span>
|
||||
<?php else: ?>—<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Ticket Modal -->
|
||||
<div class="modal fade" id="newTicketModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header"><h5 class="modal-title">New Ticket</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||||
<div class="modal-body">
|
||||
<!-- KB Search step -->
|
||||
<div id="modal-kb-step">
|
||||
<label class="form-label fw-bold">What's the issue?</label>
|
||||
<div class="input-group mb-3">
|
||||
<input type="text" id="modal-kb-search" class="form-control" placeholder="Describe your issue to search for existing answers...">
|
||||
<button type="button" class="btn btn-outline-primary" id="modal-kb-btn"><span class="icon-search"></span></button>
|
||||
</div>
|
||||
<div id="modal-kb-results" class="list-group mb-3 d-none"></div>
|
||||
<button type="button" class="btn btn-primary" id="modal-show-form">
|
||||
<span class="icon-plus"></span> Create Ticket
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Ticket form step (hidden initially) -->
|
||||
<form id="modal-ticket-form" class="d-none" method="post" action="<?php echo Route::_('index.php?option=com_mokowaas&task=display.createTicket&format=json'); ?>">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Subject</label>
|
||||
<input type="text" name="subject" id="modal-subject" class="form-control" required>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Category</label>
|
||||
<select name="category_id" class="form-select">
|
||||
<option value="">— Select —</option>
|
||||
<?php foreach ($categories as $cat): ?>
|
||||
<option value="<?php echo $cat->id; ?>"><?php echo $this->escape($cat->title); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Priority</label>
|
||||
<select name="priority" class="form-select">
|
||||
<option value="normal">Normal</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="high">High</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea name="body" class="form-control" rows="6" required></textarea>
|
||||
</div>
|
||||
<input type="hidden" name="<?php echo $token; ?>" value="1">
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary"><span class="icon-plus"></span> Create Ticket</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Modal KB search
|
||||
var modalSearch = document.getElementById('modal-kb-search');
|
||||
var modalSearchBtn = document.getElementById('modal-kb-btn');
|
||||
var modalResults = document.getElementById('modal-kb-results');
|
||||
var modalShowForm = document.getElementById('modal-show-form');
|
||||
var modalKbStep = document.getElementById('modal-kb-step');
|
||||
var modalForm = document.getElementById('modal-ticket-form');
|
||||
var modalSubject = document.getElementById('modal-subject');
|
||||
|
||||
function modalDoSearch() {
|
||||
var q = modalSearch.value.trim();
|
||||
if (q.length < 3) return;
|
||||
fetch('<?php echo Route::_('index.php?option=com_mokowaas&task=display.searchKb&format=json'); ?>&q=' + encodeURIComponent(q), {
|
||||
headers: {'X-Requested-With': 'XMLHttpRequest'}
|
||||
}).then(function(r){return r.json()}).then(function(d) {
|
||||
modalResults.textContent = '';
|
||||
if (d.results && d.results.length > 0) {
|
||||
d.results.forEach(function(item) {
|
||||
var a = document.createElement('a');
|
||||
a.href = item.url;
|
||||
a.target = '_blank';
|
||||
a.className = 'list-group-item list-group-item-action';
|
||||
var strong = document.createElement('strong');
|
||||
strong.textContent = item.title;
|
||||
a.appendChild(strong);
|
||||
if (item.description) {
|
||||
a.appendChild(document.createElement('br'));
|
||||
var small = document.createElement('small');
|
||||
small.className = 'text-muted';
|
||||
small.textContent = item.description;
|
||||
a.appendChild(small);
|
||||
}
|
||||
modalResults.appendChild(a);
|
||||
});
|
||||
modalResults.classList.remove('d-none');
|
||||
} else {
|
||||
modalResults.classList.add('d-none');
|
||||
}
|
||||
});
|
||||
}
|
||||
if (modalSearchBtn) modalSearchBtn.addEventListener('click', modalDoSearch);
|
||||
if (modalSearch) modalSearch.addEventListener('keydown', function(e) { if (e.key === 'Enter') { e.preventDefault(); modalDoSearch(); } });
|
||||
|
||||
// Show ticket form
|
||||
if (modalShowForm) {
|
||||
modalShowForm.addEventListener('click', function() {
|
||||
modalKbStep.classList.add('d-none');
|
||||
modalForm.classList.remove('d-none');
|
||||
if (modalSearch.value && !modalSubject.value) modalSubject.value = modalSearch.value;
|
||||
modalSubject.focus();
|
||||
});
|
||||
}
|
||||
|
||||
// Submit ticket from modal
|
||||
if (modalForm) {
|
||||
modalForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
var form = this;
|
||||
var fd = new FormData(form);
|
||||
fetch(form.action, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){
|
||||
if (d.success) { location.href = 'index.php?option=com_mokowaas&view=ticket&id=' + d.id; }
|
||||
else { Joomla.renderMessages({error:[d.message]}); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Reset modal on close
|
||||
document.getElementById('newTicketModal').addEventListener('hidden.bs.modal', function() {
|
||||
modalKbStep.classList.remove('d-none');
|
||||
modalForm.classList.add('d-none');
|
||||
modalResults.classList.add('d-none');
|
||||
modalSearch.value = '';
|
||||
modalForm.reset();
|
||||
});
|
||||
|
||||
// ATS Import
|
||||
var atsBtn = document.getElementById('btn-import-ats');
|
||||
if (atsBtn) {
|
||||
atsBtn.addEventListener('click', function() {
|
||||
var el = this;
|
||||
if (!confirm('Import ' + el.dataset.tickets + ' tickets and ' + el.dataset.posts + ' posts from Akeeba Ticket System? Duplicates will be skipped.')) return;
|
||||
el.disabled = true;
|
||||
el.textContent = ' Importing...';
|
||||
var fd = new FormData();
|
||||
fd.append(el.dataset.token, '1');
|
||||
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){
|
||||
if (d.success) { Joomla.renderMessages({message:[d.message]}); location.reload(); }
|
||||
else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; el.textContent = 'Import Failed - Retry'; }
|
||||
})
|
||||
.catch(function(){ Joomla.renderMessages({error:['Network error']}); el.disabled = false; });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$logs = $this->logs;
|
||||
$ruleCounts = $this->ruleCounts;
|
||||
$topIps = $this->topIps;
|
||||
$ruleNames = $this->ruleNames;
|
||||
$total = $this->total;
|
||||
$filters = $this->filters;
|
||||
$token = Session::getFormToken();
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$page = max(1, $input->getInt('page', 1));
|
||||
$totalPages = max(1, ceil($total / 50));
|
||||
|
||||
$ruleBadge = [
|
||||
'sqli' => 'bg-danger', 'xss' => 'bg-danger', 'mua' => 'bg-warning text-dark',
|
||||
'rfi' => 'bg-danger', 'dfi' => 'bg-danger', 'blocked_file' => 'bg-info',
|
||||
'blocked_php' => 'bg-info', 'tmpl_switch' => 'bg-secondary',
|
||||
'ip_blocklist' => 'bg-dark', 'admin_secret' => 'bg-dark',
|
||||
];
|
||||
?>
|
||||
|
||||
<div id="mokowaas-waflog">
|
||||
<!-- Rule distribution cards -->
|
||||
<div class="d-flex flex-wrap gap-2 mb-4">
|
||||
<?php foreach ($ruleCounts as $rc): ?>
|
||||
<div class="card p-2 text-center" style="min-width:100px">
|
||||
<span class="badge <?php echo $ruleBadge[$rc->rule] ?? 'bg-secondary'; ?> mb-1"><?php echo htmlspecialchars($rc->rule); ?></span>
|
||||
<span class="fw-bold"><?php echo number_format($rc->cnt); ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<div class="card p-2 text-center" style="min-width:100px">
|
||||
<span class="badge bg-primary mb-1">Total</span>
|
||||
<span class="fw-bold"><?php echo number_format($total); ?></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Main: Log table -->
|
||||
<div class="col-12 col-xl-9">
|
||||
<!-- Filters -->
|
||||
<form method="get" class="card mb-3">
|
||||
<div class="card-body">
|
||||
<input type="hidden" name="option" value="com_mokowaas">
|
||||
<input type="hidden" name="view" value="waflog">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-2">
|
||||
<select name="filter_rule" class="form-select form-select-sm">
|
||||
<option value="">All Rules</option>
|
||||
<?php foreach ($ruleNames as $r): ?>
|
||||
<option value="<?php echo htmlspecialchars($r); ?>" <?php echo $filters['rule'] === $r ? 'selected' : ''; ?>><?php echo htmlspecialchars($r); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="text" name="filter_ip" class="form-control form-control-sm" placeholder="IP address" value="<?php echo htmlspecialchars($filters['ip']); ?>">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="text" name="filter_search" class="form-control form-control-sm" placeholder="Search URI/detail" value="<?php echo htmlspecialchars($filters['search']); ?>">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="date" name="filter_date_from" class="form-control form-control-sm" value="<?php echo htmlspecialchars($filters['date_from']); ?>">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="date" name="filter_date_to" class="form-control form-control-sm" value="<?php echo htmlspecialchars($filters['date_to']); ?>">
|
||||
</div>
|
||||
<div class="col-md-2 d-flex gap-1">
|
||||
<button type="submit" class="btn btn-sm btn-primary"><span class="icon-search"></span> Filter</button>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokowaas&view=waflog'); ?>" class="btn btn-sm btn-outline-secondary">Reset</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Log table -->
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong><?php echo number_format($total); ?> blocked requests</strong>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" id="btn-purge"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.purgeWafLog&format=json'); ?>"
|
||||
data-token="<?php echo $token; ?>">
|
||||
<span class="icon-trash"></span> Purge Old Logs
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover table-sm mb-0">
|
||||
<thead>
|
||||
<tr><th>Time</th><th>IP</th><th>Rule</th><th>URI</th><th>Detail</th><th>User Agent</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($logs)): ?>
|
||||
<tr><td colspan="7" class="text-center text-muted py-4">No blocked requests found.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($logs as $log): ?>
|
||||
<tr>
|
||||
<td class="text-nowrap small"><?php echo HTMLHelper::_('date', $log->created, 'M d H:i:s'); ?></td>
|
||||
<td><code><?php echo htmlspecialchars($log->ip); ?></code></td>
|
||||
<td><span class="badge <?php echo $ruleBadge[$log->rule] ?? 'bg-secondary'; ?>"><?php echo htmlspecialchars($log->rule); ?></span></td>
|
||||
<td class="small" style="max-width:250px;overflow:hidden;text-overflow:ellipsis" title="<?php echo htmlspecialchars($log->uri); ?>"><?php echo htmlspecialchars(mb_substr($log->uri, 0, 60)); ?></td>
|
||||
<td class="small" style="max-width:200px;overflow:hidden;text-overflow:ellipsis"><?php echo htmlspecialchars(mb_substr($log->detail, 0, 50)); ?></td>
|
||||
<td class="small" style="max-width:200px;overflow:hidden;text-overflow:ellipsis"><?php echo htmlspecialchars(mb_substr($log->user_agent, 0, 40)); ?></td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger btn-ban-ip" data-ip="<?php echo htmlspecialchars($log->ip); ?>"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.banIpFromLog&format=json'); ?>"
|
||||
data-token="<?php echo $token; ?>" title="Ban this IP">
|
||||
<span class="icon-ban"></span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<?php if ($totalPages > 1): ?>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted">Page <?php echo $page; ?> of <?php echo $totalPages; ?></small>
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
<?php if ($page > 1): ?>
|
||||
<li class="page-item"><a class="page-link" href="<?php echo Route::_('index.php?option=com_mokowaas&view=waflog&page=' . ($page - 1)); ?>">Prev</a></li>
|
||||
<?php endif; ?>
|
||||
<?php if ($page < $totalPages): ?>
|
||||
<li class="page-item"><a class="page-link" href="<?php echo Route::_('index.php?option=com_mokowaas&view=waflog&page=' . ($page + 1)); ?>">Next</a></li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar: Top IPs -->
|
||||
<div class="col-12 col-xl-3">
|
||||
<div class="card">
|
||||
<div class="card-header"><strong>Top Blocked IPs</strong></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr><th>IP</th><th>Blocks</th><th>Last</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($topIps as $tip): ?>
|
||||
<tr>
|
||||
<td><code class="small"><?php echo htmlspecialchars($tip->ip); ?></code></td>
|
||||
<td class="fw-bold"><?php echo $tip->cnt; ?></td>
|
||||
<td class="small text-nowrap"><?php echo HTMLHelper::_('date', $tip->last_seen, 'M d'); ?></td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger btn-ban-ip" data-ip="<?php echo htmlspecialchars($tip->ip); ?>"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.banIpFromLog&format=json'); ?>"
|
||||
data-token="<?php echo $token; ?>" title="Ban">
|
||||
<span class="icon-ban"></span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var token = '<?php echo $token; ?>';
|
||||
|
||||
// Ban IP buttons
|
||||
document.querySelectorAll('.btn-ban-ip').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var el = this;
|
||||
var ip = el.dataset.ip;
|
||||
if (!confirm('Add ' + ip + ' to the firewall IP blocklist?')) return;
|
||||
el.disabled = true;
|
||||
var fd = new FormData();
|
||||
fd.append('ip', ip);
|
||||
fd.append(el.dataset.token, '1');
|
||||
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){
|
||||
if (d.success) { Joomla.renderMessages({message:[d.message]}); el.textContent = 'Banned'; }
|
||||
else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Purge button
|
||||
var purgeBtn = document.getElementById('btn-purge');
|
||||
if (purgeBtn) {
|
||||
purgeBtn.addEventListener('click', function() {
|
||||
var days = prompt('Delete WAF logs older than how many days?', '30');
|
||||
if (!days || isNaN(days)) return;
|
||||
this.disabled = true;
|
||||
var fd = new FormData();
|
||||
fd.append('days', days);
|
||||
fd.append(this.dataset.token, '1');
|
||||
fetch(this.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){
|
||||
if (d.success) { Joomla.renderMessages({message:[d.message]}); location.reload(); }
|
||||
else { Joomla.renderMessages({error:[d.message]}); }
|
||||
})
|
||||
.finally(function(){ purgeBtn.disabled = false; });
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -109,4 +109,26 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Akeeba import buttons
|
||||
['btn-import-admintools', 'btn-import-ats-dash'].forEach(function(id) {
|
||||
var btn = document.getElementById(id);
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', function() {
|
||||
var el = this;
|
||||
if (!confirm('Import Akeeba data into MokoWaaS? Akeeba extensions will be disabled after import.')) return;
|
||||
el.disabled = true;
|
||||
var origText = el.textContent;
|
||||
el.textContent = ' Importing...';
|
||||
var fd = new FormData();
|
||||
fd.append(el.dataset.token, '1');
|
||||
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){
|
||||
if (d.success) { Joomla.renderMessages({message:[d.message]}); setTimeout(function(){location.reload()}, 2000); }
|
||||
else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; el.textContent = origText; }
|
||||
})
|
||||
.catch(function(){ Joomla.renderMessages({error:['Network error']}); el.disabled = false; el.textContent = origText; });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
DEFGROUP: Joomla.Component
|
||||
INGROUP: MokoWaaS
|
||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
|
||||
VERSION: 02.32.03
|
||||
VERSION: 02.34.00
|
||||
PATH: /mokowaas.xml
|
||||
BRIEF: Component manifest for MokoWaaS admin dashboard and REST API
|
||||
-->
|
||||
@@ -20,27 +20,52 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ version_read v04.00.15 │
|
||||
│ Read version — manifest.xml is canonical, falls back to README.md and Joomla XML│
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
02.32.02-dev</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>
|
||||
|
||||
<administration>
|
||||
<menu img="class:cogs">MokoWaaS</menu>
|
||||
<submenu>
|
||||
<menu link="option=com_mokowaas" img="class:cogs">COM_MOKOWAAS_MENU_DASHBOARD</menu>
|
||||
<menu link="option=com_mokowaas&view=extensions" img="class:puzzle-piece">COM_MOKOWAAS_MENU_EXTENSIONS</menu>
|
||||
<menu link="option=com_mokowaas&view=tickets" img="class:headphones">COM_MOKOWAAS_MENU_TICKETS</menu>
|
||||
<menu link="option=com_mokowaas&view=htaccess" img="class:file-code">COM_MOKOWAAS_MENU_HTACCESS</menu>
|
||||
<menu link="option=com_mokowaas&view=privacy" img="class:lock">COM_MOKOWAAS_MENU_PRIVACY</menu>
|
||||
<menu link="option=com_mokowaas&view=waflog" img="class:shield-alt">COM_MOKOWAAS_MENU_WAFLOG</menu>
|
||||
<menu link="option=com_mokowaas&view=database" img="class:database">COM_MOKOWAAS_MENU_DATABASE</menu>
|
||||
<menu link="option=com_mokowaas&view=cleanup" img="class:trash">COM_MOKOWAAS_MENU_CLEANUP</menu>
|
||||
<menu link="option=com_plugins&filter[folder]=system&filter[search]=mokowaas" img="class:power-off">COM_MOKOWAAS_MENU_PLUGINS</menu>
|
||||
<menu link="option=com_installer&view=update" img="class:refresh">COM_MOKOWAAS_MENU_UPDATES</menu>
|
||||
<menu link="option=com_checkin" img="class:check-square">COM_MOKOWAAS_MENU_CHECKIN</menu>
|
||||
<menu link="option=com_cache" img="class:bolt">COM_MOKOWAAS_MENU_CACHE</menu>
|
||||
</submenu>
|
||||
<files folder="admin">
|
||||
<filename>access.xml</filename>
|
||||
<filename>config.xml</filename>
|
||||
<folder>language</folder>
|
||||
<folder>services</folder>
|
||||
<folder>sql</folder>
|
||||
<folder>src</folder>
|
||||
<folder>tmpl</folder>
|
||||
</files>
|
||||
<languages folder="admin/language">
|
||||
<language tag="en-GB">en-GB/com_mokowaas.sys.ini</language>
|
||||
</languages>
|
||||
</administration>
|
||||
|
||||
<files folder="site">
|
||||
<folder>language</folder>
|
||||
<folder>services</folder>
|
||||
<folder>src</folder>
|
||||
<folder>tmpl</folder>
|
||||
</files>
|
||||
|
||||
<install>
|
||||
<sql><file driver="mysql" charset="utf8">admin/sql/install.mysql.sql</file></sql>
|
||||
</install>
|
||||
|
||||
<api>
|
||||
<files folder="api">
|
||||
<folder>src</folder>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
; MokoWaaS Customer Portal - Language Strings
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GPL-3.0-or-later
|
||||
|
||||
COM_MOKOWAAS_PORTAL_TITLE="Support Portal"
|
||||
COM_MOKOWAAS_PORTAL_MY_TICKETS="My Support Tickets"
|
||||
COM_MOKOWAAS_PORTAL_NEW_TICKET="New Ticket"
|
||||
COM_MOKOWAAS_PORTAL_SUBMIT="Submit Ticket"
|
||||
COM_MOKOWAAS_PORTAL_REPLY="Send Reply"
|
||||
COM_MOKOWAAS_PORTAL_NO_TICKETS="You haven't submitted any support tickets yet."
|
||||
COM_MOKOWAAS_PORTAL_LOGIN_REQUIRED="Please log in to access the support portal."
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas.site
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Dispatcher\ComponentDispatcherFactoryInterface;
|
||||
use Joomla\CMS\Extension\ComponentInterface;
|
||||
use Joomla\CMS\Extension\Service\Provider\ComponentDispatcherFactory;
|
||||
use Joomla\CMS\Extension\Service\Provider\MVCFactory;
|
||||
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
|
||||
use Joomla\DI\Container;
|
||||
use Joomla\DI\ServiceProviderInterface;
|
||||
|
||||
return new class implements ServiceProviderInterface
|
||||
{
|
||||
public function register(Container $container): void
|
||||
{
|
||||
$container->registerServiceProvider(new MVCFactory('\\Moko\\Component\\MokoWaaS'));
|
||||
$container->registerServiceProvider(new ComponentDispatcherFactory('\\Moko\\Component\\MokoWaaS'));
|
||||
|
||||
$container->set(
|
||||
ComponentInterface::class,
|
||||
function (Container $container) {
|
||||
$component = new \Joomla\CMS\Extension\MVCComponent(
|
||||
$container->get(ComponentDispatcherFactoryInterface::class)
|
||||
);
|
||||
$component->setMVCFactory($container->get(MVCFactoryInterface::class));
|
||||
|
||||
return $component;
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas.site
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Site\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
class DisplayController extends BaseController
|
||||
{
|
||||
protected $default_view = 'tickets';
|
||||
|
||||
public function display($cachable = false, $urlparams = [])
|
||||
{
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
|
||||
if ($user->guest)
|
||||
{
|
||||
Factory::getApplication()->enqueueMessage('Please log in to access the support portal.', 'warning');
|
||||
Factory::getApplication()->redirect(Route::_(
|
||||
'index.php?option=com_users&view=login&return=' . base64_encode('index.php?option=com_mokowaas&view=tickets'),
|
||||
false
|
||||
));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return parent::display($cachable, $urlparams);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a new ticket.
|
||||
*/
|
||||
public function submitTicket()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
|
||||
if ($user->guest)
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Please log in.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
|
||||
// Use admin TicketsModel
|
||||
$model = $this->getModel('Tickets', 'Administrator');
|
||||
|
||||
$this->jsonResponse($model->createTicket([
|
||||
'subject' => $input->getString('subject', ''),
|
||||
'body' => $input->getRaw('body', ''),
|
||||
'priority' => $input->getString('priority', 'normal'),
|
||||
'category_id' => $input->getInt('category_id', 0),
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a reply.
|
||||
*/
|
||||
public function submitReply()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
$input = Factory::getApplication()->getInput();
|
||||
|
||||
if ($user->guest)
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Please log in.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$ticketId = $input->getInt('ticket_id', 0);
|
||||
$model = $this->getModel('Tickets', 'Administrator');
|
||||
$ticket = $model->getTicket($ticketId);
|
||||
|
||||
if (!$ticket)
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Ticket not found.']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Customers can only reply to their own tickets; staff can reply to any
|
||||
if ((int) $ticket->created_by !== $user->id && !$this->isStaff($user))
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Access denied.']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Staff replies from frontend are not internal notes
|
||||
$this->jsonResponse($model->addReply(
|
||||
$ticketId,
|
||||
$input->getRaw('body', ''),
|
||||
false
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update ticket status (staff/manager only from frontend).
|
||||
*/
|
||||
public function updateStatus()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
|
||||
if (!$this->isStaff($user))
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Access denied.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$model = $this->getModel('Tickets', 'Administrator');
|
||||
|
||||
$this->jsonResponse($model->updateStatus(
|
||||
$input->getInt('ticket_id', 0),
|
||||
$input->getString('status', '')
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a ticket (manager only from frontend).
|
||||
*/
|
||||
public function assignTicket()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
|
||||
if (!$user->authorise('mokowaas.tickets.assign', 'com_mokowaas'))
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Access denied.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$input = Factory::getApplication()->getInput();
|
||||
$ticketId = $input->getInt('ticket_id', 0);
|
||||
$assignTo = $input->getInt('assigned_to', 0);
|
||||
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__mokowaas_tickets'))
|
||||
->set($db->quoteName('assigned_to') . ' = ' . ($assignTo ?: 'NULL'))
|
||||
->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql()))
|
||||
->where($db->quoteName('id') . ' = ' . $ticketId)
|
||||
)->execute();
|
||||
|
||||
$this->jsonResponse(['success' => true, 'message' => 'Ticket assigned.']);
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => $e->getMessage()]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a data privacy request from frontend.
|
||||
*/
|
||||
public function submitDataRequest()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
|
||||
if ($user->guest)
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Please log in.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$type = Factory::getApplication()->getInput()->getString('type', '');
|
||||
$model = new \Moko\Component\MokoWaaS\Administrator\Model\PrivacyModel();
|
||||
|
||||
$this->jsonResponse($model->createRequest($user->id, $type, 'Submitted via self-service portal'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is support staff (can manage tickets beyond their own).
|
||||
*/
|
||||
private function isStaff($user): bool
|
||||
{
|
||||
if ($user->guest)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Super admins always staff
|
||||
if ($user->authorise('core.admin'))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Anyone with mokowaas.tickets ACL on the component is staff
|
||||
return $user->authorise('mokowaas.tickets', 'com_mokowaas');
|
||||
}
|
||||
|
||||
/**
|
||||
* Search KB articles via Smart Search (com_finder).
|
||||
*/
|
||||
public function searchKb()
|
||||
{
|
||||
$query = Factory::getApplication()->getInput()->getString('q', '');
|
||||
|
||||
if (strlen($query) < 3)
|
||||
{
|
||||
$this->jsonResponse(['results' => []]);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$escaped = $db->quote('%' . $db->escape($query, true) . '%');
|
||||
|
||||
$results = $db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('l.link_id'),
|
||||
$db->quoteName('l.title'),
|
||||
$db->quoteName('l.url'),
|
||||
$db->quoteName('l.description'),
|
||||
])
|
||||
->from($db->quoteName('#__finder_links', 'l'))
|
||||
->where($db->quoteName('l.published') . ' = 1')
|
||||
->where('(' . $db->quoteName('l.title') . ' LIKE ' . $escaped
|
||||
. ' OR ' . $db->quoteName('l.description') . ' LIKE ' . $escaped . ')')
|
||||
->order($db->quoteName('l.title') . ' ASC')
|
||||
->setLimit(8)
|
||||
)->loadObjectList() ?: [];
|
||||
|
||||
foreach ($results as $r)
|
||||
{
|
||||
$r->description = mb_substr(strip_tags($r->description ?? ''), 0, 150);
|
||||
}
|
||||
|
||||
$this->jsonResponse(['results' => $results]);
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$this->jsonResponse(['results' => []]);
|
||||
}
|
||||
}
|
||||
|
||||
private function jsonResponse(array $data): void
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
$app->setHeader('Content-Type', 'application/json');
|
||||
echo json_encode($data);
|
||||
$app->close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
namespace Moko\Component\MokoWaaS\Site\View\Privacy;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $requests = [];
|
||||
protected $consent = [];
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
|
||||
if ($user->guest)
|
||||
{
|
||||
Factory::getApplication()->redirect(Route::_(
|
||||
'index.php?option=com_users&view=login&return=' . base64_encode('index.php?option=com_mokowaas&view=privacy'),
|
||||
false
|
||||
));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$db = Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
|
||||
|
||||
// Get user's data requests
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokowaas_data_requests'))
|
||||
->where($db->quoteName('user_id') . ' = ' . (int) $user->id)
|
||||
->order($db->quoteName('created') . ' DESC');
|
||||
|
||||
try
|
||||
{
|
||||
$db->setQuery($query);
|
||||
$this->requests = $db->loadObjectList() ?: [];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$this->requests = [];
|
||||
}
|
||||
|
||||
// Get consent history
|
||||
try
|
||||
{
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokowaas_consent_log'))
|
||||
->where($db->quoteName('user_id') . ' = ' . (int) $user->id)
|
||||
->order($db->quoteName('created') . ' DESC')
|
||||
->setLimit(20)
|
||||
);
|
||||
$this->consent = $db->loadObjectList() ?: [];
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$this->consent = [];
|
||||
}
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas.site
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Site\View\Ticket;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Router\Route;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $ticket;
|
||||
protected $isStaff = false;
|
||||
protected $canAssign = false;
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$db = Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
$id = Factory::getApplication()->getInput()->getInt('id', 0);
|
||||
|
||||
$this->isStaff = $user->authorise('core.admin') || $user->authorise('mokowaas.tickets', 'com_mokowaas');
|
||||
$this->canAssign = $user->authorise('core.admin') || $user->authorise('mokowaas.tickets.assign', 'com_mokowaas');
|
||||
|
||||
// Get ticket — staff see any, customers see only their own
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('t') . '.*',
|
||||
$db->quoteName('c.title', 'category_title'),
|
||||
$db->quoteName('u.name', 'created_by_name'),
|
||||
$db->quoteName('u.email', 'created_by_email'),
|
||||
$db->quoteName('a.name', 'assigned_to_name'),
|
||||
])
|
||||
->from($db->quoteName('#__mokowaas_tickets', 't'))
|
||||
->leftJoin($db->quoteName('#__mokowaas_ticket_categories', 'c') . ' ON c.id = t.category_id')
|
||||
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
|
||||
->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to')
|
||||
->where($db->quoteName('t.id') . ' = ' . $id);
|
||||
|
||||
if (!$this->isStaff)
|
||||
{
|
||||
$query->where($db->quoteName('t.created_by') . ' = ' . (int) $user->id);
|
||||
}
|
||||
|
||||
$db->setQuery($query);
|
||||
$this->ticket = $db->loadObject();
|
||||
|
||||
if (!$this->ticket)
|
||||
{
|
||||
Factory::getApplication()->enqueueMessage('Ticket not found.', 'error');
|
||||
Factory::getApplication()->redirect(Route::_('index.php?option=com_mokowaas&view=tickets', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Load replies — staff see internal notes, customers don't
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('r') . '.*',
|
||||
$db->quoteName('u.name', 'user_name'),
|
||||
])
|
||||
->from($db->quoteName('#__mokowaas_ticket_replies', 'r'))
|
||||
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id')
|
||||
->where($db->quoteName('r.ticket_id') . ' = ' . $id);
|
||||
|
||||
if (!$this->isStaff)
|
||||
{
|
||||
$query->where($db->quoteName('r.is_internal') . ' = 0');
|
||||
}
|
||||
|
||||
$query->order($db->quoteName('r.created') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
$this->ticket->replies = $db->loadObjectList() ?: [];
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas.site
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Site\View\Tickets;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $tickets = [];
|
||||
protected $categories = [];
|
||||
protected $isStaff = false;
|
||||
|
||||
public function display($tpl = null)
|
||||
{
|
||||
$db = Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
|
||||
$this->isStaff = $user->authorise('core.admin')
|
||||
|| $user->authorise('mokowaas.tickets', 'com_mokowaas');
|
||||
|
||||
// Staff see all tickets, customers see their own
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('t.id'),
|
||||
$db->quoteName('t.subject'),
|
||||
$db->quoteName('t.status'),
|
||||
$db->quoteName('t.priority'),
|
||||
$db->quoteName('t.created'),
|
||||
$db->quoteName('t.assigned_to'),
|
||||
$db->quoteName('c.title', 'category_title'),
|
||||
$db->quoteName('u.name', 'created_by_name'),
|
||||
$db->quoteName('a.name', 'assigned_to_name'),
|
||||
])
|
||||
->from($db->quoteName('#__mokowaas_tickets', 't'))
|
||||
->leftJoin($db->quoteName('#__mokowaas_ticket_categories', 'c') . ' ON c.id = t.category_id')
|
||||
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
|
||||
->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to');
|
||||
|
||||
if (!$this->isStaff)
|
||||
{
|
||||
$query->where($db->quoteName('t.created_by') . ' = ' . (int) $user->id);
|
||||
}
|
||||
|
||||
$filterStatus = Factory::getApplication()->getInput()->getString('filter_status', '');
|
||||
|
||||
if ($filterStatus)
|
||||
{
|
||||
$query->where($db->quoteName('t.status') . ' = ' . $db->quote($filterStatus));
|
||||
}
|
||||
|
||||
$query->order($db->quoteName('t.created') . ' DESC')->setLimit(50);
|
||||
$db->setQuery($query);
|
||||
$this->tickets = $db->loadObjectList() ?: [];
|
||||
|
||||
// Categories for new ticket form
|
||||
$query = $db->getQuery(true)
|
||||
->select([$db->quoteName('id'), $db->quoteName('title')])
|
||||
->from($db->quoteName('#__mokowaas_ticket_categories'))
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->order($db->quoteName('ordering') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
$this->categories = $db->loadObjectList() ?: [];
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
$requests = $this->requests;
|
||||
$consent = $this->consent;
|
||||
$token = Session::getFormToken();
|
||||
|
||||
$statusLabel = ['pending' => 'Pending', 'processing' => 'Processing', 'completed' => 'Completed', 'denied' => 'Denied'];
|
||||
$statusClass = ['pending' => 'warning', 'processing' => 'info', 'completed' => 'success', 'denied' => 'secondary'];
|
||||
?>
|
||||
|
||||
<div class="mokowaas-portal">
|
||||
<h2>My Privacy & Data</h2>
|
||||
<p class="text-muted">Manage your personal data, download your information, or request account deletion.</p>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-4">
|
||||
<button type="button" class="btn btn-primary w-100 py-3 btn-data-request" data-type="export">
|
||||
<span class="icon-download d-block mb-1" style="font-size:1.5rem"></span>
|
||||
Download My Data
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<button type="button" class="btn btn-outline-warning w-100 py-3 btn-data-request" data-type="anonymize">
|
||||
<span class="icon-user-shield d-block mb-1" style="font-size:1.5rem"></span>
|
||||
Anonymize My Account
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<button type="button" class="btn btn-outline-danger w-100 py-3 btn-data-request" data-type="delete">
|
||||
<span class="icon-trash d-block mb-1" style="font-size:1.5rem"></span>
|
||||
Delete My Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- My requests -->
|
||||
<?php if (!empty($requests)): ?>
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><strong>My Data Requests</strong></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped mb-0">
|
||||
<thead><tr><th>Type</th><th>Status</th><th>Submitted</th><th>Processed</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($requests as $r): ?>
|
||||
<tr>
|
||||
<td><?php echo ucfirst($r->type); ?></td>
|
||||
<td><span class="badge bg-<?php echo $statusClass[$r->status] ?? 'secondary'; ?>"><?php echo $statusLabel[$r->status] ?? $r->status; ?></span></td>
|
||||
<td><?php echo HTMLHelper::_('date', $r->created, 'M d, Y H:i'); ?></td>
|
||||
<td><?php echo $r->processed ? HTMLHelper::_('date', $r->processed, 'M d, Y H:i') : '—'; ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Consent history -->
|
||||
<?php if (!empty($consent)): ?>
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><strong>Consent History</strong></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr><th>Category</th><th>Action</th><th>Date</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($consent as $c): ?>
|
||||
<tr>
|
||||
<td><?php echo htmlspecialchars(ucwords(str_replace('_', ' ', $c->category))); ?></td>
|
||||
<td><span class="badge bg-<?php echo $c->action === 'granted' ? 'success' : 'secondary'; ?>"><?php echo ucfirst($c->action); ?></span></td>
|
||||
<td><?php echo HTMLHelper::_('date', $c->created, 'M d, Y H:i'); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.btn-data-request').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var type = this.dataset.type;
|
||||
var messages = {
|
||||
'export': 'Request a download of all your personal data?',
|
||||
'anonymize': 'Request your account to be anonymized? Your name, email, and personal details will be replaced. This cannot be undone.',
|
||||
'delete': 'Request permanent deletion of your account and all data? This cannot be undone.'
|
||||
};
|
||||
if (!confirm(messages[type] || 'Submit this request?')) return;
|
||||
this.disabled = true;
|
||||
var fd = new FormData();
|
||||
fd.append('type', type);
|
||||
fd.append('<?php echo $token; ?>', '1');
|
||||
fetch('<?php echo Route::_("index.php?option=com_mokowaas&task=display.submitDataRequest&format=json"); ?>', {
|
||||
method: 'POST', body: fd, headers: {'X-Requested-With': 'XMLHttpRequest'}
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.success) { alert(d.message); location.reload(); }
|
||||
else { alert(d.message || 'Failed.'); }
|
||||
})
|
||||
.catch(function() { alert('Network error.'); })
|
||||
.finally(function() { btn.disabled = false; });
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
use Joomla\CMS\Factory;
|
||||
|
||||
$t = $this->ticket;
|
||||
$isStaff = $this->isStaff;
|
||||
$canAssign = $this->canAssign;
|
||||
$token = Session::getFormToken();
|
||||
$userId = Factory::getApplication()->getIdentity()->id;
|
||||
|
||||
$statusLabel = [
|
||||
'open' => 'Open', 'in_progress' => 'In Progress', 'waiting' => 'Awaiting Response',
|
||||
'resolved' => 'Resolved', 'closed' => 'Closed',
|
||||
];
|
||||
$statusClass = [
|
||||
'open' => 'primary', 'in_progress' => 'info', 'waiting' => 'warning',
|
||||
'resolved' => 'success', 'closed' => 'secondary',
|
||||
];
|
||||
?>
|
||||
|
||||
<div class="mokowaas-portal-ticket">
|
||||
<div class="mb-3">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokowaas&view=tickets'); ?>" class="btn btn-sm btn-outline-secondary">
|
||||
<span class="icon-arrow-left"></span> Back to Tickets
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Main column: conversation -->
|
||||
<div class="col-12 <?php echo $isStaff ? 'col-lg-8' : ''; ?>">
|
||||
|
||||
<!-- Ticket header -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h3 class="mb-1">#<?php echo $t->id; ?> — <?php echo htmlspecialchars($t->subject); ?></h3>
|
||||
<small class="text-muted">
|
||||
<?php echo htmlspecialchars($t->category_title ?? 'General'); ?>
|
||||
· <?php echo HTMLHelper::_('date', $t->created, 'M d, Y H:i'); ?>
|
||||
· <?php echo ucfirst($t->priority); ?>
|
||||
<?php if ($isStaff): ?>
|
||||
· By: <?php echo htmlspecialchars($t->created_by_name); ?>
|
||||
<?php endif; ?>
|
||||
</small>
|
||||
</div>
|
||||
<span class="badge bg-<?php echo $statusClass[$t->status] ?? 'secondary'; ?> fs-6">
|
||||
<?php echo $statusLabel[$t->status] ?? $t->status; ?>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Original message -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<strong><?php echo htmlspecialchars($t->created_by_name); ?></strong>
|
||||
<small class="text-muted ms-2"><?php echo HTMLHelper::_('date', $t->created, 'M d, Y H:i'); ?></small>
|
||||
</div>
|
||||
<div class="card-body"><?php echo nl2br(htmlspecialchars($t->body)); ?></div>
|
||||
</div>
|
||||
|
||||
<!-- Replies -->
|
||||
<?php foreach ($t->replies as $reply): ?>
|
||||
<?php
|
||||
$replyIsStaffUser = ((int) $reply->user_id !== (int) $t->created_by);
|
||||
$isInternal = (int) $reply->is_internal;
|
||||
?>
|
||||
<div class="card mb-3 <?php echo $isInternal ? 'border-warning bg-warning bg-opacity-10' : ($replyIsStaffUser ? 'border-primary' : ''); ?>">
|
||||
<div class="card-header d-flex justify-content-between">
|
||||
<div>
|
||||
<strong><?php echo htmlspecialchars($reply->user_name ?? 'Support'); ?></strong>
|
||||
<?php if ($replyIsStaffUser): ?><span class="badge bg-primary ms-1">Staff</span><?php endif; ?>
|
||||
<?php if ($isInternal): ?><span class="badge bg-warning text-dark ms-1">Internal Note</span><?php endif; ?>
|
||||
<small class="text-muted ms-2"><?php echo HTMLHelper::_('date', $reply->created, 'M d, Y H:i'); ?></small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body"><?php echo nl2br(htmlspecialchars($reply->body)); ?></div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<!-- Reply form -->
|
||||
<?php if (!\in_array($t->status, ['closed'])): ?>
|
||||
<div class="card mt-4">
|
||||
<div class="card-body">
|
||||
<h5>Reply</h5>
|
||||
<form id="portalReply">
|
||||
<textarea name="body" class="form-control mb-3" rows="5" required placeholder="Type your reply..."></textarea>
|
||||
<input type="hidden" name="ticket_id" value="<?php echo $t->id; ?>">
|
||||
<input type="hidden" name="<?php echo $token; ?>" value="1">
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="icon-paper-plane"></span> Send Reply
|
||||
</button>
|
||||
<?php if ($isStaff): ?>
|
||||
<button type="button" class="btn btn-outline-warning" id="btn-internal-note">
|
||||
<span class="icon-eye-slash"></span> Internal Note
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php elseif ($t->status === 'closed'): ?>
|
||||
<div class="alert alert-secondary mt-4">
|
||||
This ticket is closed. <a href="<?php echo Route::_('index.php?option=com_mokowaas&view=tickets&layout=submit'); ?>">Open a new ticket</a> if you need further help.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Staff sidebar -->
|
||||
<?php if ($isStaff): ?>
|
||||
<div class="col-12 col-lg-4">
|
||||
<!-- Ticket info -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>Details</strong></div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-5 text-muted">Status</dt>
|
||||
<dd class="col-7"><span class="badge bg-<?php echo $statusClass[$t->status] ?? 'secondary'; ?>"><?php echo $statusLabel[$t->status] ?? $t->status; ?></span></dd>
|
||||
<dt class="col-5 text-muted">Priority</dt>
|
||||
<dd class="col-7"><?php echo ucfirst($t->priority); ?></dd>
|
||||
<dt class="col-5 text-muted">Category</dt>
|
||||
<dd class="col-7"><?php echo htmlspecialchars($t->category_title ?? '—'); ?></dd>
|
||||
<dt class="col-5 text-muted">Submitted By</dt>
|
||||
<dd class="col-7"><?php echo htmlspecialchars($t->created_by_name); ?><br><small class="text-muted"><?php echo htmlspecialchars($t->created_by_email ?? ''); ?></small></dd>
|
||||
<dt class="col-5 text-muted">Assigned To</dt>
|
||||
<dd class="col-7"><?php echo htmlspecialchars($t->assigned_to_name ?? 'Unassigned'); ?></dd>
|
||||
<dt class="col-5 text-muted">Created</dt>
|
||||
<dd class="col-7"><?php echo HTMLHelper::_('date', $t->created, 'M d H:i'); ?></dd>
|
||||
<dt class="col-5 text-muted">Replies</dt>
|
||||
<dd class="col-7"><?php echo \count($t->replies); ?></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status actions -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>Change Status</strong></div>
|
||||
<div class="card-body d-grid gap-2">
|
||||
<?php foreach (['open' => 'Reopen', 'in_progress' => 'In Progress', 'waiting' => 'Waiting on Customer', 'resolved' => 'Resolve', 'closed' => 'Close'] as $s => $label): ?>
|
||||
<?php if ($s !== $t->status): ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-<?php echo $s === 'closed' ? 'danger' : ($s === 'resolved' ? 'success' : 'secondary'); ?> btn-status"
|
||||
data-status="<?php echo $s; ?>">
|
||||
<?php echo $label; ?>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($canAssign): ?>
|
||||
<!-- Quick assign -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>Assign</strong></div>
|
||||
<div class="card-body">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary w-100" id="btn-assign-me">
|
||||
Assign to Me
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var token = '<?php echo $token; ?>';
|
||||
var ticketId = <?php echo $t->id; ?>;
|
||||
|
||||
// Reply
|
||||
var replyForm = document.getElementById('portalReply');
|
||||
if (replyForm) {
|
||||
replyForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
sendReply(false);
|
||||
});
|
||||
}
|
||||
|
||||
// Internal note
|
||||
var internalBtn = document.getElementById('btn-internal-note');
|
||||
if (internalBtn) {
|
||||
internalBtn.addEventListener('click', function() { sendReply(true); });
|
||||
}
|
||||
|
||||
function sendReply(isInternal) {
|
||||
var body = replyForm.querySelector('textarea[name=body]').value.trim();
|
||||
if (!body) return;
|
||||
var fd = new FormData();
|
||||
fd.append('ticket_id', ticketId);
|
||||
fd.append('body', body);
|
||||
fd.append('is_internal', isInternal ? '1' : '0');
|
||||
fd.append(token, '1');
|
||||
fetch('<?php echo Route::_("index.php?option=com_mokowaas&task=display.submitReply&format=json"); ?>', {
|
||||
method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}
|
||||
}).then(function(r){return r.json()}).then(function(d){
|
||||
if (d.success) location.reload();
|
||||
else alert(d.message);
|
||||
});
|
||||
}
|
||||
|
||||
// Status buttons
|
||||
document.querySelectorAll('.btn-status').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var fd = new FormData();
|
||||
fd.append('ticket_id', ticketId);
|
||||
fd.append('status', this.dataset.status);
|
||||
fd.append(token, '1');
|
||||
fetch('<?php echo Route::_("index.php?option=com_mokowaas&task=display.updateStatus&format=json"); ?>', {
|
||||
method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}
|
||||
}).then(function(r){return r.json()}).then(function(d){
|
||||
if (d.success) location.reload();
|
||||
else alert(d.message);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Assign to me
|
||||
var assignBtn = document.getElementById('btn-assign-me');
|
||||
if (assignBtn) {
|
||||
assignBtn.addEventListener('click', function() {
|
||||
var fd = new FormData();
|
||||
fd.append('ticket_id', ticketId);
|
||||
fd.append('assigned_to', <?php echo $userId; ?>);
|
||||
fd.append(token, '1');
|
||||
fetch('<?php echo Route::_("index.php?option=com_mokowaas&task=display.assignTicket&format=json"); ?>', {
|
||||
method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}
|
||||
}).then(function(r){return r.json()}).then(function(d){
|
||||
if (d.success) location.reload();
|
||||
else alert(d.message);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$tickets = $this->tickets;
|
||||
$categories = $this->categories;
|
||||
$isStaff = $this->isStaff;
|
||||
$token = Session::getFormToken();
|
||||
|
||||
$statusLabel = [
|
||||
'open' => 'Open', 'in_progress' => 'In Progress', 'waiting' => 'Awaiting Response',
|
||||
'resolved' => 'Resolved', 'closed' => 'Closed',
|
||||
];
|
||||
$statusClass = [
|
||||
'open' => 'primary', 'in_progress' => 'info', 'waiting' => 'warning',
|
||||
'resolved' => 'success', 'closed' => 'secondary',
|
||||
];
|
||||
?>
|
||||
|
||||
<div class="mokowaas-portal">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><?php echo $isStaff ? 'All Support Tickets' : 'My Support Tickets'; ?></h2>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokowaas&view=tickets&layout=submit'); ?>" class="btn btn-primary">
|
||||
<span class="icon-plus"></span> New Ticket
|
||||
</a>
|
||||
<?php if ($isStaff): ?>
|
||||
<form method="get" class="d-inline">
|
||||
<input type="hidden" name="option" value="com_mokowaas">
|
||||
<input type="hidden" name="view" value="tickets">
|
||||
<select name="filter_status" class="form-select form-select-sm" style="width:auto" onchange="this.form.submit()">
|
||||
<option value="">All Statuses</option>
|
||||
<?php foreach ($statusLabel as $k => $v): ?>
|
||||
<option value="<?php echo $k; ?>" <?php echo Factory::getApplication()->getInput()->getString('filter_status') === $k ? 'selected' : ''; ?>><?php echo $v; ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (empty($tickets)): ?>
|
||||
<div class="alert alert-info">
|
||||
<span class="icon-info-circle"></span>
|
||||
<?php echo $isStaff ? 'No tickets found.' : 'You haven\'t submitted any support tickets yet.'; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Subject</th>
|
||||
<th>Status</th>
|
||||
<th>Priority</th>
|
||||
<th>Category</th>
|
||||
<?php if ($isStaff): ?><th>Submitted By</th><th>Assigned To</th><?php endif; ?>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($tickets as $t): ?>
|
||||
<tr>
|
||||
<td><a href="<?php echo Route::_('index.php?option=com_mokowaas&view=ticket&id=' . $t->id); ?>"><?php echo $t->id; ?></a></td>
|
||||
<td><a href="<?php echo Route::_('index.php?option=com_mokowaas&view=ticket&id=' . $t->id); ?>"><?php echo htmlspecialchars(mb_substr($t->subject, 0, 60)); ?></a></td>
|
||||
<td><span class="badge bg-<?php echo $statusClass[$t->status] ?? 'secondary'; ?>"><?php echo $statusLabel[$t->status] ?? $t->status; ?></span></td>
|
||||
<td><?php echo ucfirst($t->priority); ?></td>
|
||||
<td><?php echo htmlspecialchars($t->category_title ?? '—'); ?></td>
|
||||
<?php if ($isStaff): ?>
|
||||
<td><?php echo htmlspecialchars($t->created_by_name ?? ''); ?></td>
|
||||
<td><?php echo htmlspecialchars($t->assigned_to_name ?? '<em>Unassigned</em>'); ?></td>
|
||||
<?php endif; ?>
|
||||
<td class="text-nowrap"><?php echo HTMLHelper::_('date', $t->created, 'M d, Y'); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
/**
|
||||
* Submit a Ticket layout — search KB first, then submit form.
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
$categories = $this->categories;
|
||||
$token = Session::getFormToken();
|
||||
$searchUrl = Route::_('index.php?option=com_mokowaas&task=display.searchKb&format=json');
|
||||
$submitUrl = Route::_('index.php?option=com_mokowaas&task=display.submitTicket&format=json');
|
||||
$ticketUrl = Route::_('index.php?option=com_mokowaas&view=ticket&id=');
|
||||
$ticketsUrl = Route::_('index.php?option=com_mokowaas&view=tickets');
|
||||
|
||||
// Check if Smart Search has indexed content
|
||||
$finderEnabled = false;
|
||||
try {
|
||||
$db = \Joomla\CMS\Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
|
||||
$db->setQuery('SELECT COUNT(*) FROM #__finder_links WHERE published = 1');
|
||||
$finderEnabled = (int) $db->loadResult() > 0;
|
||||
} catch (\Throwable $e) {}
|
||||
?>
|
||||
|
||||
<div class="mokowaas-portal">
|
||||
<h2>Submit a Support Request</h2>
|
||||
|
||||
<?php if ($finderEnabled): ?>
|
||||
<!-- Step 1: Search -->
|
||||
<div id="step-search" class="mb-4">
|
||||
<p class="text-muted">Before submitting, let's see if we already have an answer for you.</p>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<label class="form-label fw-bold" for="kb-search">Describe your issue</label>
|
||||
<div class="input-group input-group-lg">
|
||||
<input type="text" id="kb-search" class="form-control" placeholder="e.g. how do I reset my password?" autofocus>
|
||||
<button type="button" class="btn btn-primary" id="kb-search-btn">
|
||||
<span class="icon-search"></span> Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search results -->
|
||||
<div id="kb-results" class="mt-3 d-none">
|
||||
<h5>Related Articles</h5>
|
||||
<div id="kb-results-list" class="list-group mb-3"></div>
|
||||
<p class="text-muted">Didn't find what you need?</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="button" class="btn btn-outline-primary" id="btn-show-form">
|
||||
<span class="icon-plus"></span> Submit a Ticket Anyway
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Step 2: Ticket Form -->
|
||||
<div id="step-form" class="<?php echo $finderEnabled ? 'd-none' : ''; ?>">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-3">Ticket Details</h5>
|
||||
<form id="submitTicketForm">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="ticket-subject">Subject <span class="text-danger">*</span></label>
|
||||
<input type="text" id="ticket-subject" name="subject" class="form-control" required placeholder="Brief description of your issue">
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="ticket-category">Category</label>
|
||||
<select id="ticket-category" name="category_id" class="form-select">
|
||||
<option value="">Select a category</option>
|
||||
<?php foreach ($categories as $cat): ?>
|
||||
<option value="<?php echo $cat->id; ?>"><?php echo htmlspecialchars($cat->title); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="ticket-priority">Priority</label>
|
||||
<select id="ticket-priority" name="priority" class="form-select">
|
||||
<option value="normal">Normal</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="high">High</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="ticket-body">Description <span class="text-danger">*</span></label>
|
||||
<textarea id="ticket-body" name="body" class="form-control" rows="8" required placeholder="Please describe your issue in detail."></textarea>
|
||||
</div>
|
||||
<input type="hidden" name="<?php echo $token; ?>" value="1">
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<span class="icon-paper-plane"></span> Submit Ticket
|
||||
</button>
|
||||
<a href="<?php echo $ticketsUrl; ?>" class="btn btn-outline-secondary btn-lg">
|
||||
My Tickets
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var searchInput = document.getElementById('kb-search');
|
||||
var searchBtn = document.getElementById('kb-search-btn');
|
||||
var resultBox = document.getElementById('kb-results');
|
||||
var resultList = document.getElementById('kb-results-list');
|
||||
var showFormBtn = document.getElementById('btn-show-form');
|
||||
var stepSearch = document.getElementById('step-search');
|
||||
var stepForm = document.getElementById('step-form');
|
||||
var subjectField = document.getElementById('ticket-subject');
|
||||
|
||||
// Search
|
||||
function doSearch() {
|
||||
var q = (searchInput ? searchInput.value.trim() : '');
|
||||
if (q.length < 3) return;
|
||||
|
||||
fetch('<?php echo $searchUrl; ?>&q=' + encodeURIComponent(q), {
|
||||
headers: {'X-Requested-With': 'XMLHttpRequest'}
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
resultList.textContent = '';
|
||||
if (d.results && d.results.length > 0) {
|
||||
d.results.forEach(function(item) {
|
||||
var a = document.createElement('a');
|
||||
a.href = item.url;
|
||||
a.target = '_blank';
|
||||
a.className = 'list-group-item list-group-item-action';
|
||||
var strong = document.createElement('strong');
|
||||
strong.textContent = item.title;
|
||||
a.appendChild(strong);
|
||||
if (item.description) {
|
||||
a.appendChild(document.createElement('br'));
|
||||
var small = document.createElement('small');
|
||||
small.className = 'text-muted';
|
||||
small.textContent = item.description;
|
||||
a.appendChild(small);
|
||||
}
|
||||
resultList.appendChild(a);
|
||||
});
|
||||
resultBox.classList.remove('d-none');
|
||||
} else {
|
||||
resultBox.classList.add('d-none');
|
||||
}
|
||||
// Always show the "submit anyway" button after search
|
||||
if (showFormBtn) showFormBtn.classList.remove('d-none');
|
||||
});
|
||||
}
|
||||
|
||||
if (searchBtn) searchBtn.addEventListener('click', doSearch);
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); doSearch(); }
|
||||
});
|
||||
}
|
||||
|
||||
// Show form and prefill subject from search query
|
||||
if (showFormBtn) {
|
||||
showFormBtn.addEventListener('click', function() {
|
||||
if (stepSearch) stepSearch.classList.add('d-none');
|
||||
if (stepForm) stepForm.classList.remove('d-none');
|
||||
if (searchInput && subjectField && !subjectField.value) {
|
||||
subjectField.value = searchInput.value;
|
||||
}
|
||||
subjectField.focus();
|
||||
});
|
||||
}
|
||||
|
||||
// Submit ticket
|
||||
var form = document.getElementById('submitTicketForm');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
var btn = form.querySelector('button[type=submit]');
|
||||
btn.disabled = true;
|
||||
btn.textContent = ' Submitting...';
|
||||
var fd = new FormData(form);
|
||||
fetch('<?php echo $submitUrl; ?>', {
|
||||
method: 'POST', body: fd, headers: {'X-Requested-With': 'XMLHttpRequest'}
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.success && d.id) {
|
||||
window.location.href = '<?php echo $ticketUrl; ?>' + d.id;
|
||||
} else {
|
||||
alert(d.message || 'Failed.');
|
||||
btn.disabled = false;
|
||||
btn.textContent = ' Submit Ticket';
|
||||
}
|
||||
})
|
||||
.catch(function() { alert('Network error.'); btn.disabled = false; });
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,3 @@
|
||||
MOD_MOKOWAAS_CACHE="MokoWaaS Cache Cleaner"
|
||||
MOD_MOKOWAAS_CACHE_DESC="One-click cache cleaner in the admin status bar. Clears all Joomla cache (site, admin, and expired)."
|
||||
MOD_MOKOWAAS_CACHE_CLEAR_ALL="Clear All Cache"
|
||||
@@ -0,0 +1,2 @@
|
||||
MOD_MOKOWAAS_CACHE="MokoWaaS Cache Cleaner"
|
||||
MOD_MOKOWAAS_CACHE_DESC="One-click cache cleaner in the admin status bar. Clears all Joomla cache (site, admin, and expired)."
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<extension type="module" client="administrator" method="upgrade">
|
||||
<name>mod_mokowaas_cache</name>
|
||||
<author>Moko Consulting</author>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.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>
|
||||
@@ -0,0 +1,29 @@
|
||||
; MokoWaaS CPanel Module
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GPL-3.0-or-later
|
||||
|
||||
MOD_MOKOWAAS_CPANEL="MokoWaaS"
|
||||
MOD_MOKOWAAS_CPANEL_DESC="Displays MokoWaaS feature plugin status and site health on the admin dashboard."
|
||||
|
||||
MOD_MOKOWAAS_CPANEL_FIELDSET_DISPLAY="Display Options"
|
||||
MOD_MOKOWAAS_CPANEL_FIELDSET_DISPLAY_DESC="Choose which sections to show in the module."
|
||||
|
||||
MOD_MOKOWAAS_CPANEL_COLLAPSED_LABEL="Collapsed by Default"
|
||||
MOD_MOKOWAAS_CPANEL_COLLAPSED_DESC="Start the module body collapsed. Click the header to expand."
|
||||
MOD_MOKOWAAS_CPANEL_SHOW_HEALTH_LABEL="Health Status"
|
||||
MOD_MOKOWAAS_CPANEL_SHOW_STATS_LABEL="Stats Cards"
|
||||
MOD_MOKOWAAS_CPANEL_SHOW_STATS_DESC="Article count, user count, and pending updates."
|
||||
MOD_MOKOWAAS_CPANEL_SHOW_DISK_LABEL="Disk Usage"
|
||||
MOD_MOKOWAAS_CPANEL_SHOW_IP_LABEL="Current IP"
|
||||
MOD_MOKOWAAS_CPANEL_SHOW_PLUGINS_LABEL="Feature Plugins"
|
||||
MOD_MOKOWAAS_CPANEL_SHOW_ACTIONS_LABEL="Quick Actions"
|
||||
MOD_MOKOWAAS_CPANEL_SHOW_ACTIONS_DESC="Clear cache, check updates buttons."
|
||||
MOD_MOKOWAAS_CPANEL_SHOW_VERSIONS_LABEL="Joomla/PHP Versions"
|
||||
MOD_MOKOWAAS_CPANEL_SHOW_VERSIONS_DESC="Show Joomla and PHP version numbers."
|
||||
|
||||
MOD_MOKOWAAS_CPANEL_OPEN_DASHBOARD="Control Panel"
|
||||
MOD_MOKOWAAS_CPANEL_DEBUG="Debug ON"
|
||||
MOD_MOKOWAAS_CPANEL_OFFLINE="Offline"
|
||||
MOD_MOKOWAAS_CPANEL_HEALTH_OK="All Systems OK"
|
||||
MOD_MOKOWAAS_CPANEL_HEALTH_ERROR="Database Error"
|
||||
MOD_MOKOWAAS_CPANEL_PLUGINS_SUMMARY="%d of %d features enabled"
|
||||
@@ -0,0 +1,3 @@
|
||||
; MokoWaaS CPanel Module - System strings
|
||||
MOD_MOKOWAAS_CPANEL="MokoWaaS"
|
||||
MOD_MOKOWAAS_CPANEL_DESC="Displays MokoWaaS feature plugin status and site health on the admin dashboard."
|
||||
@@ -0,0 +1,93 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<extension type="module" client="administrator" method="upgrade">
|
||||
<name>mod_mokowaas_cpanel</name>
|
||||
<author>Moko Consulting</author>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.34.08-dev</version>
|
||||
<description>MOD_MOKOWAAS_CPANEL_DESC</description>
|
||||
<namespace path="src">Moko\Module\MokoWaaSCpanel</namespace>
|
||||
|
||||
<files>
|
||||
<folder module="mod_mokowaas_cpanel">services</folder>
|
||||
<folder>src</folder>
|
||||
<folder>tmpl</folder>
|
||||
</files>
|
||||
|
||||
<languages folder="language">
|
||||
<language tag="en-GB">en-GB/mod_mokowaas_cpanel.ini</language>
|
||||
<language tag="en-GB">en-GB/mod_mokowaas_cpanel.sys.ini</language>
|
||||
</languages>
|
||||
|
||||
<config>
|
||||
<fields name="params">
|
||||
<fieldset name="basic"
|
||||
label="MOD_MOKOWAAS_CPANEL_FIELDSET_DISPLAY"
|
||||
description="MOD_MOKOWAAS_CPANEL_FIELDSET_DISPLAY_DESC">
|
||||
|
||||
<field name="collapsed" type="radio" default="1"
|
||||
label="MOD_MOKOWAAS_CPANEL_COLLAPSED_LABEL"
|
||||
description="MOD_MOKOWAAS_CPANEL_COLLAPSED_DESC"
|
||||
layout="joomla.form.field.radio.switcher">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
|
||||
<field name="show_health" type="radio" default="1"
|
||||
label="MOD_MOKOWAAS_CPANEL_SHOW_HEALTH_LABEL"
|
||||
layout="joomla.form.field.radio.switcher">
|
||||
<option value="0">JHIDE</option>
|
||||
<option value="1">JSHOW</option>
|
||||
</field>
|
||||
|
||||
<field name="show_stats" type="radio" default="1"
|
||||
label="MOD_MOKOWAAS_CPANEL_SHOW_STATS_LABEL"
|
||||
description="MOD_MOKOWAAS_CPANEL_SHOW_STATS_DESC"
|
||||
layout="joomla.form.field.radio.switcher">
|
||||
<option value="0">JHIDE</option>
|
||||
<option value="1">JSHOW</option>
|
||||
</field>
|
||||
|
||||
<field name="show_disk" type="radio" default="1"
|
||||
label="MOD_MOKOWAAS_CPANEL_SHOW_DISK_LABEL"
|
||||
layout="joomla.form.field.radio.switcher">
|
||||
<option value="0">JHIDE</option>
|
||||
<option value="1">JSHOW</option>
|
||||
</field>
|
||||
|
||||
<field name="show_ip" type="radio" default="1"
|
||||
label="MOD_MOKOWAAS_CPANEL_SHOW_IP_LABEL"
|
||||
layout="joomla.form.field.radio.switcher">
|
||||
<option value="0">JHIDE</option>
|
||||
<option value="1">JSHOW</option>
|
||||
</field>
|
||||
|
||||
<field name="show_plugins" type="radio" default="1"
|
||||
label="MOD_MOKOWAAS_CPANEL_SHOW_PLUGINS_LABEL"
|
||||
layout="joomla.form.field.radio.switcher">
|
||||
<option value="0">JHIDE</option>
|
||||
<option value="1">JSHOW</option>
|
||||
</field>
|
||||
|
||||
<field name="show_actions" type="radio" default="1"
|
||||
label="MOD_MOKOWAAS_CPANEL_SHOW_ACTIONS_LABEL"
|
||||
description="MOD_MOKOWAAS_CPANEL_SHOW_ACTIONS_DESC"
|
||||
layout="joomla.form.field.radio.switcher">
|
||||
<option value="0">JHIDE</option>
|
||||
<option value="1">JSHOW</option>
|
||||
</field>
|
||||
|
||||
<field name="show_versions" type="radio" default="1"
|
||||
label="MOD_MOKOWAAS_CPANEL_SHOW_VERSIONS_LABEL"
|
||||
description="MOD_MOKOWAAS_CPANEL_SHOW_VERSIONS_DESC"
|
||||
layout="joomla.form.field.radio.switcher">
|
||||
<option value="0">JHIDE</option>
|
||||
<option value="1">JSHOW</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
</fields>
|
||||
</config>
|
||||
</extension>
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage mod_mokowaas_cpanel
|
||||
* @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\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\\MokoWaaSCpanel'));
|
||||
$container->registerServiceProvider(new HelperFactory('\\Moko\\Module\\MokoWaaSCpanel\\Administrator\\Helper'));
|
||||
$container->registerServiceProvider(new Module());
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage mod_mokowaas_cpanel
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Module\MokoWaaSCpanel\Administrator\Dispatcher;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Dispatcher\AbstractModuleDispatcher;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Helper\HelperFactoryAwareInterface;
|
||||
use Joomla\CMS\Helper\HelperFactoryAwareTrait;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
|
||||
class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareInterface
|
||||
{
|
||||
use HelperFactoryAwareTrait;
|
||||
|
||||
protected function getLayoutData()
|
||||
{
|
||||
$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');
|
||||
|
||||
$data['siteInfo'] = $helper->getSiteInfo($db);
|
||||
$data['plugins'] = $helper->getFeaturePlugins($db);
|
||||
$data['healthOk'] = $helper->isDatabaseOk($db);
|
||||
$data['counts'] = $helper->getCounts($db);
|
||||
$data['disk'] = $helper->getDiskInfo();
|
||||
$data['currentIp'] = $helper->getCurrentIp();
|
||||
$data['ssl'] = $helper->getSslStatus();
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage mod_mokowaas_cpanel
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Module\MokoWaaSCpanel\Administrator\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Version;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
|
||||
class CpanelHelper
|
||||
{
|
||||
/**
|
||||
* Get basic site info for the cpanel card header.
|
||||
*/
|
||||
public function getSiteInfo(DatabaseInterface $db): object
|
||||
{
|
||||
$config = Factory::getConfig();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('manifest_cache'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('pkg_mokowaas'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('package'));
|
||||
$db->setQuery($query);
|
||||
$pkgCache = json_decode($db->loadResult() ?? '{}');
|
||||
|
||||
return (object) [
|
||||
'mokowaas_version' => $pkgCache->version ?? '',
|
||||
'joomla_version' => (new Version())->getShortVersion(),
|
||||
'php_version' => PHP_VERSION,
|
||||
'debug' => (bool) $config->get('debug'),
|
||||
'offline' => (bool) $config->get('offline'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MokoWaaS system feature plugins with their enabled state.
|
||||
*/
|
||||
public function getFeaturePlugins(DatabaseInterface $db): array
|
||||
{
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('extension_id'),
|
||||
$db->quoteName('name'),
|
||||
$db->quoteName('element'),
|
||||
$db->quoteName('enabled'),
|
||||
])
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
|
||||
->where('(' . $db->quoteName('element') . ' = ' . $db->quote('mokowaas')
|
||||
. ' OR ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mokowaas\\_%') . ')')
|
||||
->order($db->quoteName('element') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadObjectList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick database connectivity check.
|
||||
*/
|
||||
public function isDatabaseOk(DatabaseInterface $db): bool
|
||||
{
|
||||
try
|
||||
{
|
||||
$db->setQuery('SELECT 1');
|
||||
$db->loadResult();
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content and system counts.
|
||||
*/
|
||||
public function getCounts(DatabaseInterface $db): object
|
||||
{
|
||||
$counts = (object) [
|
||||
'articles' => 0,
|
||||
'users' => 0,
|
||||
'extensions' => 0,
|
||||
'updates' => 0,
|
||||
'moko_updates' => 0,
|
||||
];
|
||||
|
||||
try
|
||||
{
|
||||
$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from($db->quoteName('#__content')));
|
||||
$counts->articles = (int) $db->loadResult();
|
||||
|
||||
$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from($db->quoteName('#__users')));
|
||||
$counts->users = (int) $db->loadResult();
|
||||
|
||||
$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from($db->quoteName('#__extensions'))->where($db->quoteName('enabled') . ' = 1'));
|
||||
$counts->extensions = (int) $db->loadResult();
|
||||
|
||||
$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)
|
||||
{
|
||||
// Silent
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get disk usage info.
|
||||
*/
|
||||
public function getDiskInfo(): object
|
||||
{
|
||||
$free = @disk_free_space(JPATH_ROOT);
|
||||
$total = @disk_total_space(JPATH_ROOT);
|
||||
|
||||
return (object) [
|
||||
'free_mb' => $free !== false ? round($free / 1048576) : null,
|
||||
'total_mb' => $total !== false ? round($total / 1048576) : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current visitor's IP address.
|
||||
*/
|
||||
public function getCurrentIp(): string
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage mod_mokowaas_cpanel
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
// Hidden when on MokoWaaS dashboard (redundant info)
|
||||
if (!empty($hidden)) return;
|
||||
|
||||
|
||||
$siteInfo = $siteInfo ?? (object) [];
|
||||
$plugins = $plugins ?? [];
|
||||
$healthOk = $healthOk ?? true;
|
||||
$counts = $counts ?? (object) ['articles' => 0, 'users' => 0, 'extensions' => 0, 'updates' => 0];
|
||||
$disk = $disk ?? (object) ['free_mb' => null, 'total_mb' => null];
|
||||
$currentIp = $currentIp ?? '';
|
||||
$collapsed = $params->get('collapsed', 1);
|
||||
$showHealth = $params->get('show_health', 1);
|
||||
$showStats = $params->get('show_stats', 1);
|
||||
$showDisk = $params->get('show_disk', 1);
|
||||
$showIp = $params->get('show_ip', 1);
|
||||
$showPlugins = $params->get('show_plugins', 1);
|
||||
$showActions = $params->get('show_actions', 1);
|
||||
$showVersions = $params->get('show_versions', 1);
|
||||
$token = Session::getFormToken();
|
||||
|
||||
$enabledCount = 0;
|
||||
$totalCount = count($plugins);
|
||||
|
||||
foreach ($plugins as $p)
|
||||
{
|
||||
if ($p->enabled)
|
||||
{
|
||||
$enabledCount++;
|
||||
}
|
||||
}
|
||||
|
||||
$labels = [
|
||||
'mokowaas' => 'Core',
|
||||
'mokowaas_firewall' => 'Firewall',
|
||||
'mokowaas_tenant' => 'Tenant',
|
||||
'mokowaas_devtools' => 'DevTools',
|
||||
'mokowaas_monitor' => 'Monitor',
|
||||
];
|
||||
|
||||
$diskPct = ($disk->total_mb && $disk->total_mb > 0)
|
||||
? round((($disk->total_mb - ($disk->free_mb ?? 0)) / $disk->total_mb) * 100)
|
||||
: null;
|
||||
$diskColor = ($diskPct !== null && $diskPct > 90) ? 'bg-danger' : (($diskPct !== null && $diskPct > 75) ? 'bg-warning' : 'bg-success');
|
||||
?>
|
||||
|
||||
<div class="mod-mokowaas-cpanel card p-3 mb-4">
|
||||
<!-- 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">
|
||||
|
||||
<?php if ($showHealth && $showStats): ?>
|
||||
<!-- Health + stats row -->
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="border rounded p-2 text-center h-100">
|
||||
<?php if ($healthOk): ?>
|
||||
<span class="icon-check-circle text-success d-block" style="font-size:1.5rem"></span>
|
||||
<small class="text-success fw-bold">Healthy</small>
|
||||
<?php else: ?>
|
||||
<span class="icon-exclamation-circle text-danger d-block" style="font-size:1.5rem"></span>
|
||||
<small class="text-danger fw-bold">DB Error</small>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="border rounded p-2 text-center h-100">
|
||||
<span class="fw-bold d-block" style="font-size:1.25rem"><?php echo $counts->articles; ?></span>
|
||||
<small class="text-muted">Articles</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="border rounded p-2 text-center h-100">
|
||||
<span class="fw-bold d-block" style="font-size:1.25rem"><?php echo $counts->users; ?></span>
|
||||
<small class="text-muted">Users</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="border rounded p-2 text-center h-100">
|
||||
<?php if ($counts->updates > 0): ?>
|
||||
<span class="fw-bold d-block text-warning" style="font-size:1.25rem"><?php echo $counts->updates; ?></span>
|
||||
<small class="text-warning">Updates</small>
|
||||
<?php else: ?>
|
||||
<span class="icon-check d-block text-success" style="font-size:1.25rem"></span>
|
||||
<small class="text-muted">Up to date</small>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info + plugins + actions (consolidated) -->
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
<?php if ($showDisk && $diskPct !== null): ?>
|
||||
<span class="text-muted d-inline-flex align-items-center gap-1">
|
||||
<span class="icon-hdd" aria-hidden="true"></span>
|
||||
<?php echo $diskPct; ?>%
|
||||
<span class="progress d-inline-flex" style="width:40px;height:5px"><span class="progress-bar <?php echo $diskColor; ?>" style="width:<?php echo $diskPct; ?>%"></span></span>
|
||||
<?php echo number_format(($disk->free_mb ?? 0) / 1024, 1); ?>G free
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<?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; ?>
|
||||
<?php if ($showPlugins && !empty($plugins)): ?>
|
||||
<span class="border-start ps-2 ms-1"></span>
|
||||
<?php foreach ($plugins as $p): ?>
|
||||
<?php
|
||||
$label = $labels[$p->element] ?? $p->element;
|
||||
$badge = $p->enabled ? 'bg-success' : 'bg-secondary';
|
||||
$icon = $p->enabled ? 'icon-check' : 'icon-times';
|
||||
$configUrl = Route::_('index.php?option=com_plugins&task=plugin.edit&extension_id=' . (int) $p->extension_id);
|
||||
?>
|
||||
<a href="<?php echo $configUrl; ?>" class="badge <?php echo $badge; ?> text-decoration-none" title="<?php echo htmlspecialchars($p->name); ?>">
|
||||
<span class="<?php echo $icon; ?>" aria-hidden="true"></span> <?php echo htmlspecialchars($label); ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
<?php if ($showActions): ?>
|
||||
<span class="border-start ps-2 ms-1"></span>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="mokowaas-cpanel-cache"
|
||||
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.clearCache&format=json'); ?>"
|
||||
data-token="<?php echo $token; ?>">
|
||||
<span class="icon-trash" aria-hidden="true"></span> Clear Cache
|
||||
</button>
|
||||
<a href="<?php echo Route::_('index.php?option=com_installer&view=update'); ?>" class="btn btn-sm btn-outline-secondary">
|
||||
<span class="icon-refresh" aria-hidden="true"></span> Check Updates
|
||||
</a>
|
||||
<?php if ($counts->updates > 0): ?>
|
||||
<a href="<?php echo Route::_('index.php?option=com_installer&view=update'); ?>" class="badge bg-warning text-dark text-decoration-none">
|
||||
<span class="icon-upload" aria-hidden="true"></span> <?php echo $counts->updates; ?> update<?php echo $counts->updates > 1 ? 's' : ''; ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div><!-- /.collapse -->
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var btn = document.getElementById('mokowaas-cpanel-cache');
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', function() {
|
||||
var el = this;
|
||||
var url = el.dataset.url;
|
||||
var token = el.dataset.token;
|
||||
el.disabled = true;
|
||||
var icon = el.querySelector('span');
|
||||
var origClass = icon ? icon.className : '';
|
||||
if (icon) icon.className = 'icon-spinner icon-spin';
|
||||
var fd = new FormData();
|
||||
fd.append(token, '1');
|
||||
fetch(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:['Cache cleared.']});
|
||||
else Joomla.renderMessages({error:[d.message||'Failed']});
|
||||
})
|
||||
.catch(function(){Joomla.renderMessages({error:['Network error']})})
|
||||
.finally(function(){
|
||||
el.disabled = false;
|
||||
if (icon) icon.className = origClass;
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user