Compare commits
143 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cf3b51024 | |||
| 6f762534fe | |||
| c8af0fa5ca | |||
| ac920b997a | |||
| c5552a94fb | |||
| 8168bfb2dc | |||
| d73b8b06ef | |||
| f3a3bc90b3 | |||
| 756c2bff32 | |||
| b8fbb0d1d6 | |||
| fd6c79d3a2 | |||
| f350cd0169 | |||
| 4a18318cb9 | |||
| ce04701616 | |||
| b37120341f | |||
| 7f0b7756e4 | |||
| 80c2658b06 | |||
| 995fc4b591 | |||
| 240a947bec | |||
| a2091b1a67 | |||
| eab0ed1b80 | |||
| 23d6a1ad44 | |||
| 2706d81267 | |||
| ed138fdc57 | |||
| 883e7c72f0 | |||
| cb33aabb0c | |||
| fe87e9038a | |||
| c4b3892d9c | |||
| adccf3bd2a | |||
| bb03cd94d6 | |||
| 6ae5daffa2 | |||
| 614b813056 | |||
| 33ce8b115c | |||
| cf85a560e4 | |||
| 4684c4a1eb | |||
| e69953ad17 | |||
| b50661d9ee | |||
| 1ce287cb2e | |||
| 6798a5da7e | |||
| 7425c412fc | |||
| 7f64651517 | |||
| db260008a2 | |||
| 8ea724116c | |||
| 94b20b0c54 | |||
| 0e5caf6b3f | |||
| 25e2c29e2e | |||
| b5eebb0acc | |||
| f3d6ef948b | |||
| 1cdbfd035d | |||
| b7d90f9b18 | |||
| 3be42ec37a | |||
| 9565911089 | |||
| 9a375740b9 | |||
| a89d516623 | |||
| cf39c169d2 | |||
| 1ad1f1c010 | |||
| 1e6a255fab | |||
| a78178b5dd | |||
| 79c3cfc1f0 | |||
| dac5c6c052 | |||
| b4beaf5bc9 | |||
| d563e2eac8 | |||
| 267beea8f9 | |||
| 5a1a2f98b0 | |||
| ed4b06d330 | |||
| 23dc30b5f9 | |||
| af841ace19 | |||
| 8ce3452125 | |||
| 12e9115a6a | |||
| eeb4822b37 | |||
| 0632981d88 | |||
| a013755ce4 | |||
| 8240e693fb | |||
| 6a02a2b4e5 | |||
| 5d94419d9f | |||
| 3d3c918848 | |||
| d0a3b5d6a4 | |||
| 4f2aea75f5 | |||
| 7be52a964e | |||
| 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 | |||
| 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.10</version>
|
||||
<version>02.32.38</version>
|
||||
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
|
||||
</identity>
|
||||
<governance>
|
||||
|
||||
@@ -1,270 +1,283 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/universal/auto-release.yml.template
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Universal build & release � detects platform from manifest.xml
|
||||
#
|
||||
# +========================================================================+
|
||||
# | UNIVERSAL BUILD & RELEASE PIPELINE |
|
||||
# +========================================================================+
|
||||
# | |
|
||||
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
||||
# | |
|
||||
# | Platform-specific: |
|
||||
# | joomla: XML manifest, updates.xml, type-prefixed packages |
|
||||
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
||||
# | generic: README-only, no update stream |
|
||||
# | |
|
||||
# +========================================================================+
|
||||
|
||||
name: "Universal: Build & Release"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, closed]
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
action:
|
||||
description: 'Action to perform'
|
||||
required: false
|
||||
type: choice
|
||||
default: release
|
||||
options:
|
||||
- release
|
||||
- promote-rc
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────
|
||||
promote-rc:
|
||||
name: Promote to RC
|
||||
runs-on: release
|
||||
if: >-
|
||||
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||
rm -rf /tmp/moko-platform-api
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
|
||||
- name: Rename branch to rc
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/branch_rename.php \
|
||||
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
|
||||
--pr "${{ github.event.pull_request.number }}"
|
||||
|
||||
- name: Checkout rc and configure git
|
||||
run: |
|
||||
git fetch origin rc
|
||||
git checkout rc
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
- name: Publish RC release
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/release_publish.php \
|
||||
--path . --stability rc --bump minor --branch rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Branch renamed to rc, minor bump, RC + lesser stream releases built, updates.xml synced" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
|
||||
release:
|
||||
name: Build & Release Pipeline
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event.pull_request.merged == true ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure git for bot pushes
|
||||
run: |
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
|
||||
run: |
|
||||
# Ensure PHP + Composer are available
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||
rm -rf /tmp/moko-platform-api
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
|
||||
|
||||
- name: "Publish stable release"
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/release_publish.php \
|
||||
--path . --stability stable --bump minor --branch main \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
|
||||
- name: "Step 9: Mirror release to GitHub"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
secrets.GH_MIRROR_TOKEN != ''
|
||||
continue-on-error: true
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php /tmp/moko-platform-api/cli/release_mirror.php \
|
||||
--version "$VERSION" --tag "$RELEASE_TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
|
||||
--branch main 2>&1 || true
|
||||
echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- STEP 10: Sync main branch to GitHub mirror ----------------------------
|
||||
- name: "Step 10: Push main to GitHub mirror"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
secrets.GH_MIRROR_TOKEN != ''
|
||||
continue-on-error: true
|
||||
run: |
|
||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||
GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
|
||||
GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
|
||||
git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
|
||||
git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
|
||||
git fetch origin main --depth=1
|
||||
git push github origin/main:refs/heads/main --force 2>/dev/null \
|
||||
&& echo "main branch pushed to GitHub mirror" \
|
||||
|| echo "WARNING: GitHub mirror push failed"
|
||||
|
||||
- name: "Step 11: Delete rc branch and recreate dev from main"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# Delete rc branch (ephemeral — created by promote-rc)
|
||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/branches/rc" 2>/dev/null \
|
||||
&& echo "Deleted rc branch" || echo "rc branch not found"
|
||||
|
||||
# Delete dev branch
|
||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
|
||||
|
||||
# Recreate dev from main (now includes version bump + changelog promotion)
|
||||
curl -sf -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_BASE}/branches" \
|
||||
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
|
||||
|
||||
echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: "Step 12: Create version branch from main"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
BRANCH_NAME="version/${VERSION}"
|
||||
MAIN_SHA=$(git rev-parse HEAD)
|
||||
|
||||
# Delete old version branch if it exists (same version re-release)
|
||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}"
|
||||
|
||||
# Create version/XX.YY.ZZ from main
|
||||
curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed"
|
||||
|
||||
echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
|
||||
|
||||
# -- Dolibarr post-release: Reset dev version -----------------------------
|
||||
- name: "Post-release: Reset dev version"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php /tmp/moko-platform-api/cli/version_reset_dev.php \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
|
||||
--branch dev --path . 2>&1 || true
|
||||
|
||||
# -- Summary --------------------------------------------------------------
|
||||
- name: Pipeline Summary
|
||||
if: always()
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
|
||||
echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
|
||||
echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
|
||||
elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
|
||||
echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/universal/auto-release.yml.template
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: Universal build & release � detects platform from manifest.xml
|
||||
#
|
||||
# +========================================================================+
|
||||
# | UNIVERSAL BUILD & RELEASE PIPELINE |
|
||||
# +========================================================================+
|
||||
# | |
|
||||
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
||||
# | |
|
||||
# | Platform-specific: |
|
||||
# | joomla: XML manifest, updates.xml, type-prefixed packages |
|
||||
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
||||
# | generic: README-only, no update stream |
|
||||
# | |
|
||||
# +========================================================================+
|
||||
|
||||
name: "Universal: Build & Release"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, closed]
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
action:
|
||||
description: 'Action to perform'
|
||||
required: false
|
||||
type: choice
|
||||
default: release
|
||||
options:
|
||||
- release
|
||||
- promote-rc
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────
|
||||
promote-rc:
|
||||
name: Promote to RC
|
||||
runs-on: release
|
||||
if: >-
|
||||
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
run: |
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||
rm -rf /tmp/moko-platform-api
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
|
||||
- name: Rename branch to rc
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/branch_rename.php \
|
||||
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
|
||||
--pr "${{ github.event.pull_request.number }}"
|
||||
|
||||
- name: Checkout rc and configure git
|
||||
run: |
|
||||
git fetch origin rc
|
||||
git checkout rc
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
- name: Publish RC release
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/release_publish.php \
|
||||
--path . --stability rc --bump minor --branch rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Branch renamed to rc, minor bump, RC + lesser stream releases built, updates.xml synced" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
|
||||
release:
|
||||
name: Build & Release Pipeline
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event.pull_request.merged == true ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure git for bot pushes
|
||||
run: |
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
|
||||
- name: Check for merge conflict markers
|
||||
run: |
|
||||
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
|
||||
if [ -n "$CONFLICTS" ]; then
|
||||
echo "::error::Merge conflict markers found — aborting release"
|
||||
echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "No conflict markers found"
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
|
||||
run: |
|
||||
# Ensure PHP + Composer are available
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||
rm -rf /tmp/moko-platform-api
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
|
||||
|
||||
- name: "Publish stable release"
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/release_publish.php \
|
||||
--path . --stability stable --bump minor --branch main \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
|
||||
- name: "Step 9: Mirror release to GitHub"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
secrets.GH_MIRROR_TOKEN != ''
|
||||
continue-on-error: true
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php /tmp/moko-platform-api/cli/release_mirror.php \
|
||||
--version "$VERSION" --tag "$RELEASE_TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
|
||||
--branch main 2>&1 || true
|
||||
echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- STEP 10: Sync main branch to GitHub mirror ----------------------------
|
||||
- name: "Step 10: Push main to GitHub mirror"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
secrets.GH_MIRROR_TOKEN != ''
|
||||
continue-on-error: true
|
||||
run: |
|
||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||
GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
|
||||
GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
|
||||
git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
|
||||
git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
|
||||
git fetch origin main --depth=1
|
||||
git push github origin/main:refs/heads/main --force 2>/dev/null \
|
||||
&& echo "main branch pushed to GitHub mirror" \
|
||||
|| echo "WARNING: GitHub mirror push failed"
|
||||
|
||||
- name: "Step 11: Delete rc branch and recreate dev from main"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# Delete rc branch (ephemeral — created by promote-rc)
|
||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/branches/rc" 2>/dev/null \
|
||||
&& echo "Deleted rc branch" || echo "rc branch not found"
|
||||
|
||||
# Delete dev branch
|
||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
|
||||
|
||||
# Recreate dev from main (now includes version bump + changelog promotion)
|
||||
curl -sf -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_BASE}/branches" \
|
||||
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
|
||||
|
||||
echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: "Step 12: Create version branch from main"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
BRANCH_NAME="version/${VERSION}"
|
||||
MAIN_SHA=$(git rev-parse HEAD)
|
||||
|
||||
# Delete old version branch if it exists (same version re-release)
|
||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}"
|
||||
|
||||
# Create version/XX.YY.ZZ from main
|
||||
curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed"
|
||||
|
||||
echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
|
||||
|
||||
# -- Dolibarr post-release: Reset dev version -----------------------------
|
||||
- name: "Post-release: Reset dev version"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php /tmp/moko-platform-api/cli/version_reset_dev.php \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
|
||||
--branch dev --path . 2>&1 || true
|
||||
|
||||
# -- Summary --------------------------------------------------------------
|
||||
- name: Pipeline Summary
|
||||
if: always()
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
|
||||
echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
|
||||
echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
|
||||
elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
|
||||
echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Automation
|
||||
# VERSION: 02.32.10
|
||||
# VERSION: 02.32.38
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
+277
-236
@@ -1,236 +1,277 @@
|
||||
# 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: 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
|
||||
|
||||
# ── 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."
|
||||
|
||||
@@ -51,6 +51,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -14,7 +14,7 @@
|
||||
INGROUP: MokoWaaS.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
PATH: ./CHANGELOG.md
|
||||
VERSION: 02.32.10
|
||||
VERSION: 02.32.38
|
||||
BRIEF: Version history using `Keep a Changelog`
|
||||
-->
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.10
|
||||
VERSION: 02.32.38
|
||||
PATH: ./CODE_OF_CONDUCT.md
|
||||
BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default
|
||||
-->
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@
|
||||
DEFGROUP: mokoconsulting-tech.MokoWaaSBrand
|
||||
INGROUP: MokoStandards.Governance
|
||||
REPO: https://github.com/mokoconsulting-tech/MokoWaaSBrand
|
||||
VERSION: 02.32.10
|
||||
VERSION: 02.32.38
|
||||
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.10
|
||||
VERSION: 02.32.38
|
||||
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.10
|
||||
VERSION: 02.32.38
|
||||
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.10
|
||||
VERSION: 02.32.38
|
||||
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.10
|
||||
VERSION: 02.32.38
|
||||
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.10)
|
||||
# MokoWaaS Build Guide (VERSION: 02.32.38)
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.10
|
||||
VERSION: 02.32.38
|
||||
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.10)
|
||||
# MokoWaaS Configuration Guide (VERSION: 02.32.38)
|
||||
|
||||
## 1. Objective
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.10
|
||||
VERSION: 02.32.38
|
||||
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.10)
|
||||
# MokoWaaS Installation Guide (VERSION: 02.32.38)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.10
|
||||
VERSION: 02.32.38
|
||||
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.10)
|
||||
# MokoWaaS Operations Guide (VERSION: 02.32.38)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.10
|
||||
VERSION: 02.32.38
|
||||
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.10)
|
||||
# MokoWaaS Rollback and Recovery Guide (VERSION: 02.32.38)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.10
|
||||
VERSION: 02.32.38
|
||||
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.10)
|
||||
# MokoWaaS Testing Guide (VERSION: 02.32.38)
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.10
|
||||
VERSION: 02.32.38
|
||||
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.10)
|
||||
# MokoWaaS Troubleshooting Guide (VERSION: 02.32.38)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Guides
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.10
|
||||
VERSION: 02.32.38
|
||||
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.10)
|
||||
# MokoWaaS Upgrade and Versioning Guide (VERSION: 02.32.38)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
+2
-2
@@ -10,13 +10,13 @@
|
||||
DEFGROUP: Joomla.Plugin
|
||||
INGROUP: MokoWaaS.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
VERSION: 02.32.10
|
||||
VERSION: 02.32.38
|
||||
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.10)
|
||||
# MokoWaaS Documentation Index (VERSION: 02.32.38)
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
INGROUP: MokoWaaS
|
||||
REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
PATH: /docs/plugin-basic.md
|
||||
VERSION: 02.32.10
|
||||
VERSION: 02.32.38
|
||||
BRIEF: Baseline documentation for the MokoWaaS system plugin
|
||||
NOTE: Foundational reference for internal and external stakeholders
|
||||
-->
|
||||
|
||||
# MokoWaaS Plugin Overview (VERSION: 02.32.10)
|
||||
# MokoWaaS Plugin Overview (VERSION: 02.32.38)
|
||||
|
||||
## 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.10
|
||||
VERSION: 02.32.38
|
||||
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>
|
||||
@@ -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,11 @@
|
||||
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_CACHE="Cache Management"
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
--
|
||||
-- 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);
|
||||
@@ -20,72 +20,318 @@ 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',
|
||||
];
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
$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();
|
||||
}
|
||||
|
||||
$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();
|
||||
}
|
||||
|
||||
$downloadUrl = Factory::getApplication()->getInput()->getString('download_url', '');
|
||||
|
||||
if (empty($downloadUrl))
|
||||
{
|
||||
$this->jsonResponse(['success' => false, 'message' => 'Missing download URL.']);
|
||||
}
|
||||
|
||||
$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();
|
||||
}
|
||||
|
||||
$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();
|
||||
}
|
||||
|
||||
$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();
|
||||
}
|
||||
|
||||
$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();
|
||||
}
|
||||
|
||||
$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();
|
||||
}
|
||||
|
||||
$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' => []]);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Importers
|
||||
// ==================================================================
|
||||
|
||||
public function importAts()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('mokowaas.tickets'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
}
|
||||
|
||||
$this->jsonResponse($this->getModel('Import')->importAts());
|
||||
}
|
||||
|
||||
public function importAdminTools()
|
||||
{
|
||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
||||
|
||||
if (!$this->checkAcl('core.admin'))
|
||||
{
|
||||
$this->jsonForbidden();
|
||||
}
|
||||
|
||||
$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')]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -302,4 +327,124 @@ 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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/kb/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/kb/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/kb/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/kb/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/kb/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/kb/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/kb/mokogallerycalendar',
|
||||
'protected' => false,
|
||||
],
|
||||
'MokoJoomOpenGraph' => [
|
||||
'label' => 'MokoJoomOpenGraph',
|
||||
'description' => 'Open Graph meta tags for articles, categories, and pages. Controls Facebook, Twitter, and LinkedIn link previews.',
|
||||
'element' => 'pkg_mokoog',
|
||||
'type' => 'package',
|
||||
'icon' => 'icon-share-alt',
|
||||
'category' => 'Components',
|
||||
'article' => 'https://mokoconsulting.tech/kb/mokojoomopengraph',
|
||||
'protected' => false,
|
||||
],
|
||||
];
|
||||
|
||||
private const GITEA_URL = 'https://git.mokoconsulting.tech';
|
||||
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,617 @@
|
||||
<?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.
|
||||
*/
|
||||
public function checkAdminToolsAvailable(): ?object
|
||||
{
|
||||
$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;
|
||||
|
||||
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.
|
||||
*/
|
||||
public function checkAtsAvailable(): ?object
|
||||
{
|
||||
$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();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,793 @@
|
||||
<?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 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
|
||||
$this->runAutomation('ticket_created', (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 if first staff reply
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__mokowaas_tickets'))
|
||||
->set($db->quoteName('modified') . ' = ' . $db->quote($now))
|
||||
->set($db->quoteName('sla_responded') . ' = 1')
|
||||
->where($db->quoteName('id') . ' = ' . $ticketId)
|
||||
->where($db->quoteName('sla_responded') . ' = 0')
|
||||
)->execute();
|
||||
|
||||
// Run automation
|
||||
$this->runAutomation('ticket_replied', $ticketId);
|
||||
|
||||
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();
|
||||
|
||||
$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
|
||||
$this->runAutomation('status_changed', $ticketId);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,26 +17,39 @@ 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 = [];
|
||||
|
||||
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);
|
||||
|
||||
// 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,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,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');
|
||||
}
|
||||
}
|
||||
@@ -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,276 @@ $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>
|
||||
</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 Community Builder if available, otherwise Joomla user manager
|
||||
$useCB = file_exists(JPATH_ADMINISTRATOR . '/components/com_comprofiler/comprofiler.php');
|
||||
$userUrl = $useCB
|
||||
? Route::_('index.php?option=com_comprofiler&task=showusers')
|
||||
: Route::_('index.php?option=com_users');
|
||||
$userLabel = $useCB ? 'Community Builder' : 'User Manager';
|
||||
?>
|
||||
<a href="<?php echo $userUrl; ?>" class="btn btn-outline-secondary w-100 py-3">
|
||||
<span class="icon-users d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
|
||||
<?php echo $userLabel; ?>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 col-md-4 col-xl-3">
|
||||
<a href="<?php echo Route::_('index.php?option=com_redirect'); ?>" class="btn btn-outline-secondary w-100 py-3">
|
||||
<span class="icon-arrow-right d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
|
||||
Redirects
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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 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">
|
||||
<?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 small" 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: Information Tables (4 cols) -->
|
||||
<div class="col-12 col-xl-4">
|
||||
|
||||
<!-- 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="small"><?php echo $this->escape($upd->name); ?></td>
|
||||
<td class="small text-muted"><?php echo $this->escape($upd->current_version); ?></td>
|
||||
<td class="small text-success"><?php echo $this->escape($upd->version); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="card-body text-center text-muted small 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="small"><?php echo $this->escape(mb_substr($item->title, 0, 30)); ?></td>
|
||||
<td class="small"><?php echo $this->escape($item->username ?? ''); ?></td>
|
||||
<td class="small text-muted"><?php echo HTMLHelper::_('date', $item->checked_out_time, 'M d H:i'); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer text-center py-1">
|
||||
<a href="<?php echo Route::_('index.php?option=com_checkin'); ?>" class="small">Global Check-in</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="card-body text-center text-muted small 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="small"><code><?php echo $this->escape($block->ip); ?></code></td>
|
||||
<td class="small"><span class="badge bg-danger"><?php echo $this->escape($block->rule); ?></span></td>
|
||||
<td class="small text-muted"><?php echo HTMLHelper::_('date', $block->created, 'M d H:i'); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="card-body text-center text-muted small 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="small"><?php echo $this->escape($login->username ?? ''); ?></td>
|
||||
<td class="small"><code><?php echo $this->escape($login->ip_address ?? ''); ?></code></td>
|
||||
<td class="small text-muted"><?php echo HTMLHelper::_('date', $login->log_date, 'M d H:i'); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="card-body text-center text-muted small py-3">No login activity recorded</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
</div><!-- /.col-xl-4 -->
|
||||
</div><!-- /.row -->
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<?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'): ?>
|
||||
<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,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,290 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
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 $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>
|
||||
@@ -109,4 +109,26 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Akeeba import buttons
|
||||
['btn-import-admintools', 'btn-import-ats-dash'].forEach(function(id) {
|
||||
var btn = document.getElementById(id);
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', function() {
|
||||
var el = this;
|
||||
if (!confirm('Import Akeeba data into MokoWaaS? Akeeba extensions will be disabled after import.')) return;
|
||||
el.disabled = true;
|
||||
var origText = el.textContent;
|
||||
el.textContent = ' Importing...';
|
||||
var fd = new FormData();
|
||||
fd.append(el.dataset.token, '1');
|
||||
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){
|
||||
if (d.success) { Joomla.renderMessages({message:[d.message]}); setTimeout(function(){location.reload()}, 2000); }
|
||||
else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; el.textContent = origText; }
|
||||
})
|
||||
.catch(function(){ Joomla.renderMessages({error:['Network error']}); el.disabled = false; el.textContent = origText; });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,21 +20,44 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.32.10</version>
|
||||
<version>02.32.38</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_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>
|
||||
<folder>language</folder>
|
||||
<folder>services</folder>
|
||||
<folder>sql</folder>
|
||||
<folder>src</folder>
|
||||
<folder>tmpl</folder>
|
||||
</files>
|
||||
</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,239 @@
|
||||
<?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.']);
|
||||
}
|
||||
|
||||
$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.']);
|
||||
}
|
||||
|
||||
$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.']);
|
||||
}
|
||||
|
||||
// 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.']);
|
||||
}
|
||||
|
||||
// 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.']);
|
||||
}
|
||||
|
||||
$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.']);
|
||||
}
|
||||
|
||||
$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()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,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,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>
|
||||
@@ -5,8 +5,22 @@
|
||||
MOD_MOKOWAAS_CPANEL="MokoWaaS"
|
||||
MOD_MOKOWAAS_CPANEL_DESC="Displays MokoWaaS feature plugin status and site health on the admin dashboard."
|
||||
|
||||
MOD_MOKOWAAS_CPANEL_SHOW_HEALTH_LABEL="Show Health Status"
|
||||
MOD_MOKOWAAS_CPANEL_SHOW_PLUGINS_LABEL="Show Feature Plugins"
|
||||
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"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.32.10</version>
|
||||
<version>02.32.38</version>
|
||||
<description>MOD_MOKOWAAS_CPANEL_DESC</description>
|
||||
<namespace path="src">Moko\Module\MokoWaaSCpanel</namespace>
|
||||
|
||||
@@ -24,19 +24,69 @@
|
||||
|
||||
<config>
|
||||
<fields name="params">
|
||||
<fieldset name="basic">
|
||||
<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>
|
||||
|
||||
@@ -47,6 +47,7 @@ class CpanelHelper
|
||||
{
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('extension_id'),
|
||||
$db->quoteName('name'),
|
||||
$db->quoteName('element'),
|
||||
$db->quoteName('enabled'),
|
||||
|
||||
@@ -19,9 +19,15 @@ $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 ?? '';
|
||||
$showHealth = $params->get('show_health', 1);
|
||||
$showPlugins = $params->get('show_plugins', 1);
|
||||
$token = Session::getFormToken();
|
||||
$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);
|
||||
@@ -48,10 +54,10 @@ $diskPct = ($disk->total_mb && $disk->total_mb > 0)
|
||||
$diskColor = ($diskPct !== null && $diskPct > 90) ? 'bg-danger' : (($diskPct !== null && $diskPct > 75) ? 'bg-warning' : 'bg-success');
|
||||
?>
|
||||
|
||||
<div class="mod-mokowaas-cpanel p-3">
|
||||
<!-- Header row -->
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="mod-mokowaas-cpanel card p-3 mb-4">
|
||||
<!-- Header row (always visible, acts as collapse toggle) -->
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<a class="d-flex align-items-center gap-2 text-decoration-none text-reset" data-bs-toggle="collapse" href="#mokowaas-cpanel-body" role="button" aria-expanded="<?php echo $collapsed ? 'false' : 'true'; ?>" aria-controls="mokowaas-cpanel-body">
|
||||
<span class="icon-shield-alt" aria-hidden="true" style="font-size:1.25rem;color:#1a2744"></span>
|
||||
<strong>MokoWaaS</strong>
|
||||
<span class="badge bg-primary"><?php echo htmlspecialchars($siteInfo->mokowaas_version ?? ''); ?></span>
|
||||
@@ -61,14 +67,18 @@ $diskColor = ($diskPct !== null && $diskPct > 90) ? 'bg-danger' : (($diskPct !==
|
||||
<?php if (!empty($siteInfo->offline)): ?>
|
||||
<span class="badge bg-danger">Offline</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<span class="icon-chevron-down small text-muted" aria-hidden="true"></span>
|
||||
</a>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokowaas'); ?>" class="btn btn-sm btn-primary">
|
||||
<span class="icon-cogs" aria-hidden="true"></span>
|
||||
<?php echo Text::_('MOD_MOKOWAAS_CPANEL_OPEN_DASHBOARD'); ?>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<?php if ($showHealth): ?>
|
||||
<!-- 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">
|
||||
@@ -107,52 +117,38 @@ $diskColor = ($diskPct !== null && $diskPct > 90) ? 'bg-danger' : (($diskPct !==
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Disk + IP row -->
|
||||
<div class="d-flex flex-wrap align-items-center gap-3 mb-3 small text-muted">
|
||||
<?php if ($diskPct !== null): ?>
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<!-- 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>
|
||||
<span>Disk <?php echo $diskPct; ?>%</span>
|
||||
<div class="progress" style="width:60px;height:6px">
|
||||
<div class="progress-bar <?php echo $diskColor; ?>" style="width:<?php echo $diskPct; ?>%"></div>
|
||||
</div>
|
||||
<span><?php echo number_format(($disk->free_mb ?? 0) / 1024, 1); ?> GB free</span>
|
||||
</div>
|
||||
<?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 ($currentIp): ?>
|
||||
<div>
|
||||
<span class="icon-globe" aria-hidden="true"></span>
|
||||
Your IP: <code><?php echo htmlspecialchars($currentIp); ?></code>
|
||||
</div>
|
||||
<?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; ?>
|
||||
<div class="ms-auto">
|
||||
Joomla <?php echo htmlspecialchars($siteInfo->joomla_version ?? ''); ?> / PHP <?php echo htmlspecialchars($siteInfo->php_version ?? ''); ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($showPlugins && !empty($plugins)): ?>
|
||||
<!-- Feature plugin badges -->
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<small class="text-muted"><?php echo Text::sprintf('MOD_MOKOWAAS_CPANEL_PLUGINS_SUMMARY', $enabledCount, $totalCount); ?></small>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
<?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';
|
||||
?>
|
||||
<span class="badge <?php echo $badge; ?>" title="<?php echo htmlspecialchars($p->name); ?>">
|
||||
<span class="<?php echo $icon; ?>" aria-hidden="true"></span>
|
||||
<?php echo htmlspecialchars($label); ?>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Quick action buttons -->
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<?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; ?>">
|
||||
@@ -162,11 +158,15 @@ $diskColor = ($diskPct !== null && $diskPct > 90) ? 'bg-danger' : (($diskPct !==
|
||||
<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="btn btn-sm btn-warning">
|
||||
<span class="icon-upload" aria-hidden="true"></span> <?php echo $counts->updates; ?> Update<?php echo $counts->updates > 1 ? 's' : ''; ?> Available
|
||||
<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>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoWaaS
|
||||
* REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
* VERSION: 02.32.10
|
||||
* VERSION: 02.32.38
|
||||
* PATH: /src/Extension/MokoWaaS.php
|
||||
* NOTE: Handles Joomla system events for rebranding functionality
|
||||
*/
|
||||
@@ -161,20 +161,7 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
*/
|
||||
public function boot(ContainerInterface $container): void
|
||||
{
|
||||
$timeout = (int) $this->params->get('admin_session_timeout', 0);
|
||||
|
||||
if ($timeout <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->ipIsTrusted())
|
||||
{
|
||||
// Set both PHP and Joomla session lifetimes before the
|
||||
// session handler runs its expiry check.
|
||||
ini_set('session.gc_maxlifetime', 315360000);
|
||||
Factory::getConfig()->set('lifetime', 525600);
|
||||
}
|
||||
// Session lifetime for trusted IPs is now handled by the firewall plugin
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,12 +176,7 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
*/
|
||||
public function onAfterInitialise()
|
||||
{
|
||||
// Security: HTTPS redirect (runs for all clients)
|
||||
$this->enforceHttps();
|
||||
|
||||
// Site alias handling: offline page and backend redirect.
|
||||
// Must run in onAfterInitialise (not onAfterRoute) so that
|
||||
// Joomla's offline check in doExecute() sees the updated config.
|
||||
// Site alias handling
|
||||
$this->handleSiteAlias();
|
||||
|
||||
// MokoWaaS API endpoints (run before routing)
|
||||
@@ -205,18 +187,15 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
$this->handleMokoApi($mokoAction);
|
||||
}
|
||||
|
||||
// Dev mode: disable caching
|
||||
$this->enforceDevMode();
|
||||
|
||||
// Admin-only WaaS controls
|
||||
// Admin-only core controls (branding, emergency access, master user)
|
||||
// NOTE: enforceHttps, enforceDevMode, enforceAdminSessionTimeout,
|
||||
// enforceUploadRestrictions are now in feature plugins
|
||||
if ($this->app->isClient('administrator'))
|
||||
{
|
||||
$this->handleEmergencyAccess();
|
||||
$this->enforceMasterUser();
|
||||
$this->enforceLoginSupportUrls();
|
||||
$this->enforceAtumBranding();
|
||||
$this->enforceAdminSessionTimeout();
|
||||
$this->enforceUploadRestrictions();
|
||||
}
|
||||
|
||||
$this->loadLanguageOverrides();
|
||||
@@ -815,7 +794,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
return $strings;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Event triggered after an extension's config is saved.
|
||||
*
|
||||
@@ -883,41 +861,7 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
// Grafana auto-provisioning
|
||||
$this->handleGrafanaProvisioning($params, $app);
|
||||
|
||||
if ((int) $params->get('reset_hits', 0) === 1)
|
||||
{
|
||||
$count = $this->resetAllHits();
|
||||
$params->set('reset_hits', '0');
|
||||
$changed = true;
|
||||
|
||||
$app->enqueueMessage(
|
||||
sprintf('Reset hit counters on %d articles.', $count),
|
||||
'message'
|
||||
);
|
||||
|
||||
Log::add(
|
||||
sprintf('All article hits reset (%d rows) by MokoWaaS', $count),
|
||||
Log::WARNING,
|
||||
'mokowaas'
|
||||
);
|
||||
}
|
||||
|
||||
if ((int) $params->get('delete_versions', 0) === 1)
|
||||
{
|
||||
$count = $this->deleteAllVersions();
|
||||
$params->set('delete_versions', '0');
|
||||
$changed = true;
|
||||
|
||||
$app->enqueueMessage(
|
||||
sprintf('Deleted %d version history records.', $count),
|
||||
'message'
|
||||
);
|
||||
|
||||
Log::add(
|
||||
sprintf('All content versions purged (%d rows) by MokoWaaS', $count),
|
||||
Log::WARNING,
|
||||
'mokowaas'
|
||||
);
|
||||
}
|
||||
// NOTE: reset_hits and delete_versions now handled by devtools plugin
|
||||
|
||||
// Content Sync: Push Now
|
||||
if ((int) $params->get('sync_push_now', 0) === 1)
|
||||
@@ -977,48 +921,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all article hit counters to zero.
|
||||
*
|
||||
* @return int Number of rows affected
|
||||
*
|
||||
* @since 02.01.08
|
||||
*/
|
||||
protected function resetAllHits()
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__content'))
|
||||
->set($db->quoteName('hits') . ' = 0')
|
||||
->where($db->quoteName('hits') . ' > 0')
|
||||
);
|
||||
$db->execute();
|
||||
|
||||
return $db->getAffectedRows();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all content version history records.
|
||||
*
|
||||
* @return int Number of rows deleted
|
||||
*
|
||||
* @since 02.01.08
|
||||
*/
|
||||
protected function deleteAllVersions()
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete($db->quoteName('#__history'))
|
||||
);
|
||||
$db->execute();
|
||||
|
||||
return $db->getAffectedRows();
|
||||
}
|
||||
|
||||
/**
|
||||
* Event triggered after the route has been determined.
|
||||
*
|
||||
@@ -1036,8 +938,8 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
return;
|
||||
}
|
||||
|
||||
$this->warnMissingLicenseKey();
|
||||
$this->enforceAdminRestrictions();
|
||||
// NOTE: warnMissingLicenseKey and enforceAdminRestrictions
|
||||
// are now handled by feature plugins (deferred / tenant)
|
||||
$this->protectPlugin();
|
||||
}
|
||||
|
||||
@@ -1464,106 +1366,97 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter admin menu items for non-master users.
|
||||
* Cascade enable/disable state across all MokoWaaS extensions.
|
||||
*
|
||||
* @param string $context Menu context
|
||||
* @param array &$items Menu items (by reference)
|
||||
* @param mixed $params Module params
|
||||
* @param mixed $enabled Whether module is enabled
|
||||
* When the core system plugin (plg_system_mokowaas) is disabled,
|
||||
* all feature plugins and the cpanel module are also disabled.
|
||||
* When re-enabled, they are re-enabled too.
|
||||
*
|
||||
* @param string $context The extension context
|
||||
* @param array $pks Extension IDs being changed
|
||||
* @param int $value New state (1=enabled, 0=disabled)
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.01.08
|
||||
* @since 02.32.00
|
||||
*/
|
||||
public function onPreprocessMenuItems($context, &$items, $params, $enabled)
|
||||
public function onExtensionChangeState($context, $pks, $value)
|
||||
{
|
||||
if (!$this->app->isClient('administrator'))
|
||||
if (empty($pks))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isMasterUser())
|
||||
try
|
||||
{
|
||||
return;
|
||||
}
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$hidden = $this->getHiddenMenuComponents();
|
||||
// Check if the core MokoWaaS plugin is among the changed extensions
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('extension_id'))
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('folder') . ' = ' . $db->quote('system'));
|
||||
$db->setQuery($query);
|
||||
$coreId = (int) $db->loadResult();
|
||||
|
||||
if (empty($hidden))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($items as $key => $item)
|
||||
{
|
||||
foreach ($hidden as $component)
|
||||
if (!$coreId || !\in_array($coreId, array_map('intval', $pks), true))
|
||||
{
|
||||
if (isset($item->link)
|
||||
&& strpos($item->link, 'option=' . $component) !== false)
|
||||
{
|
||||
unset($items[$key]);
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce password policy before user save.
|
||||
*
|
||||
* @param array $oldUser Existing user data
|
||||
* @param boolean $isNew Whether this is a new user
|
||||
* @param array $newUser New user data being saved
|
||||
*
|
||||
* @return boolean True to allow save
|
||||
*
|
||||
* @since 02.01.08
|
||||
*/
|
||||
public function onUserBeforeSave($oldUser, $isNew, $newUser)
|
||||
{
|
||||
if (empty($newUser['password_clear']))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// Cascade to all MokoWaaS feature plugins + module
|
||||
$mokoElements = [
|
||||
$db->quote('mokowaas_firewall'),
|
||||
$db->quote('mokowaas_tenant'),
|
||||
$db->quote('mokowaas_devtools'),
|
||||
$db->quote('mokowaas_offline'),
|
||||
$db->quote('mod_mokowaas_cpanel'),
|
||||
];
|
||||
|
||||
$password = $newUser['password_clear'];
|
||||
$errors = [];
|
||||
$query = $db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('enabled') . ' = ' . (int) $value)
|
||||
->where($db->quoteName('element') . ' IN (' . implode(',', $mokoElements) . ')');
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
$affected = $db->getAffectedRows();
|
||||
|
||||
$minLen = (int) $this->params->get('password_min_length', 12);
|
||||
// Also update module published state
|
||||
if ($value == 0)
|
||||
{
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__modules'))
|
||||
->set($db->quoteName('published') . ' = 0')
|
||||
->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokowaas_cpanel'))
|
||||
)->execute();
|
||||
}
|
||||
else
|
||||
{
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__modules'))
|
||||
->set($db->quoteName('published') . ' = 1')
|
||||
->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokowaas_cpanel'))
|
||||
)->execute();
|
||||
}
|
||||
|
||||
if (strlen($password) < $minLen)
|
||||
{
|
||||
$errors[] = sprintf(
|
||||
'Password must be at least %d characters.', $minLen
|
||||
$state = $value ? 'enabled' : 'disabled';
|
||||
$this->app->enqueueMessage(
|
||||
"MokoWaaS: {$state} {$affected} associated extensions.",
|
||||
'message'
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->params->get('password_require_uppercase', 1)
|
||||
&& !preg_match('/[A-Z]/', $password))
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$errors[] = 'Password must contain an uppercase letter.';
|
||||
Log::add('MokoWaaS cascade state error: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
|
||||
}
|
||||
|
||||
if ($this->params->get('password_require_number', 1)
|
||||
&& !preg_match('/\d/', $password))
|
||||
{
|
||||
$errors[] = 'Password must contain a number.';
|
||||
}
|
||||
|
||||
if ($this->params->get('password_require_special', 1)
|
||||
&& !preg_match('/[^A-Za-z0-9]/', $password))
|
||||
{
|
||||
$errors[] = 'Password must contain a special character.';
|
||||
}
|
||||
|
||||
if (!empty($errors))
|
||||
{
|
||||
throw new \RuntimeException(implode(' ', $errors));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// onPreprocessMenuItems — REMOVED, now in plg_system_mokowaas_tenant
|
||||
// onUserBeforeSave — REMOVED, now in plg_system_mokowaas_firewall
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Diagnostics / Health Endpoint (called from onAfterInitialise)
|
||||
@@ -4330,130 +4223,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
// License key check (called from onAfterRoute)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Show a persistent admin warning if no license key is set on the
|
||||
* MokoWaaS update site.
|
||||
*
|
||||
* Checks the extra_query column in #__update_sites for a dlid value.
|
||||
* Also validates the key against MokoGitea on a heartbeat interval
|
||||
* (once per day) and warns if the key is invalid or expired.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.31.00
|
||||
*/
|
||||
protected function warnMissingLicenseKey(): void
|
||||
{
|
||||
// Only show to master users
|
||||
if (!$this->isMasterUser())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Only warn once per session
|
||||
$session = Factory::getSession();
|
||||
|
||||
if ($session->get('mokowaas.license_warned', false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$session->set('mokowaas.license_warned', true);
|
||||
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('extra_query'))
|
||||
->from($db->quoteName('#__update_sites'))
|
||||
->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoWaaS%')
|
||||
. ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoWaaS%') . ')')
|
||||
->setLimit(1);
|
||||
$db->setQuery($query);
|
||||
$extraQuery = (string) $db->loadResult();
|
||||
|
||||
if (empty($extraQuery) || strpos($extraQuery, 'dlid=') === false)
|
||||
{
|
||||
$this->app->enqueueMessage(
|
||||
'<strong>Moko Consulting License Key Required</strong> — '
|
||||
. 'No download key is configured. Updates will not be available until a valid license key is entered. '
|
||||
. 'Go to <a href="index.php?option=com_installer&view=updatesites">System → Update Sites</a> '
|
||||
. 'and enter your license key in the Download Key field for the MokoWaaS update site.',
|
||||
'warning'
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract the key value from extra_query
|
||||
parse_str($extraQuery, $parsed);
|
||||
$licenseKey = $parsed['dlid'] ?? '';
|
||||
|
||||
if (empty($licenseKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Heartbeat validation — check once per day
|
||||
$session = Factory::getSession();
|
||||
$lastCheck = (int) $session->get('mokowaas.license_check', 0);
|
||||
$now = time();
|
||||
|
||||
if (($now - $lastCheck) < 86400)
|
||||
{
|
||||
// Show cached warning if key was invalid last check
|
||||
if ($session->get('mokowaas.license_invalid', false))
|
||||
{
|
||||
$this->app->enqueueMessage(
|
||||
'<strong>Moko Consulting License Key Invalid</strong> — '
|
||||
. 'Your license key could not be validated. Please verify your key in '
|
||||
. '<a href="index.php?option=com_installer&view=updatesites">System → Update Sites</a>.',
|
||||
'error'
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate against MokoGitea
|
||||
$session->set('mokowaas.license_check', $now);
|
||||
|
||||
$validateUrl = 'https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/updates.xml'
|
||||
. '?dlid=' . urlencode($licenseKey)
|
||||
. '&domain=' . urlencode(Uri::root());
|
||||
|
||||
$ch = curl_init($validateUrl);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
// Empty <updates></updates> or non-200 means invalid key
|
||||
$isValid = ($httpCode === 200 && $response && strpos($response, '<update>') !== false);
|
||||
|
||||
$session->set('mokowaas.license_invalid', !$isValid);
|
||||
|
||||
if (!$isValid)
|
||||
{
|
||||
$this->app->enqueueMessage(
|
||||
'<strong>Moko Consulting License Key Invalid</strong> — '
|
||||
. 'Your license key could not be validated. Updates will not be available. '
|
||||
. 'Please verify your key in '
|
||||
. '<a href="index.php?option=com_installer&view=updatesites">System → Update Sites</a>.',
|
||||
'error'
|
||||
);
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
// Silent — license check is non-critical
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -4589,110 +4358,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
*
|
||||
* @since 02.01.08
|
||||
*/
|
||||
/**
|
||||
* Enforce development mode settings.
|
||||
*
|
||||
* When dev mode is ON:
|
||||
* - Disable Joomla caching
|
||||
* - Enable Joomla debug mode (Global Config)
|
||||
* - Enable MokoOnyx template debug
|
||||
* - Disable article hit recording
|
||||
*
|
||||
* When dev mode is OFF (and was previously on):
|
||||
* - Reset all content version history
|
||||
* - Reset article published dates to now
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.01.15
|
||||
*/
|
||||
protected function enforceDevMode()
|
||||
{
|
||||
if (!$this->params->get('dev_mode', 0))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable caching
|
||||
$config = Factory::getConfig();
|
||||
$config->set('caching', 0);
|
||||
|
||||
// Enable Joomla debug
|
||||
$config->set('debug', 1);
|
||||
|
||||
// Enable MokoOnyx template debug
|
||||
$this->setTemplateParam('mokoonyx', 'debug', 1);
|
||||
|
||||
// Show offline page on primary domain only — site aliases
|
||||
// and dev.* subdomains bypass offline mode for development
|
||||
$currentHost = $_SERVER['HTTP_HOST'] ?? '';
|
||||
$primaryDomain = $this->params->get('primary_domain', '');
|
||||
|
||||
if (!empty($primaryDomain) && $currentHost === $primaryDomain)
|
||||
{
|
||||
$config->set('offline', 1);
|
||||
}
|
||||
|
||||
// Suppress hit recording
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__content'))
|
||||
->set($db->quoteName('hits') . ' = 0')
|
||||
->where($db->quoteName('hits') . ' > 0')
|
||||
)->execute();
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
// Silent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions to run when dev mode is turned off.
|
||||
*
|
||||
* Resets content versions and hits, disables debug.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.31.00
|
||||
*/
|
||||
protected function onDevModeDisabled(): void
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Delete all content version history
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)->delete($db->quoteName('#__history'))
|
||||
)->execute();
|
||||
|
||||
// Reset hits
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__content'))
|
||||
->set($db->quoteName('hits') . ' = 0')
|
||||
)->execute();
|
||||
|
||||
// Disable debug
|
||||
$this->setTemplateParam('mokoonyx', 'debug', 0);
|
||||
|
||||
// Take site back online
|
||||
Factory::getConfig()->set('offline', 0);
|
||||
|
||||
$this->app->enqueueMessage(
|
||||
'Development mode disabled — versions cleared, hits reset, debug off, site online.',
|
||||
'message'
|
||||
);
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
Log::add('Dev mode cleanup failed: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a parameter on a template style.
|
||||
@@ -4740,194 +4405,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
}
|
||||
}
|
||||
|
||||
protected function enforceHttps()
|
||||
{
|
||||
if (!$this->params->get('force_https', 0))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->app->isClient('cli'))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$isHttps = (!empty($_SERVER['HTTPS'])
|
||||
&& $_SERVER['HTTPS'] !== 'off')
|
||||
|| ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
|
||||
|
||||
if (!$isHttps)
|
||||
{
|
||||
$this->app->redirect(
|
||||
'https://' . $_SERVER['HTTP_HOST']
|
||||
. $_SERVER['REQUEST_URI'], 301
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce admin session idle timeout.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.01.08
|
||||
*/
|
||||
protected function enforceAdminSessionTimeout()
|
||||
{
|
||||
$timeout = (int) $this->params->get('admin_session_timeout', 0);
|
||||
|
||||
if ($timeout <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't timeout the master user
|
||||
if ($this->isMasterUser())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Trusted IPs — session lifetime already extended in boot()
|
||||
if ($this->ipIsTrusted())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$session = Factory::getSession();
|
||||
$lastHit = $session->get('mokowaas.last_activity', 0);
|
||||
$now = time();
|
||||
|
||||
if ($lastHit > 0 && ($now - $lastHit) > ($timeout * 60))
|
||||
{
|
||||
$this->app->logout();
|
||||
$this->app->redirect(
|
||||
Route::_('index.php', false)
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$session->set('mokowaas.last_activity', $now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the current request IP matches any trusted IP entry.
|
||||
*
|
||||
* Supports exact IPs, CIDR notation (e.g. 10.0.0.0/8), and
|
||||
* wildcard patterns (e.g. 192.168.1.*).
|
||||
*
|
||||
* @return bool True if the current IP is in the trusted list.
|
||||
*
|
||||
* @since 02.11.00
|
||||
*/
|
||||
protected function ipIsTrusted(): bool
|
||||
{
|
||||
$entries = $this->params->get('trusted_ips', '');
|
||||
|
||||
if (empty($entries))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Subform stores as JSON string or array
|
||||
if (\is_string($entries))
|
||||
{
|
||||
$entries = json_decode($entries, true);
|
||||
}
|
||||
|
||||
if (!\is_array($entries))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
$ip = $this->app
|
||||
? $this->app->input->server->getString('REMOTE_ADDR', '')
|
||||
: ($_SERVER['REMOTE_ADDR'] ?? '');
|
||||
$ipLong = ip2long($ip);
|
||||
|
||||
if ($ipLong === false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($entries as $entry)
|
||||
{
|
||||
if (empty($entry['enabled']) || empty($entry['ip']))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$range = trim($entry['ip']);
|
||||
|
||||
// Wildcard: 192.168.1.*
|
||||
if (str_contains($range, '*'))
|
||||
{
|
||||
$pattern = '/^' . str_replace(['.', '*'], ['\\.', '\\d+'], $range) . '$/';
|
||||
|
||||
if (preg_match($pattern, $ip))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// CIDR: 10.0.0.0/8
|
||||
if (str_contains($range, '/'))
|
||||
{
|
||||
[$subnet, $bits] = explode('/', $range, 2);
|
||||
$subnetLong = ip2long($subnet);
|
||||
$mask = -1 << (32 - (int) $bits);
|
||||
|
||||
if ($subnetLong !== false && ($ipLong & $mask) === ($subnetLong & $mask))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Exact match
|
||||
if ($ip === $range)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Override Joomla upload restrictions at runtime.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.01.08
|
||||
*/
|
||||
protected function enforceUploadRestrictions()
|
||||
{
|
||||
$types = $this->params->get('upload_allowed_types', '');
|
||||
$maxMb = (int) $this->params->get('upload_max_size_mb', 0);
|
||||
|
||||
if (empty($types) && $maxMb <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$config = $this->app->getConfig();
|
||||
|
||||
if (!empty($types))
|
||||
{
|
||||
$config->set('upload_extensions', $types);
|
||||
}
|
||||
|
||||
if ($maxMb > 0)
|
||||
{
|
||||
$config->set('upload_maxsize', $maxMb);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce login support module URLs on admin requests.
|
||||
*
|
||||
@@ -4996,121 +4473,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
// Tenant Restrictions (called from onAfterRoute)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check admin routes against restriction rules and redirect if blocked.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.01.08
|
||||
*/
|
||||
protected function enforceAdminRestrictions()
|
||||
{
|
||||
// Master user bypasses ALL restrictions
|
||||
if ($this->isMasterUser())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$input = $this->app->input;
|
||||
$option = $input->get('option', '');
|
||||
$view = $input->get('view', '');
|
||||
$task = $input->get('task', '');
|
||||
|
||||
// Disable install-from-URL for non-master users
|
||||
if ($this->params->get('disable_install_url', 1)
|
||||
&& $option === 'com_installer'
|
||||
&& stripos($task, 'install') !== false
|
||||
&& $input->get('installtype') === 'url')
|
||||
{
|
||||
$this->blockAccess('Install from URL is disabled.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$blocked = [];
|
||||
|
||||
if ($this->params->get('restrict_installer', 1))
|
||||
{
|
||||
// Allow the update view by default so tenants can update extensions
|
||||
$allowUpdates = (int) $this->params->get('allow_extension_updates', 1);
|
||||
|
||||
if ($allowUpdates && $option === 'com_installer'
|
||||
&& \in_array($view, ['update', 'updatesites'], true))
|
||||
{
|
||||
// Do not block — update views are permitted
|
||||
}
|
||||
elseif ($option === 'com_installer')
|
||||
{
|
||||
$this->blockAccess('Access restricted.');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->params->get('hide_sysinfo', 1))
|
||||
{
|
||||
$blocked[] = [
|
||||
'option' => 'com_admin',
|
||||
'view' => 'sysinfo',
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->params->get('restrict_global_config', 1))
|
||||
{
|
||||
$blocked[] = [
|
||||
'option' => 'com_config',
|
||||
'view' => 'application',
|
||||
];
|
||||
// Also block empty view (default landing = global config)
|
||||
if ($option === 'com_config' && $view === '')
|
||||
{
|
||||
$this->blockAccess('Access restricted.');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->params->get('restrict_template_editing', 1))
|
||||
{
|
||||
$blocked[] = [
|
||||
'option' => 'com_templates',
|
||||
'view' => 'template',
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($blocked as $rule)
|
||||
{
|
||||
if ($option !== $rule['option'])
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($rule['view']) && $view !== $rule['view'])
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->blockAccess('Access restricted.');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to admin dashboard with an error message.
|
||||
*
|
||||
* @param string $message Error message to display
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 02.01.08
|
||||
*/
|
||||
protected function blockAccess($message)
|
||||
{
|
||||
$this->app->enqueueMessage($message, 'error');
|
||||
$this->app->redirect(Route::_('index.php', false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the current user is the master WaaS user.
|
||||
*
|
||||
@@ -5162,38 +4524,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
|
||||
return $this->masterNames;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the list of components to hide from admin menu.
|
||||
*
|
||||
* Combines explicit hidden_menu_items config with components that
|
||||
* are implicitly blocked by other restriction toggles.
|
||||
*
|
||||
* @return array Component option strings
|
||||
*
|
||||
* @since 02.01.08
|
||||
*/
|
||||
protected function getHiddenMenuComponents()
|
||||
{
|
||||
$hidden = array_filter(array_map(
|
||||
'trim',
|
||||
explode("\n", $this->params->get('hidden_menu_items', ''))
|
||||
));
|
||||
|
||||
// Auto-hide components that are restricted (keep visible when updates are allowed)
|
||||
if ($this->params->get('restrict_installer', 1)
|
||||
&& !$this->params->get('allow_extension_updates', 1))
|
||||
{
|
||||
$hidden[] = 'com_installer';
|
||||
}
|
||||
|
||||
if ($this->params->get('hide_sysinfo', 1))
|
||||
{
|
||||
$hidden[] = 'com_admin';
|
||||
}
|
||||
|
||||
return array_unique($hidden);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Atum Template Branding (called from onAfterInitialise)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoWaaS
|
||||
* VERSION: 02.32.10
|
||||
* VERSION: 02.32.38
|
||||
* PATH: /src/Field/AllowedIpsField.php
|
||||
* BRIEF: Custom form field that displays the current IP whitelist
|
||||
*/
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoWaaS
|
||||
* VERSION: 02.32.10
|
||||
* VERSION: 02.32.38
|
||||
* PATH: /src/Field/CopyableTokenField.php
|
||||
* BRIEF: Read-only token field with a copy-to-clipboard button
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoWaaS
|
||||
* VERSION: 02.32.10
|
||||
* VERSION: 02.32.38
|
||||
* PATH: /src/Field/CurrentIpField.php
|
||||
* BRIEF: Read-only field that displays the current user's IP address
|
||||
*/
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoWaaS
|
||||
* VERSION: 02.32.10
|
||||
* VERSION: 02.32.38
|
||||
* PATH: /src/Field/DemoTaskInfoField.php
|
||||
* BRIEF: Read-only field showing scheduled task info with link to manage it
|
||||
*/
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoWaaS
|
||||
* VERSION: 02.32.10
|
||||
* VERSION: 02.32.38
|
||||
* PATH: /src/Field/NextResetField.php
|
||||
* BRIEF: Read-only field showing next reset time from Joomla scheduled task
|
||||
*/
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoWaaS
|
||||
* VERSION: 02.32.10
|
||||
* VERSION: 02.32.38
|
||||
* PATH: /src/Field/SnapshotTablesField.php
|
||||
* BRIEF: Multi-select list field that loads DB tables with sensible defaults
|
||||
*/
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* INGROUP: MokoWaaS
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
|
||||
* PATH: /src/packages/plg_system_mokowaas/Service/ContentSyncReceiver.php
|
||||
* VERSION: 02.32.10
|
||||
* VERSION: 02.32.38
|
||||
* BRIEF: Receiver-side content sync — applies incoming payload to local DB
|
||||
*/
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* INGROUP: MokoWaaS
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
|
||||
* PATH: /src/packages/plg_system_mokowaas/Service/ContentSyncService.php
|
||||
* VERSION: 02.32.10
|
||||
* VERSION: 02.32.38
|
||||
* BRIEF: Sender-side content sync — builds payload and pushes to remote sites
|
||||
*/
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* INGROUP: MokoWaaS
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
|
||||
* PATH: /src/packages/plg_system_mokowaas/Service/DemoResetService.php
|
||||
* VERSION: 02.32.10
|
||||
* VERSION: 02.32.38
|
||||
* BRIEF: Content-only snapshot/restore for demo site reset
|
||||
*/
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<license>GNU General Public License version 3 or later; see LICENSE.md</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.32.10</version>
|
||||
<version>02.32.38</version>
|
||||
<description>This plugin rebrands the Joomla system interface with MokoWaaS identity. It applies language overrides and ensures consistent branding across the platform.</description>
|
||||
<namespace path=".">Moko\Plugin\System\MokoWaaS</namespace>
|
||||
<scriptfile>script.php</scriptfile>
|
||||
@@ -76,7 +76,9 @@
|
||||
<fields name="params"
|
||||
addfieldprefix="Moko\Plugin\System\MokoWaaS\Field"
|
||||
>
|
||||
<fieldset name="basic">
|
||||
<fieldset name="basic"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIELDSET_CORE_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIELDSET_CORE_DESC">
|
||||
<field
|
||||
name="health_api_token"
|
||||
type="CopyableToken"
|
||||
@@ -86,87 +88,6 @@
|
||||
filter="raw"
|
||||
readonly="true"
|
||||
/>
|
||||
<field name="dev_mode" type="radio" default="0"
|
||||
label="PLG_SYSTEM_MOKOWAAS_DEV_MODE_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_DEV_MODE_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="reset_hits"
|
||||
type="radio"
|
||||
label="PLG_SYSTEM_MOKOWAAS_RESET_HITS_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_RESET_HITS_DESC"
|
||||
default="0"
|
||||
class="btn-group btn-group-yesno"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="delete_versions"
|
||||
type="radio"
|
||||
label="PLG_SYSTEM_MOKOWAAS_DELETE_VERSIONS_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_DELETE_VERSIONS_DESC"
|
||||
default="0"
|
||||
class="btn-group btn-group-yesno"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
<fieldset name="tenant_restrictions"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIELDSET_TENANT_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIELDSET_TENANT_DESC"
|
||||
>
|
||||
<field name="restrict_installer" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_RESTRICT_INSTALLER_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_RESTRICT_INSTALLER_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="allow_extension_updates" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_ALLOW_UPDATES_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_ALLOW_UPDATES_DESC"
|
||||
class="btn-group btn-group-yesno"
|
||||
showon="restrict_installer:1">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="hide_sysinfo" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_HIDE_SYSINFO_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_HIDE_SYSINFO_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="restrict_global_config" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_RESTRICT_CONFIG_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_RESTRICT_CONFIG_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="restrict_template_editing" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_RESTRICT_TEMPLATE_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_RESTRICT_TEMPLATE_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="disable_install_url" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_DISABLE_INSTALL_URL_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_DISABLE_INSTALL_URL_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="hidden_menu_items" type="textarea"
|
||||
label="PLG_SYSTEM_MOKOWAAS_HIDDEN_MENUS_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_HIDDEN_MENUS_DESC"
|
||||
rows="5" filter="raw" />
|
||||
</fieldset>
|
||||
<fieldset name="demo_mode"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIELDSET_DEMO_LABEL"
|
||||
@@ -198,62 +119,11 @@
|
||||
type="AllowedIps"
|
||||
label=""
|
||||
/>
|
||||
<field name="force_https" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FORCE_HTTPS_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FORCE_HTTPS_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="admin_session_timeout" type="number"
|
||||
label="PLG_SYSTEM_MOKOWAAS_SESSION_TIMEOUT_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_SESSION_TIMEOUT_DESC"
|
||||
default="60" hint="Minutes (0 = Joomla default)" />
|
||||
<field
|
||||
name="current_ip_display"
|
||||
type="CurrentIp"
|
||||
label=""
|
||||
/>
|
||||
<field
|
||||
name="trusted_ips"
|
||||
type="subform"
|
||||
label="PLG_SYSTEM_MOKOWAAS_TRUSTED_IPS_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_TRUSTED_IPS_DESC"
|
||||
formsource="plugins/system/mokowaas/forms/trusted_ip_entry.xml"
|
||||
multiple="true"
|
||||
layout="joomla.form.field.subform.repeatable-table"
|
||||
groupByFieldset="false"
|
||||
buttons="add,remove,move"
|
||||
/>
|
||||
<field name="password_min_length" type="number" default="12"
|
||||
label="PLG_SYSTEM_MOKOWAAS_PASSWORD_LENGTH_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_PASSWORD_LENGTH_DESC" />
|
||||
<field name="password_require_uppercase" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_PASSWORD_UPPER_LABEL"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="password_require_number" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_PASSWORD_NUMBER_LABEL"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="password_require_special" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_PASSWORD_SPECIAL_LABEL"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="upload_allowed_types" type="text"
|
||||
label="PLG_SYSTEM_MOKOWAAS_UPLOAD_TYPES_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_UPLOAD_TYPES_DESC"
|
||||
default="jpg,jpeg,png,gif,webp,svg,pdf,doc,docx,xls,xlsx" />
|
||||
<field name="upload_max_size_mb" type="number"
|
||||
label="PLG_SYSTEM_MOKOWAAS_UPLOAD_SIZE_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_UPLOAD_SIZE_DESC"
|
||||
default="100" />
|
||||
</fieldset>
|
||||
</fields>
|
||||
</config>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoWaaS
|
||||
* REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
* VERSION: 02.32.10
|
||||
* VERSION: 02.32.38
|
||||
* PATH: /src/script.php
|
||||
* BRIEF: Installation script for MokoWaaS plugin
|
||||
* NOTE: Handles installation, update, and uninstallation tasks including language override deployment
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoWaaS
|
||||
* REPO: https://github.com/mokoconsulting-tech/mokowaas
|
||||
* VERSION: 02.32.10
|
||||
* VERSION: 02.32.38
|
||||
* PATH: /src/services/provider.php
|
||||
* BRIEF: Service provider for dependency injection in Joomla 5.x
|
||||
* NOTE: Registers the plugin with Joomla's DI container
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.32.10</version>
|
||||
<version>02.32.38</version>
|
||||
<description>PLG_SYSTEM_MOKOWAAS_DEVTOOLS_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoWaaSDevTools</namespace>
|
||||
|
||||
|
||||
+46
-9
@@ -3,28 +3,65 @@
|
||||
; License: GPL-3.0-or-later
|
||||
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL="System - MokoWaaS Firewall"
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_DESC="HTTPS enforcement, trusted IPs, session timeout, upload restrictions, and password policy."
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_DESC="Web Application Firewall with security shields, IP management, request inspection, and access control."
|
||||
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_BASIC="Network & Session"
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_BASIC_DESC="HTTPS, session timeout, and trusted IP settings."
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_FORCE_HTTPS_LABEL="Force HTTPS"
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_FORCE_HTTPS_DESC="Redirect all HTTP requests to HTTPS. Recommended for production sites."
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_FORCE_HTTPS_DESC="Redirect all HTTP requests to HTTPS."
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_SESSION_TIMEOUT_LABEL="Admin Session Timeout (minutes)"
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_SESSION_TIMEOUT_DESC="Idle timeout in minutes for admin sessions. 0 = use Joomla default. Master users and trusted IPs are exempt."
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_SESSION_TIMEOUT_DESC="Idle timeout for admin sessions. 0 = Joomla default. Master users and trusted IPs exempt."
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_TRUSTED_IPS_LABEL="Trusted IPs"
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_TRUSTED_IPS_DESC="IP addresses or CIDR blocks that bypass session timeout. Supports exact IPs, CIDR (10.0.0.0/8), and wildcards (192.168.1.*)."
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_TRUSTED_IPS_DESC="IPs that bypass session timeout and WAF shields. Supports exact, CIDR, and wildcard."
|
||||
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_WAF="Web Application Firewall"
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_WAF_DESC="Threat detection shields that inspect incoming requests."
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_WAF_ENABLED_LABEL="Enable WAF"
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_WAF_ENABLED_DESC="Master toggle for all WAF shields."
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_WAF_SQLI_LABEL="SQLiShield"
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_WAF_SQLI_DESC="Block SQL injection patterns in GET, POST, and COOKIE data."
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_WAF_XSS_LABEL="XSSShield"
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_WAF_XSS_DESC="Block cross-site scripting patterns in GET and POST data."
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_WAF_MUA_LABEL="MUAShield"
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_WAF_MUA_DESC="Block known malicious user agents (scanners, bots, attack tools)."
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_WAF_MUA_LIST_LABEL="User Agent Blocklist"
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_WAF_MUA_LIST_DESC="Comma-separated user agent fragments to block."
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_WAF_RFI_LABEL="RFIShield"
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_WAF_RFI_DESC="Block remote file inclusion attempts (URLs in GET parameters)."
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_WAF_DFI_LABEL="DFIShield"
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_WAF_DFI_DESC="Block directory traversal and local file inclusion attempts."
|
||||
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_ACCESS="Access Control"
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_ACCESS_DESC="IP blocking, admin secret URL, and login restrictions."
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_IP_BLOCKLIST_LABEL="IP Deny List"
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_IP_BLOCKLIST_DESC="Block specific IPs or CIDR ranges. Checked before all other shields."
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_ADMIN_SECRET_LABEL="Admin Secret URL Parameter"
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_ADMIN_SECRET_DESC="Require ?secret=VALUE to access /administrator. Leave empty to disable."
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_ADMIN_SECRET_REDIRECT_LABEL="Secret Failure Redirect"
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_ADMIN_SECRET_REDIRECT_DESC="URL to redirect when admin secret is missing. Empty = 403 Forbidden."
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_BLOCK_FE_SU_LABEL="Forbid Frontend Super User Login"
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_BLOCK_FE_SU_DESC="Prevent Super User accounts from logging in on the frontend."
|
||||
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_PROTECTION="File & Template Protection"
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_PROTECTION_DESC="Block access to sensitive files and prevent template switching."
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_BLOCK_FILES_LABEL="Block Sensitive Files"
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_BLOCK_FILES_DESC="Block access to htaccess.txt, configuration.php-dist, and similar files."
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_BLOCK_PHP_LABEL="Block Direct PHP Access"
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_BLOCK_PHP_DESC="Block PHP execution in images/, media/, tmp/, cache/, logs/ directories."
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_BLOCK_TMPL_LABEL="Block Template Switching"
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_BLOCK_TMPL_DESC="Block tmpl= and template= URL parameters (tmpl=component allowed)."
|
||||
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_PASSWORD="Password Policy"
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_PASSWORD_DESC="Minimum password complexity requirements for all users."
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_PASSWORD_DESC="Minimum password complexity requirements."
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_PASSWORD_LENGTH_LABEL="Minimum Password Length"
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_PASSWORD_LENGTH_DESC="Minimum number of characters required."
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_PASSWORD_LENGTH_DESC="Minimum characters required."
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_PASSWORD_UPPER_LABEL="Require Uppercase"
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_PASSWORD_NUMBER_LABEL="Require Number"
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_PASSWORD_SPECIAL_LABEL="Require Special Character"
|
||||
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_UPLOADS="Upload Restrictions"
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_UPLOADS_DESC="Override Joomla's upload settings at runtime."
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_UPLOADS_DESC="Override Joomla upload settings at runtime."
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_UPLOAD_TYPES_LABEL="Allowed File Types"
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_UPLOAD_TYPES_DESC="Comma-separated list of permitted file extensions."
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_UPLOAD_TYPES_DESC="Comma-separated permitted file extensions."
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_UPLOAD_SIZE_LABEL="Max Upload Size (MB)"
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_UPLOAD_SIZE_DESC="Maximum file upload size in megabytes."
|
||||
PLG_SYSTEM_MOKOWAAS_FIREWALL_UPLOAD_SIZE_DESC="Maximum upload size in megabytes."
|
||||
|
||||
@@ -8,16 +8,24 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.32.10</version>
|
||||
<version>02.32.38</version>
|
||||
<description>PLG_SYSTEM_MOKOWAAS_FIREWALL_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoWaaSFirewall</namespace>
|
||||
|
||||
<files>
|
||||
<folder>src</folder>
|
||||
<folder>sql</folder>
|
||||
<folder>services</folder>
|
||||
<folder>language</folder>
|
||||
</files>
|
||||
|
||||
<install>
|
||||
<sql><file driver="mysql" charset="utf8">sql/install.mysql.sql</file></sql>
|
||||
</install>
|
||||
<uninstall>
|
||||
<sql><file driver="mysql" charset="utf8">sql/uninstall.mysql.sql</file></sql>
|
||||
</uninstall>
|
||||
|
||||
<languages folder="language">
|
||||
<language tag="en-GB">en-GB/plg_system_mokowaas_firewall.ini</language>
|
||||
<language tag="en-GB">en-GB/plg_system_mokowaas_firewall.sys.ini</language>
|
||||
@@ -25,6 +33,7 @@
|
||||
|
||||
<config>
|
||||
<fields name="params">
|
||||
<!-- Network & Session -->
|
||||
<fieldset name="basic"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_BASIC"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_BASIC_DESC">
|
||||
@@ -52,6 +61,137 @@
|
||||
buttons="add,remove,move" />
|
||||
</fieldset>
|
||||
|
||||
<!-- WAF Shields -->
|
||||
<fieldset name="waf"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_WAF"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_WAF_DESC">
|
||||
|
||||
<field name="waf_enabled" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_WAF_ENABLED_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_WAF_ENABLED_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
|
||||
<field name="waf_sqli" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_WAF_SQLI_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_WAF_SQLI_DESC"
|
||||
class="btn-group btn-group-yesno"
|
||||
showon="waf_enabled:1">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
|
||||
<field name="waf_xss" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_WAF_XSS_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_WAF_XSS_DESC"
|
||||
class="btn-group btn-group-yesno"
|
||||
showon="waf_enabled:1">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
|
||||
<field name="waf_mua" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_WAF_MUA_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_WAF_MUA_DESC"
|
||||
class="btn-group btn-group-yesno"
|
||||
showon="waf_enabled:1">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
|
||||
<field name="waf_mua_blocklist" type="textarea"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_WAF_MUA_LIST_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_WAF_MUA_LIST_DESC"
|
||||
rows="4" filter="raw"
|
||||
default="sqlmap,nikto,nmap,havij,w3af,acunetix,nessus,openvas,masscan,gobuster,dirbuster,wpscan,joomscan"
|
||||
showon="waf_enabled:1[AND]waf_mua:1" />
|
||||
|
||||
<field name="waf_rfi" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_WAF_RFI_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_WAF_RFI_DESC"
|
||||
class="btn-group btn-group-yesno"
|
||||
showon="waf_enabled:1">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
|
||||
<field name="waf_dfi" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_WAF_DFI_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_WAF_DFI_DESC"
|
||||
class="btn-group btn-group-yesno"
|
||||
showon="waf_enabled:1">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<!-- Access Control -->
|
||||
<fieldset name="access_control"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_ACCESS"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_ACCESS_DESC">
|
||||
|
||||
<field name="ip_blocklist" type="subform"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_IP_BLOCKLIST_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_IP_BLOCKLIST_DESC"
|
||||
formsource="plugins/system/mokowaas/forms/trusted_ip_entry.xml"
|
||||
multiple="true"
|
||||
layout="joomla.form.field.subform.repeatable-table"
|
||||
groupByFieldset="false"
|
||||
buttons="add,remove,move" />
|
||||
|
||||
<field name="admin_secret" type="text"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_ADMIN_SECRET_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_ADMIN_SECRET_DESC"
|
||||
default="" filter="raw" hint="Leave empty to disable" />
|
||||
|
||||
<field name="admin_secret_redirect" type="text"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_ADMIN_SECRET_REDIRECT_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_ADMIN_SECRET_REDIRECT_DESC"
|
||||
default="" filter="url" hint="Empty = 403 Forbidden"
|
||||
showon="admin_secret!:" />
|
||||
|
||||
<field name="block_frontend_superuser" type="radio" default="0"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_BLOCK_FE_SU_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_BLOCK_FE_SU_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<!-- File & Template Protection -->
|
||||
<fieldset name="protection"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_PROTECTION"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_PROTECTION_DESC">
|
||||
|
||||
<field name="block_sensitive_files" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_BLOCK_FILES_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_BLOCK_FILES_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
|
||||
<field name="block_direct_php" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_BLOCK_PHP_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_BLOCK_PHP_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
|
||||
<field name="block_template_switch" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_BLOCK_TMPL_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_BLOCK_TMPL_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<!-- Password Policy -->
|
||||
<fieldset name="password_policy"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_PASSWORD"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_PASSWORD_DESC">
|
||||
@@ -82,6 +222,7 @@
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<!-- Upload Restrictions -->
|
||||
<fieldset name="uploads"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_UPLOADS"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIREWALL_FIELDSET_UPLOADS_DESC">
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS `#__mokowaas_waf_log` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`ip` VARCHAR(45) NOT NULL,
|
||||
`uri` VARCHAR(2048) NOT NULL DEFAULT '',
|
||||
`rule` VARCHAR(50) NOT NULL,
|
||||
`detail` VARCHAR(512) NOT NULL DEFAULT '',
|
||||
`user_agent` VARCHAR(512) NOT NULL DEFAULT '',
|
||||
`created` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_ip` (`ip`),
|
||||
KEY `idx_rule` (`rule`),
|
||||
KEY `idx_created` (`created`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS `#__mokowaas_waf_log`;
|
||||
@@ -10,24 +10,57 @@ namespace Moko\Plugin\System\MokoWaaSFirewall\Extension;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Extension\BootableExtensionInterface;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
use Moko\Plugin\System\MokoWaaS\Helper\MokoWaaSHelper;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
/**
|
||||
* MokoWaaS Firewall Plugin
|
||||
*
|
||||
* Provides HTTPS enforcement, trusted IP management, admin session timeout,
|
||||
* upload restrictions, and password policy enforcement.
|
||||
* Web Application Firewall with security shields, IP management,
|
||||
* request inspection, and access control.
|
||||
*
|
||||
* @since 02.32.00
|
||||
*/
|
||||
class Firewall extends CMSPlugin implements SubscriberInterface
|
||||
class Firewall extends CMSPlugin implements SubscriberInterface, BootableExtensionInterface
|
||||
{
|
||||
protected $autoloadLanguage = true;
|
||||
|
||||
/**
|
||||
* Extend session lifetime for trusted IPs before Joomla creates the session.
|
||||
*/
|
||||
public function boot(ContainerInterface $container): void
|
||||
{
|
||||
$timeout = (int) $this->params->get('admin_session_timeout', 0);
|
||||
|
||||
if ($timeout <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->ipIsTrusted())
|
||||
{
|
||||
ini_set('session.gc_maxlifetime', 315360000);
|
||||
Factory::getConfig()->set('lifetime', 525600);
|
||||
}
|
||||
}
|
||||
|
||||
private const BLOCKED_FILES = [
|
||||
'htaccess.txt', 'web.config.txt', 'configuration.php-dist',
|
||||
'README.txt', 'LICENSE.txt', 'joomla.xml', 'robots.txt.dist',
|
||||
];
|
||||
|
||||
private const BLOCKED_PHP_DIRS = [
|
||||
'/images/', '/media/', '/tmp/', '/cache/', '/logs/',
|
||||
];
|
||||
|
||||
private const DEFAULT_MUA_BLOCKLIST = 'sqlmap,nikto,nmap,havij,w3af,acunetix,nessus,openvas,masscan,gobuster,dirbuster,wpscan,joomscan';
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
@@ -36,24 +69,447 @@ class Firewall extends CMSPlugin implements SubscriberInterface
|
||||
];
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Main entry point
|
||||
// ==================================================================
|
||||
|
||||
public function onAfterInitialise(): void
|
||||
{
|
||||
$app = $this->getApplication();
|
||||
|
||||
if ($app->isClient('cli'))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$bypass = MokoWaaSHelper::isMasterUser() || $this->ipIsTrusted();
|
||||
|
||||
// IP blocklist runs first — explicit deny even for trusted
|
||||
$this->checkIpBlocklist();
|
||||
|
||||
// Admin secret
|
||||
if ($app->isClient('administrator'))
|
||||
{
|
||||
$this->checkAdminSecret();
|
||||
}
|
||||
|
||||
// WAF shields — skip for trusted/master
|
||||
if (!$bypass && $this->params->get('waf_enabled', 1))
|
||||
{
|
||||
$this->checkSqlInjection();
|
||||
$this->checkXss();
|
||||
$this->checkMaliciousUserAgent();
|
||||
$this->checkRemoteFileInclusion();
|
||||
$this->checkDirectFileInclusion();
|
||||
}
|
||||
|
||||
// File/template protection — skip for trusted/master
|
||||
if (!$bypass)
|
||||
{
|
||||
$this->checkBlockedFiles();
|
||||
$this->checkTemplateSwitch();
|
||||
$this->checkDirectPhpAccess();
|
||||
}
|
||||
|
||||
// Existing features
|
||||
$this->enforceHttps();
|
||||
$this->enforceUploadRestrictions();
|
||||
|
||||
if ($this->getApplication()->isClient('administrator'))
|
||||
if ($app->isClient('administrator'))
|
||||
{
|
||||
$this->enforceAdminSessionTimeout();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce password complexity rules before user save.
|
||||
*/
|
||||
// ==================================================================
|
||||
// WAF Shields
|
||||
// ==================================================================
|
||||
|
||||
private function checkSqlInjection(): void
|
||||
{
|
||||
if (!$this->params->get('waf_sqli', 1))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$pattern = '#'
|
||||
. 'union\s+(all\s+)?select'
|
||||
. '|\bor\b\s+\d+=\d+'
|
||||
. '|\band\b\s+\d+=\d+'
|
||||
. "|\bor\b\s+['\"][^'\"]*['\"]\\s*=\\s*['\"]"
|
||||
. '|;\s*(drop|delete|insert|update|alter|create|truncate)\b'
|
||||
. '|/\*.*?\*/'
|
||||
. '|--\s'
|
||||
. '|\b(benchmark|sleep|load_file|outfile|dumpfile)\s*\('
|
||||
. '|0x[0-9a-f]{8,}'
|
||||
. '#i';
|
||||
|
||||
$match = $this->scanInput($_GET, $pattern)
|
||||
?? $this->scanInput($_POST, $pattern)
|
||||
?? $this->scanInput($_COOKIE, $pattern);
|
||||
|
||||
if ($match !== null)
|
||||
{
|
||||
$this->logAndBlock('sqli', $match);
|
||||
}
|
||||
}
|
||||
|
||||
private function checkXss(): void
|
||||
{
|
||||
if (!$this->params->get('waf_xss', 1))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$pattern = '#'
|
||||
. '<\s*script'
|
||||
. '|javascript\s*:'
|
||||
. '|vbscript\s*:'
|
||||
. '|\bon\w+\s*='
|
||||
. '|<\s*(iframe|object|embed|applet|form)\b'
|
||||
. '|document\s*\.\s*(cookie|domain)'
|
||||
. '|\beval\s*\('
|
||||
. '|expression\s*\('
|
||||
. '#i';
|
||||
|
||||
$match = $this->scanInput($_GET, $pattern)
|
||||
?? $this->scanInput($_POST, $pattern);
|
||||
|
||||
if ($match !== null)
|
||||
{
|
||||
$this->logAndBlock('xss', $match);
|
||||
}
|
||||
}
|
||||
|
||||
private function checkMaliciousUserAgent(): void
|
||||
{
|
||||
if (!$this->params->get('waf_mua', 1))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
||||
|
||||
if (empty($ua))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$blocklist = $this->params->get('waf_mua_blocklist', self::DEFAULT_MUA_BLOCKLIST);
|
||||
$agents = array_filter(array_map('trim', explode(',', $blocklist)));
|
||||
$uaLower = strtolower($ua);
|
||||
|
||||
foreach ($agents as $agent)
|
||||
{
|
||||
if (!empty($agent) && str_contains($uaLower, strtolower($agent)))
|
||||
{
|
||||
$this->logAndBlock('mua', $agent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function checkRemoteFileInclusion(): void
|
||||
{
|
||||
if (!$this->params->get('waf_rfi', 1))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$pattern = '#https?://|ftp://|php://|data://|expect://|%00#i';
|
||||
$match = $this->scanInput($_GET, $pattern);
|
||||
|
||||
if ($match !== null)
|
||||
{
|
||||
$this->logAndBlock('rfi', $match);
|
||||
}
|
||||
}
|
||||
|
||||
private function checkDirectFileInclusion(): void
|
||||
{
|
||||
if (!$this->params->get('waf_dfi', 1))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$pattern = '#\.\.[/\\\\]|/etc/(passwd|shadow|hosts)|[A-Z]:\\\\(windows|winnt)|php://(filter|input)#i';
|
||||
$match = $this->scanInput($_GET, $pattern);
|
||||
|
||||
if ($match !== null)
|
||||
{
|
||||
$this->logAndBlock('dfi', $match);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// File & Template Protection
|
||||
// ==================================================================
|
||||
|
||||
private function checkBlockedFiles(): void
|
||||
{
|
||||
if (!$this->params->get('block_sensitive_files', 1))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$path = strtolower(parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?? '');
|
||||
|
||||
foreach (self::BLOCKED_FILES as $file)
|
||||
{
|
||||
if (str_ends_with($path, '/' . strtolower($file)))
|
||||
{
|
||||
$this->logAndBlock('blocked_file', $file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function checkDirectPhpAccess(): void
|
||||
{
|
||||
if (!$this->params->get('block_direct_php', 1))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$path = strtolower(parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?? '');
|
||||
|
||||
if (!str_ends_with($path, '.php'))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (self::BLOCKED_PHP_DIRS as $dir)
|
||||
{
|
||||
if (str_contains($path, strtolower($dir)))
|
||||
{
|
||||
$this->logAndBlock('blocked_php', $path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function checkTemplateSwitch(): void
|
||||
{
|
||||
if (!$this->params->get('block_template_switch', 1))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$tmpl = $_GET['tmpl'] ?? '';
|
||||
$template = $_GET['template'] ?? '';
|
||||
|
||||
if (!empty($tmpl) && $tmpl !== 'component')
|
||||
{
|
||||
$this->logAndBlock('tmpl_switch', 'tmpl=' . $tmpl);
|
||||
}
|
||||
|
||||
if (!empty($template))
|
||||
{
|
||||
$this->logAndBlock('tmpl_switch', 'template=' . $template);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Access Control
|
||||
// ==================================================================
|
||||
|
||||
private function checkIpBlocklist(): void
|
||||
{
|
||||
$entries = $this->params->get('ip_blocklist', '');
|
||||
|
||||
if (empty($entries))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (\is_string($entries))
|
||||
{
|
||||
$entries = json_decode($entries, true);
|
||||
}
|
||||
|
||||
if (!\is_array($entries))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
|
||||
if ($this->ipMatchesList($ip, $entries))
|
||||
{
|
||||
$this->logAndBlock('ip_blocklist', $ip);
|
||||
}
|
||||
}
|
||||
|
||||
private function checkAdminSecret(): void
|
||||
{
|
||||
$secret = $this->params->get('admin_secret', '');
|
||||
|
||||
if (empty($secret))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (MokoWaaSHelper::isMasterUser() || $this->ipIsTrusted())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$provided = $_GET['secret'] ?? '';
|
||||
|
||||
if ($provided === $secret)
|
||||
{
|
||||
Factory::getSession()->set('mokowaas.admin_secret_ok', true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (Factory::getSession()->get('mokowaas.admin_secret_ok', false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$redirect = $this->params->get('admin_secret_redirect', '');
|
||||
|
||||
if (!empty($redirect))
|
||||
{
|
||||
$this->getApplication()->redirect($redirect);
|
||||
}
|
||||
else
|
||||
{
|
||||
$this->logAndBlock('admin_secret', 'missing or invalid');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Logging
|
||||
// ==================================================================
|
||||
|
||||
private function logAndBlock(string $rule, string $detail): void
|
||||
{
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
||||
$uri = $_SERVER['REQUEST_URI'] ?? '';
|
||||
|
||||
// Log to database (best-effort — don't let log failures prevent the block)
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$row = (object) [
|
||||
'ip' => substr($ip, 0, 45),
|
||||
'uri' => substr($uri, 0, 2048),
|
||||
'rule' => substr($rule, 0, 50),
|
||||
'detail' => substr($detail, 0, 512),
|
||||
'user_agent' => substr($ua, 0, 512),
|
||||
'created' => gmdate('Y-m-d H:i:s'),
|
||||
];
|
||||
$db->insertObject('#__mokowaas_waf_log', $row);
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
// Silent — blocking is more important than logging
|
||||
}
|
||||
|
||||
// Hard 403 — bypass Joomla's response stack to avoid boot-order issues
|
||||
http_response_code(403);
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
echo '<!DOCTYPE html><html><head><title>403 Forbidden</title></head>'
|
||||
. '<body><h1>403 Forbidden</h1><p>Your request has been blocked by the security firewall.</p></body></html>';
|
||||
exit;
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Input Scanning
|
||||
// ==================================================================
|
||||
|
||||
private function scanInput(array $input, string $pattern): ?string
|
||||
{
|
||||
foreach ($input as $key => $value)
|
||||
{
|
||||
if (\is_array($value))
|
||||
{
|
||||
$match = $this->scanInput($value, $pattern);
|
||||
|
||||
if ($match !== null)
|
||||
{
|
||||
return $match;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = (string) $value;
|
||||
$decoded = urldecode($value);
|
||||
|
||||
if (preg_match($pattern, $value) || preg_match($pattern, $decoded))
|
||||
{
|
||||
return substr($value, 0, 200);
|
||||
}
|
||||
|
||||
if (preg_match($pattern, (string) $key))
|
||||
{
|
||||
return substr((string) $key, 0, 200);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function ipMatchesList(string $ip, array $entries): bool
|
||||
{
|
||||
$ipLong = ip2long($ip);
|
||||
|
||||
if ($ipLong === false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($entries as $entry)
|
||||
{
|
||||
if (empty($entry['enabled']) || empty($entry['ip']))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$range = trim($entry['ip']);
|
||||
|
||||
if (str_contains($range, '*'))
|
||||
{
|
||||
$pattern = '/^' . str_replace(['.', '*'], ['\\.', '\\d+'], $range) . '$/';
|
||||
|
||||
if (preg_match($pattern, $ip))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_contains($range, '/'))
|
||||
{
|
||||
[$subnet, $bits] = explode('/', $range, 2);
|
||||
$subnetLong = ip2long($subnet);
|
||||
$mask = -1 << (32 - (int) $bits);
|
||||
|
||||
if ($subnetLong !== false && ($ipLong & $mask) === ($subnetLong & $mask))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($ip === $range)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Existing Features
|
||||
// ==================================================================
|
||||
|
||||
public function onUserBeforeSave($event): void
|
||||
{
|
||||
$oldUser = $event[0] ?? $event->getArgument(0, []);
|
||||
$isNew = $event[1] ?? $event->getArgument(1, false);
|
||||
$newUser = $event[2] ?? $event->getArgument(2, []);
|
||||
|
||||
if (empty($newUser['password_clear']))
|
||||
@@ -91,9 +547,6 @@ class Firewall extends CMSPlugin implements SubscriberInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect non-HTTPS requests to HTTPS.
|
||||
*/
|
||||
private function enforceHttps(): void
|
||||
{
|
||||
if (!$this->params->get('force_https', 0))
|
||||
@@ -117,9 +570,6 @@ class Firewall extends CMSPlugin implements SubscriberInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce admin session idle timeout.
|
||||
*/
|
||||
private function enforceAdminSessionTimeout(): void
|
||||
{
|
||||
$timeout = (int) $this->params->get('admin_session_timeout', 0);
|
||||
@@ -129,12 +579,7 @@ class Firewall extends CMSPlugin implements SubscriberInterface
|
||||
return;
|
||||
}
|
||||
|
||||
if (MokoWaaSHelper::isMasterUser())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->ipIsTrusted())
|
||||
if (MokoWaaSHelper::isMasterUser() || $this->ipIsTrusted())
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -154,9 +599,6 @@ class Firewall extends CMSPlugin implements SubscriberInterface
|
||||
$session->set('mokowaas.last_activity', $now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the current request IP matches any trusted IP entry.
|
||||
*/
|
||||
private function ipIsTrusted(): bool
|
||||
{
|
||||
$entries = $this->params->get('trusted_ips', '');
|
||||
@@ -176,64 +618,9 @@ class Firewall extends CMSPlugin implements SubscriberInterface
|
||||
return false;
|
||||
}
|
||||
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
$ipLong = ip2long($ip);
|
||||
|
||||
if ($ipLong === false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($entries as $entry)
|
||||
{
|
||||
if (empty($entry['enabled']) || empty($entry['ip']))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$range = trim($entry['ip']);
|
||||
|
||||
// Wildcard: 192.168.1.*
|
||||
if (str_contains($range, '*'))
|
||||
{
|
||||
$pattern = '/^' . str_replace(['.', '*'], ['\\.', '\\d+'], $range) . '$/';
|
||||
|
||||
if (preg_match($pattern, $ip))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// CIDR: 10.0.0.0/8
|
||||
if (str_contains($range, '/'))
|
||||
{
|
||||
[$subnet, $bits] = explode('/', $range, 2);
|
||||
$subnetLong = ip2long($subnet);
|
||||
$mask = -1 << (32 - (int) $bits);
|
||||
|
||||
if ($subnetLong !== false && ($ipLong & $mask) === ($subnetLong & $mask))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Exact match
|
||||
if ($ip === $range)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return $this->ipMatchesList($_SERVER['REMOTE_ADDR'] ?? '', $entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Override Joomla upload restrictions at runtime.
|
||||
*/
|
||||
private function enforceUploadRestrictions(): void
|
||||
{
|
||||
$types = $this->params->get('upload_allowed_types', '');
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.32.10</version>
|
||||
<version>02.32.38</version>
|
||||
<description>PLG_SYSTEM_MOKOWAAS_MONITOR_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoWaaSMonitor</namespace>
|
||||
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
; MokoWaaS Terms of Service Plugin
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GPL-3.0-or-later
|
||||
|
||||
PLG_SYSTEM_MOKOWAAS_OFFLINE="System - MokoWaaS Offline Bypass"
|
||||
PLG_SYSTEM_MOKOWAAS_OFFLINE_DESC="Keep selected pages (Terms of Service, Privacy Policy, etc.) accessible when the site is in offline mode."
|
||||
|
||||
PLG_SYSTEM_MOKOWAAS_OFFLINE_FIELDSET_BASIC="Offline-Accessible Pages"
|
||||
PLG_SYSTEM_MOKOWAAS_OFFLINE_SLUG_LABEL="Menu Items to Keep Online"
|
||||
PLG_SYSTEM_MOKOWAAS_OFFLINE_SLUG_DESC="Select menu items that remain accessible during offline mode. Hold Ctrl/Cmd for multiple."
|
||||
PLG_SYSTEM_MOKOWAAS_OFFLINE_CHILDREN_LABEL="Include Child Menu Items"
|
||||
PLG_SYSTEM_MOKOWAAS_OFFLINE_CHILDREN_DESC="Also allow access to child pages under the selected items."
|
||||
PLG_SYSTEM_MOKOWAAS_OFFLINE_SEF_WARNING="SEF URLs are disabled - path matching requires SEF. Itemid fallback is active."
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
; MokoWaaS Terms of Service Plugin - System strings
|
||||
PLG_SYSTEM_MOKOWAAS_OFFLINE="System - MokoWaaS Offline Bypass"
|
||||
PLG_SYSTEM_MOKOWAAS_OFFLINE_DESC="Keep selected pages (Terms of Service, Privacy Policy, etc.) accessible when the site is in offline mode."
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<extension type="plugin" group="system" method="upgrade">
|
||||
<name>System - MokoWaaS Offline Bypass</name>
|
||||
<element>mokowaas_offline</element>
|
||||
<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.32.38</version>
|
||||
<description>PLG_SYSTEM_MOKOWAAS_OFFLINE_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoWaaSOffline</namespace>
|
||||
|
||||
<files>
|
||||
<folder>src</folder>
|
||||
<folder>services</folder>
|
||||
<folder>language</folder>
|
||||
</files>
|
||||
|
||||
<languages folder="language">
|
||||
<language tag="en-GB">en-GB/plg_system_mokowaas_offline.ini</language>
|
||||
<language tag="en-GB">en-GB/plg_system_mokowaas_offline.sys.ini</language>
|
||||
</languages>
|
||||
|
||||
<config>
|
||||
<fields name="params" addfieldprefix="Moko\Plugin\System\MokoWaaSOffline\Field">
|
||||
<fieldset name="basic" label="PLG_SYSTEM_MOKOWAAS_OFFLINE_FIELDSET_BASIC">
|
||||
<field name="tos_slug" type="menuslug"
|
||||
label="PLG_SYSTEM_MOKOWAAS_OFFLINE_SLUG_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_OFFLINE_SLUG_DESC"
|
||||
multiple="true" />
|
||||
|
||||
<field name="include_children" type="radio" default="1"
|
||||
label="PLG_SYSTEM_MOKOWAAS_OFFLINE_CHILDREN_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_OFFLINE_CHILDREN_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
</fields>
|
||||
</config>
|
||||
</extension>
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage plg_system_mokowaas_offline
|
||||
* @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\PluginInterface;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Plugin\PluginHelper;
|
||||
use Joomla\DI\Container;
|
||||
use Joomla\DI\ServiceProviderInterface;
|
||||
use Joomla\Event\DispatcherInterface;
|
||||
use Moko\Plugin\System\MokoWaaSOffline\Extension\Tos;
|
||||
|
||||
return new class implements ServiceProviderInterface
|
||||
{
|
||||
public function register(Container $container): void
|
||||
{
|
||||
$container->set(
|
||||
PluginInterface::class,
|
||||
function (Container $container) {
|
||||
$dispatcher = $container->get(DispatcherInterface::class);
|
||||
$plugin = new Tos($dispatcher, (array) PluginHelper::getPlugin('system', 'mokowaas_offline'));
|
||||
$plugin->setApplication(Factory::getApplication());
|
||||
|
||||
return $plugin;
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage plg_system_mokowaas_offline
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Plugin\System\MokoWaaSOffline\Extension;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
|
||||
/**
|
||||
* MokoWaaS Terms of Service Plugin
|
||||
*
|
||||
* Allows configured menu items (Terms of Service, Privacy Policy, etc.)
|
||||
* to remain accessible when the site is in offline mode.
|
||||
*
|
||||
* @since 02.32.00
|
||||
*/
|
||||
final class Tos extends CMSPlugin implements SubscriberInterface
|
||||
{
|
||||
protected $autoloadLanguage = true;
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
'onAfterRoute' => 'onAfterRoute',
|
||||
];
|
||||
}
|
||||
|
||||
public function onAfterRoute(): void
|
||||
{
|
||||
$app = $this->getApplication();
|
||||
|
||||
if (!$app->isClient('site'))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$config = $app->getConfig();
|
||||
|
||||
if (!$config->get('offline'))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$slugs = $this->params->get('tos_slug', []);
|
||||
|
||||
if (\is_string($slugs))
|
||||
{
|
||||
$slugs = array_filter([trim($slugs)]);
|
||||
}
|
||||
else
|
||||
{
|
||||
$slugs = (array) $slugs;
|
||||
}
|
||||
|
||||
if (empty($slugs))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$includeChildren = (int) $this->params->get('include_children', 1);
|
||||
|
||||
if ($this->matchByPath($slugs, $config, $app, $includeChildren))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$this->matchByItemId($slugs, $config, $app, $includeChildren);
|
||||
}
|
||||
|
||||
private function matchByPath(array $slugs, $config, $app, int $includeChildren = 1): bool
|
||||
{
|
||||
$uri = Uri::getInstance();
|
||||
$path = urldecode(trim($uri->getPath(), '/'));
|
||||
|
||||
$base = trim(Uri::base(true), '/');
|
||||
|
||||
if (!empty($base) && strpos($path, $base) === 0)
|
||||
{
|
||||
$path = trim(substr($path, \strlen($base)), '/');
|
||||
}
|
||||
|
||||
if (empty($path) || $path === 'index.php')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($slugs as $slug)
|
||||
{
|
||||
$slug = trim((string) $slug);
|
||||
|
||||
if (empty($slug))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($path === $slug || ($includeChildren && strpos($path, $slug . '/') === 0))
|
||||
{
|
||||
$this->bypassOffline($config, $app);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function matchByItemId(array $slugs, $config, $app, int $includeChildren = 1): bool
|
||||
{
|
||||
$itemId = (int) $app->getInput()->getInt('Itemid', 0);
|
||||
|
||||
if (!$itemId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('path'))
|
||||
->from($db->quoteName('#__menu'))
|
||||
->where($db->quoteName('id') . ' = ' . $itemId)
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->where($db->quoteName('client_id') . ' = 0');
|
||||
$db->setQuery($query);
|
||||
$menuPath = trim((string) $db->loadResult(), '/');
|
||||
|
||||
if (empty($menuPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($slugs as $slug)
|
||||
{
|
||||
$slug = trim((string) $slug);
|
||||
|
||||
if (empty($slug))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($menuPath === $slug || ($includeChildren && strpos($menuPath, $slug . '/') === 0))
|
||||
{
|
||||
$this->bypassOffline($config, $app);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
// Silent
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function bypassOffline($config, $app): void
|
||||
{
|
||||
$config->set('offline', 0);
|
||||
$app->getInput()->set('tmpl', 'component');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage plg_system_mokowaas_offline
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Plugin\System\MokoWaaSOffline\Field;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Form\Field\ListField;
|
||||
use Joomla\CMS\Language\Text;
|
||||
|
||||
class MenuslugField extends ListField
|
||||
{
|
||||
protected $type = 'Menuslug';
|
||||
|
||||
protected function getOptions()
|
||||
{
|
||||
$options = parent::getOptions();
|
||||
|
||||
try
|
||||
{
|
||||
$sef = Factory::getApplication()->get('sef', true);
|
||||
|
||||
if (!$sef)
|
||||
{
|
||||
$options[] = (object) [
|
||||
'value' => '',
|
||||
'text' => Text::_('PLG_SYSTEM_MOKOWAAS_OFFLINE_SEF_WARNING'),
|
||||
'disabled' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
// Ignore
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName(['path', 'alias', 'title', 'menutype']))
|
||||
->from($db->quoteName('#__menu'))
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->where($db->quoteName('client_id') . ' = 0')
|
||||
->where($db->quoteName('alias') . ' != ' . $db->quote(''))
|
||||
->order($db->quoteName('menutype') . ', ' . $db->quoteName('title'));
|
||||
$db->setQuery($query);
|
||||
$menuItems = $db->loadObjectList();
|
||||
|
||||
$lastMenuType = '';
|
||||
|
||||
foreach ($menuItems ?: [] as $item)
|
||||
{
|
||||
if ($item->menutype !== $lastMenuType)
|
||||
{
|
||||
if ($lastMenuType !== '')
|
||||
{
|
||||
$options[] = (object) ['value' => '', 'text' => '──────────────', 'disabled' => true];
|
||||
}
|
||||
|
||||
$lastMenuType = $item->menutype;
|
||||
}
|
||||
|
||||
$label = $item->title !== '' ? $item->title : ucwords(str_replace(['-', '_'], ' ', $item->alias));
|
||||
$options[] = (object) ['value' => $item->path, 'text' => $label . ' (/' . $item->path . ')'];
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
// Silent
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.32.10</version>
|
||||
<version>02.32.38</version>
|
||||
<description>PLG_SYSTEM_MOKOWAAS_TENANT_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\System\MokoWaaSTenant</namespace>
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
PLG_TASK_MOKOWAAS_TICKETS="Task - MokoWaaS Ticket Automation"
|
||||
PLG_TASK_MOKOWAAS_TICKETS_DESC="Runs scheduled helpdesk automation rules."
|
||||
PLG_TASK_MOKOWAAS_TICKETS_AUTOMATION_TITLE="MokoWaaS: Ticket Automation"
|
||||
PLG_TASK_MOKOWAAS_TICKETS_AUTOMATION_DESC="Runs time-based automation rules against open tickets (auto-close, SLA escalation, etc.)."
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
PLG_TASK_MOKOWAAS_TICKETS="Task - MokoWaaS Ticket Automation"
|
||||
PLG_TASK_MOKOWAAS_TICKETS_DESC="Runs scheduled helpdesk automation rules — auto-close, SLA escalation, and time-based actions."
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<extension type="plugin" group="task" method="upgrade">
|
||||
<name>Task - MokoWaaS Ticket Automation</name>
|
||||
<element>mokowaas_tickets</element>
|
||||
<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.32.38</version>
|
||||
<description>Runs scheduled helpdesk automation rules — auto-close resolved tickets, SLA breach escalation, and time-based actions.</description>
|
||||
<namespace path="src">Moko\Plugin\Task\MokoWaaSTickets</namespace>
|
||||
|
||||
<files>
|
||||
<folder>src</folder>
|
||||
<folder>services</folder>
|
||||
<folder>language</folder>
|
||||
</files>
|
||||
|
||||
<languages folder="language">
|
||||
<language tag="en-GB">en-GB/plg_task_mokowaas_tickets.ini</language>
|
||||
<language tag="en-GB">en-GB/plg_task_mokowaas_tickets.sys.ini</language>
|
||||
</languages>
|
||||
</extension>
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Extension\PluginInterface;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Plugin\PluginHelper;
|
||||
use Joomla\DI\Container;
|
||||
use Joomla\DI\ServiceProviderInterface;
|
||||
use Joomla\Event\DispatcherInterface;
|
||||
use Moko\Plugin\Task\MokoWaaSTickets\Extension\TicketAutomation;
|
||||
|
||||
return new class implements ServiceProviderInterface
|
||||
{
|
||||
public function register(Container $container): void
|
||||
{
|
||||
$container->set(
|
||||
PluginInterface::class,
|
||||
function (Container $container) {
|
||||
$dispatcher = $container->get(DispatcherInterface::class);
|
||||
$plugin = new TicketAutomation($dispatcher, (array) PluginHelper::getPlugin('task', 'mokowaas_tickets'));
|
||||
$plugin->setApplication(Factory::getApplication());
|
||||
|
||||
return $plugin;
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage plg_task_mokowaas_tickets
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Plugin\Task\MokoWaaSTickets\Extension;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent;
|
||||
use Joomla\Component\Scheduler\Administrator\Task\Status;
|
||||
use Joomla\Component\Scheduler\Administrator\Traits\TaskPluginTrait;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
use Moko\Component\MokoWaaS\Administrator\Model\TicketsModel;
|
||||
|
||||
class TicketAutomation extends CMSPlugin implements SubscriberInterface
|
||||
{
|
||||
use TaskPluginTrait;
|
||||
|
||||
protected const TASKS_MAP = [
|
||||
'mokowaas.ticket.automation' => [
|
||||
'langConstPrefix' => 'PLG_TASK_MOKOWAAS_TICKETS_AUTOMATION',
|
||||
'method' => 'runAutomation',
|
||||
],
|
||||
];
|
||||
|
||||
protected $autoloadLanguage = true;
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
'onTaskOptionsList' => 'advertiseRoutines',
|
||||
'onExecuteTask' => 'standardRoutineHandler',
|
||||
'onContentPrepareForm' => 'enhanceTaskItemForm',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all scheduled automation rules against open tickets.
|
||||
*/
|
||||
private function runAutomation(ExecuteTaskEvent $event): int
|
||||
{
|
||||
try
|
||||
{
|
||||
$model = new TicketsModel();
|
||||
$results = $model->runScheduledAutomation();
|
||||
|
||||
$this->logTask(
|
||||
\sprintf('Ticket automation: evaluated %d tickets, acted on %d', $results['evaluated'], $results['acted'])
|
||||
);
|
||||
|
||||
return Status::OK;
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
$this->logTask('Ticket automation failed: ' . $e->getMessage(), 'error');
|
||||
|
||||
return Status::KNOCKOUT;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@
|
||||
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.32.10</version>
|
||||
<version>02.32.10</version>
|
||||
<version>02.32.38</version>
|
||||
<version>02.32.38</version>
|
||||
<description>PLG_TASK_MOKOWAASDEMO_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\Task\MokoWaaSDemo</namespace>
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.32.10</version>
|
||||
<version>02.32.38</version>
|
||||
<description>PLG_TASK_MOKOWAASSYNC_DESC</description>
|
||||
<namespace path="src">Moko\Plugin\Task\MokoWaaSSync</namespace>
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.32.10</version>
|
||||
<version>02.32.10</version>
|
||||
<version>02.32.38</version>
|
||||
<version>02.32.38</version>
|
||||
<description>Joomla Web Services API routes for MokoWaaS site management — health checks, cache, updates, backups, and site info.</description>
|
||||
<namespace path="src">Moko\Plugin\WebServices\MokoWaaS</namespace>
|
||||
<files>
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<version>02.32.10</version>
|
||||
<version>02.32.10</version>
|
||||
<version>02.32.38</version>
|
||||
<version>02.32.38</version>
|
||||
<description>Joomla Web Services API routes for Perfect Publisher (com_autotweet) — channels, posts, requests, rules, and feeds.</description>
|
||||
<namespace path="src">Moko\Plugin\WebServices\PerfectPublisher</namespace>
|
||||
<files>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* INGROUP: MokoWaaS
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
|
||||
* PATH: /src/packages/plg_webservices_perfectpublisher/services/provider.php
|
||||
* VERSION: 02.32.10
|
||||
* VERSION: 02.32.38
|
||||
* BRIEF: DI service provider for Perfect Publisher Web Services plugin
|
||||
*/
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* INGROUP: MokoWaaS
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
|
||||
* PATH: /src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php
|
||||
* VERSION: 02.32.10
|
||||
* VERSION: 02.32.38
|
||||
* BRIEF: Web Services API plugin for Perfect Publisher (com_autotweet)
|
||||
*/
|
||||
|
||||
|
||||
Submodule
+1
Submodule src/packages/tpl_mokoonyx added at f3897495ad
@@ -2,7 +2,7 @@
|
||||
<extension type="package" method="upgrade">
|
||||
<name>Package - MokoWaaS</name>
|
||||
<packagename>mokowaas</packagename>
|
||||
<version>02.32.10</version>
|
||||
<version>02.32.38</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
@@ -17,13 +17,15 @@
|
||||
<file type="plugin" id="plg_system_mokowaas_firewall" group="system">plg_system_mokowaas_firewall.zip</file>
|
||||
<file type="plugin" id="plg_system_mokowaas_tenant" group="system">plg_system_mokowaas_tenant.zip</file>
|
||||
<file type="plugin" id="plg_system_mokowaas_devtools" group="system">plg_system_mokowaas_devtools.zip</file>
|
||||
<file type="plugin" id="plg_system_mokowaas_monitor" group="system">plg_system_mokowaas_monitor.zip</file>
|
||||
<file type="plugin" id="plg_system_mokowaas_offline" group="system">plg_system_mokowaas_offline.zip</file>
|
||||
<file type="component" id="com_mokowaas">com_mokowaas.zip</file>
|
||||
<file type="module" id="mod_mokowaas_cpanel" client="administrator">mod_mokowaas_cpanel.zip</file>
|
||||
<file type="plugin" id="plg_webservices_mokowaas" group="webservices">plg_webservices_mokowaas.zip</file>
|
||||
<file type="plugin" id="plg_webservices_perfectpublisher" group="webservices">plg_webservices_perfectpublisher.zip</file>
|
||||
<file type="plugin" id="plg_task_mokowaasdemo" group="task">plg_task_mokowaasdemo.zip</file>
|
||||
<file type="plugin" id="plg_task_mokowaassync" group="task">plg_task_mokowaassync.zip</file>
|
||||
<file type="plugin" id="plg_task_mokowaas_tickets" group="task">plg_task_mokowaas_tickets.zip</file>
|
||||
<file type="template" id="mokoonyx" client="site">tpl_mokoonyx.zip</file>
|
||||
</files>
|
||||
|
||||
<updateservers>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user