Compare commits

..

20 Commits

Author SHA1 Message Date
jmiller 6cd16d9845 chore: sync security-audit.yml from Template-Joomla [skip ci] 2026-06-07 17:55:05 +00:00
jmiller 3d522134c2 chore: sync repo-health.yml from Template-Joomla [skip ci] 2026-06-07 17:55:04 +00:00
jmiller 8db306a4a7 chore: sync pre-release.yml from Template-Joomla [skip ci] 2026-06-07 17:55:04 +00:00
jmiller 6861643e6c chore: sync pr-check.yml from Template-Joomla [skip ci] 2026-06-07 17:55:03 +00:00
jmiller 4b1280213f chore: sync notify.yml from Template-Joomla [skip ci] 2026-06-07 17:55:02 +00:00
jmiller 9172baefb6 chore: sync issue-branch.yml from Template-Joomla [skip ci] 2026-06-07 17:55:01 +00:00
jmiller 5b5b81eb0f chore: sync gitleaks.yml from Template-Joomla [skip ci] 2026-06-07 17:55:01 +00:00
jmiller 82db443607 chore: sync deploy-manual.yml from Template-Joomla [skip ci] 2026-06-07 17:55:00 +00:00
jmiller 67f4da183c chore: sync cleanup.yml from Template-Joomla [skip ci] 2026-06-07 17:54:59 +00:00
jmiller bf05f4baa8 chore: sync ci-joomla.yml from Template-Joomla [skip ci] 2026-06-07 17:54:59 +00:00
jmiller 5303924f58 chore: sync ci-generic.yml from Template-Joomla [skip ci] 2026-06-07 17:54:58 +00:00
jmiller 527c8b82cf chore: sync branch-cleanup.yml from Template-Joomla [skip ci] 2026-06-07 17:54:57 +00:00
jmiller fb7c182b16 chore: sync auto-bump.yml from Template-Joomla [skip ci] 2026-06-07 17:54:56 +00:00
Jonathan Miller 70419c4018 Merge remote-tracking branch 'origin/dev'
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
2026-06-07 12:40:46 -05:00
Jonathan Miller 624b81b2d2 Merge remote-tracking branch 'origin/dev'
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
# Conflicts:
#	source/packages/plg_system_mokowaas_monitor/mokowaas_monitor.xml
#	source/packages/plg_webservices_perfectpublisher/perfectpublisher.xml
#	source/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php
#	source/pkg_mokowaas.xml
2026-06-07 11:26:46 -05:00
Jonathan Miller 872889487e Merge remote-tracking branch 'origin/dev'
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
# Conflicts:
#	.mokogitea/manifest.xml
#	.mokogitea/workflows/issue-branch.yml
#	CHANGELOG.md
#	CODE_OF_CONDUCT.md
#	GOVERNANCE.md
#	LICENSE.md
#	README.md
#	SECURITY.md
#	docs/guides/build-guide.md
#	docs/guides/configuration-guide.md
#	docs/guides/installation-guide.md
#	docs/guides/operations-guide.md
#	docs/guides/rollback-and-recovery-guide.md
#	docs/guides/testing-guide.md
#	docs/guides/troubleshooting-guide.md
#	docs/guides/upgrade-and-versioning-guide.md
#	docs/index.md
#	docs/plugin-basic.md
#	docs/update-server.md
#	source/packages/mod_mokowaas_cpanel/mod_mokowaas_cpanel.xml
#	source/packages/plg_system_mokowaas/Field/CopyableTokenField.php
#	source/packages/plg_system_mokowaas/script.php
#	source/packages/plg_system_mokowaas/services/provider.php
#	source/packages/plg_system_mokowaas_devtools/mokowaas_devtools.xml
#	source/packages/plg_system_mokowaas_firewall/mokowaas_firewall.xml
#	source/packages/plg_system_mokowaas_monitor/mokowaas_monitor.xml
#	source/packages/plg_system_mokowaas_tenant/mokowaas_tenant.xml
#	source/packages/plg_task_mokowaasdemo/mokowaasdemo.xml
#	source/packages/plg_task_mokowaasdemo/src/Service/DemoResetService.php
#	source/packages/plg_task_mokowaassync/mokowaassync.xml
#	source/packages/plg_task_mokowaassync/src/Service/ContentSyncReceiver.php
#	source/packages/plg_task_mokowaassync/src/Service/ContentSyncService.php
#	source/packages/plg_webservices_mokowaas/mokowaas.xml
#	source/packages/plg_webservices_perfectpublisher/perfectpublisher.xml
#	source/packages/plg_webservices_perfectpublisher/services/provider.php
#	source/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php
#	source/pkg_mokowaas.xml
#	src/packages/com_mokowaas/mokowaas.xml
#	src/packages/plg_system_mokowaas/Extension/MokoWaaS.php
#	src/packages/plg_system_mokowaas/Field/AllowedIpsField.php
#	src/packages/plg_system_mokowaas/Field/CurrentIpField.php
#	src/packages/plg_system_mokowaas/Field/DemoTaskInfoField.php
#	src/packages/plg_system_mokowaas/Field/NextResetField.php
#	src/packages/plg_system_mokowaas/Field/SnapshotTablesField.php
#	src/packages/plg_system_mokowaas/mokowaas.xml
2026-06-06 11:47:10 -05:00
jmiller d12971c0b7 chore: remove update-server workflow [skip ci] 2026-06-05 00:07:22 +00:00
jmiller 21156deb0e chore: remove updates.xml from main [skip ci] 2026-06-04 23:23:22 +00:00
gitea-actions[bot] 1547bd5861 chore(release): build 02.34.00 [skip ci] 2026-06-04 23:14:10 +00:00
jmiller f66871db2e chore: add dlid and blockChildUninstall to package manifest [skip ci] 2026-06-04 22:02:42 +00:00
95 changed files with 9112 additions and 5555 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
<display-name>Package - MokoSuite</display-name> <display-name>Package - MokoSuite</display-name>
<org>MokoConsulting</org> <org>MokoConsulting</org>
<description>White-label identity, security hardening, and tenant restriction layer for Suite-managed Joomla environments</description> <description>White-label identity, security hardening, and tenant restriction layer for Suite-managed Joomla environments</description>
<version>02.34.77</version> <version>02.34.50</version>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license> <license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity> </identity>
<governance> <governance>
+10 -7
View File
@@ -7,7 +7,7 @@
# INGROUP: moko-platform.Release # INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.mokogitea/workflows/auto-bump.yml # PATH: /.mokogitea/workflows/auto-bump.yml
# VERSION: 09.23.00 # VERSION: 09.02.00
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits) # BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
name: "Universal: Auto Version Bump" name: "Universal: Auto Version Bump"
@@ -48,12 +48,15 @@ jobs:
if ! command -v composer &> /dev/null; then 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 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 fi
rm -rf /tmp/moko-platform-api if [ -d "/opt/moko-platform/cli" ]; then
git clone --depth 1 --branch main --quiet \ echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV"
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \ else
/tmp/moko-platform-api git clone --depth 1 --branch main --quiet \
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV" /tmp/moko-platform-api
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
fi
- name: Bump version - name: Bump version
run: | run: |
+2 -2
View File
@@ -4,10 +4,10 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: MokoPlatform.Universal # INGROUP: MokoStandards.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.mokogitea/workflows/branch-cleanup.yml # PATH: /.mokogitea/workflows/branch-cleanup.yml
# VERSION: 09.23.00 # VERSION: 01.00.00
# BRIEF: Delete feature branches after PR merge # BRIEF: Delete feature branches after PR merge
name: "Branch Cleanup" name: "Branch Cleanup"
+204
View File
@@ -0,0 +1,204 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.CI
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
# PATH: /.gitea/workflows/ci-generic.yml
# VERSION: 01.00.00
# BRIEF: CI pipeline — lint, validate, and test for generic projects (PHP + Node.js)
name: "Generic: Project CI"
on:
push:
branches:
- main
- dev
- dev/**
- rc/**
- version/**
pull_request:
branches:
- main
- dev
- dev/**
- rc/**
workflow_dispatch:
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ── Lint & Validate ───────────────────────────────────────────────────
lint:
name: Lint & Validate
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Detect toolchain
id: detect
run: |
HAS_PHP=false
HAS_NODE=false
[ -f "composer.json" ] && HAS_PHP=true
[ -f "package.json" ] && HAS_NODE=true
echo "has_php=$HAS_PHP" >> "$GITHUB_OUTPUT"
echo "has_node=$HAS_NODE" >> "$GITHUB_OUTPUT"
echo "Toolchain: PHP=$HAS_PHP Node=$HAS_NODE"
- name: Setup PHP
if: steps.detect.outputs.has_php == 'true'
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
php -v
- name: Setup Node.js
if: steps.detect.outputs.has_node == 'true'
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install PHP dependencies
if: steps.detect.outputs.has_php == 'true'
run: |
if [ -f "composer.json" ]; then
composer install --no-interaction --prefer-dist --quiet 2>/dev/null || true
fi
- name: Install Node.js dependencies
if: steps.detect.outputs.has_node == 'true'
run: |
if [ -f "package.json" ]; then
npm ci --quiet 2>/dev/null || npm install --quiet 2>/dev/null || true
fi
- name: PHP syntax check
if: steps.detect.outputs.has_php == 'true'
run: |
ERRORS=0
while IFS= read -r -d '' file; do
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
echo "::error file=${file}::PHP syntax error"
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -not -path "./node_modules/*" -print0)
echo "## PHP Lint" >> $GITHUB_STEP_SUMMARY
if [ "$ERRORS" -eq 0 ]; then
echo "All PHP files passed syntax check." >> $GITHUB_STEP_SUMMARY
else
echo "${ERRORS} file(s) with syntax errors." >> $GITHUB_STEP_SUMMARY
exit 1
fi
- name: TypeScript/JavaScript lint
if: steps.detect.outputs.has_node == 'true'
run: |
if [ -f "node_modules/.bin/eslint" ]; then
npx eslint src/ --quiet 2>&1 || { echo "::error::ESLint errors found"; exit 1; }
echo "## ESLint" >> $GITHUB_STEP_SUMMARY
echo "All files passed ESLint." >> $GITHUB_STEP_SUMMARY
elif [ -f ".eslintrc.json" ] || [ -f ".eslintrc.js" ] || [ -f "eslint.config.js" ]; then
echo "::warning::ESLint config found but eslint not installed"
else
echo "No ESLint configured — skipping"
fi
- name: TypeScript compile check
if: steps.detect.outputs.has_node == 'true'
run: |
if [ -f "tsconfig.json" ] && [ -f "node_modules/.bin/tsc" ]; then
npx tsc --noEmit 2>&1 || { echo "::error::TypeScript compilation errors"; exit 1; }
echo "## TypeScript" >> $GITHUB_STEP_SUMMARY
echo "TypeScript compilation passed." >> $GITHUB_STEP_SUMMARY
fi
- name: PHPStan static analysis
if: steps.detect.outputs.has_php == 'true'
run: |
if [ -f "phpstan.neon" ] && [ -f "vendor/bin/phpstan" ]; then
vendor/bin/phpstan analyse --no-progress 2>&1 || { echo "::warning::PHPStan found issues"; }
fi
# ── Tests ─────────────────────────────────────────────────────────────
test:
name: Tests
runs-on: ubuntu-latest
needs: lint
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Detect toolchain
id: detect
run: |
HAS_PHP=false
HAS_NODE=false
[ -f "composer.json" ] && HAS_PHP=true
[ -f "package.json" ] && HAS_NODE=true
echo "has_php=$HAS_PHP" >> "$GITHUB_OUTPUT"
echo "has_node=$HAS_NODE" >> "$GITHUB_OUTPUT"
- name: Setup PHP
if: steps.detect.outputs.has_php == 'true'
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: Setup Node.js
if: steps.detect.outputs.has_node == 'true'
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: |
[ -f "composer.json" ] && composer install --no-interaction --prefer-dist --quiet 2>/dev/null || true
[ -f "package.json" ] && { npm ci --quiet 2>/dev/null || npm install --quiet 2>/dev/null || true; }
- name: Run PHP tests
if: steps.detect.outputs.has_php == 'true'
run: |
if [ -f "vendor/bin/phpunit" ]; then
vendor/bin/phpunit --testdox 2>&1
echo "## PHPUnit" >> $GITHUB_STEP_SUMMARY
echo "Tests passed." >> $GITHUB_STEP_SUMMARY
elif [ -f "phpunit.xml" ] || [ -f "phpunit.xml.dist" ]; then
echo "::warning::PHPUnit config found but phpunit not installed"
else
echo "No PHPUnit configured — skipping"
fi
- name: Run Node.js tests
if: steps.detect.outputs.has_node == 'true'
run: |
if jq -e '.scripts.test' package.json > /dev/null 2>&1; then
npm test 2>&1
echo "## Node.js Tests" >> $GITHUB_STEP_SUMMARY
echo "Tests passed." >> $GITHUB_STEP_SUMMARY
else
echo "No test script in package.json — skipping"
fi
- name: Build check
run: |
if [ -f "Makefile" ]; then
make build 2>&1 || echo "::warning::Build failed or not configured"
elif [ -f "package.json" ] && jq -e '.scripts.build' package.json > /dev/null 2>&1; then
npm run build 2>&1 || echo "::warning::Build failed"
fi
+500
View File
@@ -0,0 +1,500 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# This file is part of a Moko Consulting project.
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow.Template
# INGROUP: MokoStandards.CI
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/joomla/ci-joomla.yml.template
# VERSION: 04.06.00
# BRIEF: CI workflow for Joomla extensions — lint, validate, test
name: "Joomla: Extension CI"
on:
pull_request:
branches:
- main
- 'dev/**'
workflow_dispatch:
permissions:
contents: read
pull-requests: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
lint-and-validate:
name: Lint & Validate
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup PHP
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 php-zip php-curl composer >/dev/null 2>&1
fi
php -v && composer --version
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || github.token }}
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
run: |
if [ -d "/tmp/moko-platform" ] || [ -d "/opt/moko-platform" ]; then
echo "moko-platform already available on runner — skipping clone"
else
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
/tmp/moko-platform 2>/dev/null || echo "moko-platform clone skipped — continuing without it"
fi
- name: Install dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.GA_TOKEN || github.token }}"}}'
run: |
if [ -f "composer.json" ]; then
composer install \
--no-interaction \
--prefer-dist \
--optimize-autoloader
else
echo "No composer.json found — skipping dependency install"
fi
- name: PHP syntax check
run: |
ERRORS=0
for DIR in src/ htdocs/; do
if [ -d "$DIR" ]; then
FOUND=1
while IFS= read -r -d '' FILE; do
OUTPUT=$(php -l "$FILE" 2>&1)
if echo "$OUTPUT" | grep -q "Parse error"; then
echo "::error file=${FILE}::${OUTPUT}"
ERRORS=$((ERRORS + 1))
fi
done < <(find "$DIR" -name "*.php" -print0)
fi
done
echo "### PHP Syntax Check" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} syntax error(s) found.**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "All PHP files passed syntax check." >> $GITHUB_STEP_SUMMARY
fi
- name: XML manifest validation
run: |
echo "### XML Manifest Validation" >> $GITHUB_STEP_SUMMARY
ERRORS=0
# Find the extension manifest (XML with <extension tag)
MANIFEST=""
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
MANIFEST="$XML_FILE"
break
fi
done
if [ -z "$MANIFEST" ]; then
echo "No Joomla extension manifest found (XML file with \`<extension\` tag)." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "Manifest found: \`${MANIFEST}\`" >> $GITHUB_STEP_SUMMARY
# Validate well-formed XML
php -r "
\$xml = @simplexml_load_file('$MANIFEST');
if (\$xml === false) {
echo 'INVALID';
exit(1);
}
echo 'VALID';
" > /tmp/xml_result 2>&1
XML_RESULT=$(cat /tmp/xml_result)
if [ "$XML_RESULT" != "VALID" ]; then
echo "Manifest is not well-formed XML." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "Manifest is well-formed XML." >> $GITHUB_STEP_SUMMARY
fi
# Check required tags: name, version, author
for TAG in name version author; do
if ! grep -q "<${TAG}>" "$MANIFEST" 2>/dev/null; then
echo "Missing required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "Found required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY
fi
done
# Namespace is required for components/plugins but not packages
EXT_TYPE=$(grep -oP '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
if [ "$EXT_TYPE" != "package" ]; then
if ! grep -q "<namespace" "$MANIFEST" 2>/dev/null; then
echo "Missing required tag: \`<namespace>\` (required for Joomla 5+ ${EXT_TYPE} extensions)" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "Found required tag: \`<namespace>\`" >> $GITHUB_STEP_SUMMARY
fi
else
echo "Package extension — \`<namespace>\` not required." >> $GITHUB_STEP_SUMMARY
fi
fi
if [ "${ERRORS}" -gt 0 ]; then
echo "" >> $GITHUB_STEP_SUMMARY
echo "**${ERRORS} manifest issue(s) found.**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Manifest validation passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Check language files referenced in manifest
run: |
echo "### Language File Check" >> $GITHUB_STEP_SUMMARY
ERRORS=0
MANIFEST=""
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
MANIFEST="$XML_FILE"
break
fi
done
if [ -n "$MANIFEST" ]; then
# Extract language file references from manifest
LANG_FILES=$(grep -oP 'language\s+tag="[^"]*"[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
if [ -z "$LANG_FILES" ]; then
echo "No language file references found in manifest — skipping." >> $GITHUB_STEP_SUMMARY
else
while IFS= read -r LANG_FILE; do
LANG_FILE=$(echo "$LANG_FILE" | xargs)
if [ -z "$LANG_FILE" ]; then
continue
fi
# Check in common locations
FOUND=0
for BASE in "." "src" "htdocs"; do
if [ -f "${BASE}/${LANG_FILE}" ]; then
FOUND=1
break
fi
done
if [ "$FOUND" -eq 0 ]; then
echo "Missing language file: \`${LANG_FILE}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "Language file present: \`${LANG_FILE}\`" >> $GITHUB_STEP_SUMMARY
fi
done <<< "$LANG_FILES"
fi
else
echo "No manifest found — skipping language check." >> $GITHUB_STEP_SUMMARY
fi
if [ "${ERRORS}" -gt 0 ]; then
echo "" >> $GITHUB_STEP_SUMMARY
echo "**${ERRORS} missing language file(s).**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Language file check passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Check index.html files in directories
run: |
echo "### Index.html Check" >> $GITHUB_STEP_SUMMARY
MISSING=0
CHECKED=0
for DIR in src/ htdocs/; do
if [ -d "$DIR" ]; then
while IFS= read -r -d '' SUBDIR; do
CHECKED=$((CHECKED + 1))
if [ ! -f "${SUBDIR}/index.html" ]; then
echo "Missing index.html in: \`${SUBDIR}\`" >> $GITHUB_STEP_SUMMARY
MISSING=$((MISSING + 1))
fi
done < <(find "$DIR" -type d -print0)
fi
done
if [ "${CHECKED}" -eq 0 ]; then
echo "No src/ or htdocs/ directories found — skipping." >> $GITHUB_STEP_SUMMARY
elif [ "${MISSING}" -gt 0 ]; then
echo "" >> $GITHUB_STEP_SUMMARY
echo "**${MISSING} director(ies) missing index.html out of ${CHECKED} checked.**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "All ${CHECKED} directories contain index.html." >> $GITHUB_STEP_SUMMARY
fi
release-readiness:
name: Release Readiness Check
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.base_ref == 'main'
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Validate release readiness
run: |
echo "## Release Readiness" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
ERRORS=0
# Extract version from README.md
README_VERSION=$(grep -oP '^\s*VERSION:\s*\K[0-9]{2}\.[0-9]{2}\.[0-9]{2}' README.md | head -1)
if [ -z "$README_VERSION" ]; then
echo "No VERSION found in README.md FILE INFORMATION block." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "README version: \`${README_VERSION}\`" >> $GITHUB_STEP_SUMMARY
fi
# Find the extension manifest
MANIFEST=""
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
MANIFEST="$XML_FILE"
break
fi
done
if [ -z "$MANIFEST" ]; then
echo "No Joomla extension manifest found." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "Manifest: \`${MANIFEST}\`" >> $GITHUB_STEP_SUMMARY
# Check <version> matches README VERSION
MANIFEST_VERSION=$(grep -oP '<version>\K[^<]+' "$MANIFEST" | head -1)
if [ -z "$MANIFEST_VERSION" ]; then
echo "No \`<version>\` tag in manifest." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
elif [ -n "$README_VERSION" ] && [ "$MANIFEST_VERSION" != "$README_VERSION" ]; then
echo "Manifest version \`${MANIFEST_VERSION}\` does not match README \`${README_VERSION}\`." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "Manifest version: \`${MANIFEST_VERSION}\`" >> $GITHUB_STEP_SUMMARY
fi
# Check extension type, element, client attributes
EXT_TYPE=$(grep -oP '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
if [ -z "$EXT_TYPE" ]; then
echo "Missing \`type\` attribute on \`<extension>\` tag." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "Extension type: \`${EXT_TYPE}\`" >> $GITHUB_STEP_SUMMARY
fi
# Element check (component/module/plugin name)
HAS_ELEMENT=$(grep -cP '<(element|name)>' "$MANIFEST" 2>/dev/null || echo "0")
if [ "$HAS_ELEMENT" -eq 0 ]; then
echo "Missing \`<element>\` or \`<name>\` in manifest." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
# Client attribute for site/admin modules and plugins
if echo "$EXT_TYPE" | grep -qP "^(module|plugin)$"; then
HAS_CLIENT=$(grep -cP '<extension[^>]*\bclient=' "$MANIFEST" 2>/dev/null || echo "0")
if [ "$HAS_CLIENT" -eq 0 ]; then
echo "Missing \`client\` attribute for ${EXT_TYPE} extension." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
fi
fi
# Check updates.xml exists
if [ -f "updates.xml" ] || [ -f "updates.xml" ]; then
echo "Update XML present." >> $GITHUB_STEP_SUMMARY
else
echo "No updates.xml found." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
# Check CHANGELOG.md exists
if [ -f "CHANGELOG.md" ]; then
echo "CHANGELOG.md present." >> $GITHUB_STEP_SUMMARY
else
echo "No CHANGELOG.md found." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ $ERRORS -gt 0 ]; then
echo "**${ERRORS} issue(s) must be resolved before release.**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Extension is ready for release.**" >> $GITHUB_STEP_SUMMARY
fi
test:
name: Tests (PHP ${{ matrix.php }})
runs-on: ubuntu-latest
needs: lint-and-validate
strategy:
fail-fast: false
matrix:
php: ['8.2', '8.3']
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup PHP ${{ matrix.php }}
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 php-zip php-curl composer >/dev/null 2>&1
fi
php -v && composer --version
- name: Install dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.GA_TOKEN || github.token }}"}}'
run: |
if [ -f "composer.json" ]; then
composer install \
--no-interaction \
--prefer-dist \
--optimize-autoloader
else
echo "No composer.json found — skipping dependency install"
fi
- name: Run tests
run: |
echo "### Test Results (PHP ${{ matrix.php }})" >> $GITHUB_STEP_SUMMARY
if [ -f "phpunit.xml" ] || [ -f "phpunit.xml.dist" ]; then
vendor/bin/phpunit --testdox 2>&1 | tee /tmp/test-output.log
EXIT=${PIPESTATUS[0]}
if [ $EXIT -eq 0 ]; then
echo "All tests passed." >> $GITHUB_STEP_SUMMARY
else
echo "Test failures detected — see log." >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
cat /tmp/test-output.log >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
fi
exit $EXIT
else
echo "No phpunit.xml found — skipping tests." >> $GITHUB_STEP_SUMMARY
fi
static-analysis:
name: PHPStan Analysis
runs-on: ubuntu-latest
needs: lint-and-validate
continue-on-error: true
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup PHP
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 php-zip php-curl composer >/dev/null 2>&1
fi
php -v && composer --version
- name: Install dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.GA_TOKEN || github.token }}"}}'
run: |
if [ -f "composer.json" ]; then
composer install --no-interaction --prefer-dist --optimize-autoloader
fi
- name: Install PHPStan
run: |
if ! command -v vendor/bin/phpstan &> /dev/null; then
composer require --dev phpstan/phpstan --no-interaction 2>/dev/null || \
composer global require phpstan/phpstan --no-interaction
fi
- name: Run PHPStan
run: |
echo "### PHPStan Static Analysis" >> $GITHUB_STEP_SUMMARY
PHPSTAN="vendor/bin/phpstan"
if [ ! -f "$PHPSTAN" ]; then
PHPSTAN=$(composer global config bin-dir --absolute 2>/dev/null)/phpstan
fi
# Determine source directory
SRC_DIR=""
for DIR in src/ htdocs/ lib/; do
if [ -d "$DIR" ]; then
SRC_DIR="$DIR"
break
fi
done
if [ -z "$SRC_DIR" ]; then
echo "No source directory found (src/, htdocs/, lib/) — skipping." >> $GITHUB_STEP_SUMMARY
exit 0
fi
# Use repo phpstan.neon if present, otherwise use baseline config
ARGS="analyse ${SRC_DIR} --memory-limit=512M --no-progress --error-format=table"
if [ -f "phpstan.neon" ] || [ -f "phpstan.neon.dist" ]; then
echo "Using project PHPStan config." >> $GITHUB_STEP_SUMMARY
else
ARGS="$ARGS --level=3"
echo "No phpstan.neon found — using level 3 (type inference)." >> $GITHUB_STEP_SUMMARY
fi
$PHPSTAN $ARGS 2>&1 | tee /tmp/phpstan-output.txt
EXIT=${PIPESTATUS[0]}
if [ $EXIT -eq 0 ]; then
echo "**No errors found.**" >> $GITHUB_STEP_SUMMARY
else
ERRORS=$(grep -c "ERROR" /tmp/phpstan-output.txt 2>/dev/null || echo "some")
echo "**${ERRORS} error(s) found.** Review output above." >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
tail -30 /tmp/phpstan-output.txt >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
fi
exit $EXIT
pre-release:
name: Build RC Pre-Release
runs-on: ubuntu-latest
needs: [lint-and-validate, test]
if: github.event_name == 'pull_request'
steps:
- name: Trigger pre-release build
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.head_ref }}
run: |
curl -s -X POST \
"${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" \
-H "Authorization: token ${GA_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
+10 -10
View File
@@ -4,10 +4,10 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Maintenance # INGROUP: MokoStandards.Maintenance
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.gitea/workflows/cleanup.yml # PATH: /.gitea/workflows/cleanup.yml
# VERSION: 09.23.00 # VERSION: 01.00.00
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs # BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
name: "Universal: Repository Cleanup" name: "Universal: Repository Cleanup"
@@ -33,17 +33,17 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.MOKOGITEA_TOKEN }} token: ${{ secrets.GA_TOKEN }}
- name: Delete merged branches - name: Delete merged branches
env: env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} GA_TOKEN: ${{ secrets.GA_TOKEN }}
run: | run: |
echo "=== Merged Branch Cleanup ===" echo "=== Merged Branch Cleanup ==="
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}" API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
# List branches via API # List branches via API
BRANCHES=$(curl -sS -H "Authorization: token ${GITEA_TOKEN}" \ BRANCHES=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
"${API}/branches?limit=50" | jq -r '.[].name') "${API}/branches?limit=50" | jq -r '.[].name')
DELETED=0 DELETED=0
@@ -56,7 +56,7 @@ jobs:
# Check if branch is merged into main # Check if branch is merged into main
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
echo " Deleting merged branch: ${BRANCH}" echo " Deleting merged branch: ${BRANCH}"
curl -sS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \ curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
"${API}/branches/${BRANCH}" 2>/dev/null || true "${API}/branches/${BRANCH}" 2>/dev/null || true
DELETED=$((DELETED + 1)) DELETED=$((DELETED + 1))
fi fi
@@ -66,20 +66,20 @@ jobs:
- name: Clean old workflow runs - name: Clean old workflow runs
env: env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} GA_TOKEN: ${{ secrets.GA_TOKEN }}
run: | run: |
echo "=== Workflow Run Cleanup ===" echo "=== Workflow Run Cleanup ==="
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}" API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ) CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ)
# Get old completed runs # Get old completed runs
RUNS=$(curl -sS -H "Authorization: token ${GITEA_TOKEN}" \ RUNS=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
"${API}/actions/runs?status=completed&limit=50" | \ "${API}/actions/runs?status=completed&limit=50" | \
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null) jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
DELETED=0 DELETED=0
for RUN_ID in $RUNS; do for RUN_ID in $RUNS; do
curl -sS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \ curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true "${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
DELETED=$((DELETED + 1)) DELETED=$((DELETED + 1))
done done
+126
View File
@@ -0,0 +1,126 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Deploy
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
# PATH: /templates/workflows/joomla/deploy-manual.yml.template
# VERSION: 04.07.00
# BRIEF: Manual SFTP deploy to dev server for Joomla repos
name: "Universal: Deploy to Dev (Manual)"
on:
workflow_dispatch:
inputs:
clear_remote:
description: 'Delete all remote files before uploading'
required: false
default: 'false'
type: boolean
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
permissions:
contents: read
jobs:
deploy:
name: SFTP Deploy to Dev
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup PHP
run: |
php -v && composer --version
- name: Setup MokoStandards tools
env:
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
run: |
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
/tmp/mokostandards-api 2>/dev/null || true
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
fi
- name: Check FTP configuration
id: check
env:
HOST: ${{ vars.DEV_FTP_HOST }}
PATH_VAR: ${{ vars.DEV_FTP_PATH }}
PORT: ${{ vars.DEV_FTP_PORT }}
run: |
if [ -z "$HOST" ] || [ -z "$PATH_VAR" ]; then
echo "DEV_FTP_HOST or DEV_FTP_PATH not configured -- cannot deploy"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "host=$HOST" >> "$GITHUB_OUTPUT"
REMOTE="${PATH_VAR%/}"
echo "remote=$REMOTE" >> "$GITHUB_OUTPUT"
[ -z "$PORT" ] && PORT="22"
echo "port=$PORT" >> "$GITHUB_OUTPUT"
- name: Deploy via SFTP
if: steps.check.outputs.skip != 'true'
env:
SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
SFTP_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
[ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ -- nothing to deploy"; exit 0; }
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
"${{ steps.check.outputs.host }}" "${{ steps.check.outputs.port }}" "$SFTP_USER" "${{ steps.check.outputs.remote }}" \
> /tmp/sftp-config.json
if [ -n "$SFTP_KEY" ]; then
echo "$SFTP_KEY" > /tmp/deploy_key
chmod 600 /tmp/deploy_key
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
else
printf ',"password":"%s"}' "$SFTP_PASS" >> /tmp/sftp-config.json
fi
DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
[ "${{ inputs.clear_remote }}" = "true" ] && DEPLOY_ARGS+=(--clear-remote)
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
php /tmp/mokostandards-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
else
php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
fi
rm -f /tmp/deploy_key /tmp/sftp-config.json
- name: Summary
if: always()
run: |
if [ "${{ steps.check.outputs.skip }}" = "true" ]; then
echo "### Deploy Skipped -- FTP not configured" >> $GITHUB_STEP_SUMMARY
else
echo "### Manual Dev Deploy Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Host | \`${{ steps.check.outputs.host }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Remote | \`${{ steps.check.outputs.remote }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Clear | ${{ inputs.clear_remote }} |" >> $GITHUB_STEP_SUMMARY
fi
+3 -3
View File
@@ -4,10 +4,10 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Security # INGROUP: MokoStandards.Security
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform # REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/gitleaks.yml.template # PATH: /templates/workflows/gitleaks.yml.template
# VERSION: 09.23.00 # VERSION: 01.00.00
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens # BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
# #
# +========================================================================+ # +========================================================================+
+2 -2
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation # INGROUP: moko-platform.Automation
# VERSION: 02.34.77 # VERSION: 01.00.00
# BRIEF: Auto-create feature branch when an issue is opened # BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch" name: "Universal: Issue Branch"
@@ -28,7 +28,7 @@ jobs:
steps: steps:
- name: Create branch and comment - name: Create branch and comment
run: | run: |
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" TOKEN="${{ secrets.GA_TOKEN }}"
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}" API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
ISSUE_NUM="${{ github.event.issue.number }}" ISSUE_NUM="${{ github.event.issue.number }}"
ISSUE_TITLE="${{ github.event.issue.title }}" ISSUE_TITLE="${{ github.event.issue.title }}"
+3 -3
View File
@@ -4,10 +4,10 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Notifications # INGROUP: MokoStandards.Notifications
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.gitea/workflows/notify.yml # PATH: /.gitea/workflows/notify.yml
# VERSION: 09.23.00 # VERSION: 01.00.00
# BRIEF: Push notifications via ntfy on release success or workflow failure # BRIEF: Push notifications via ntfy on release success or workflow failure
name: "Universal: Notifications" name: "Universal: Notifications"
+1 -25
View File
@@ -246,7 +246,7 @@ jobs:
joomla) joomla)
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1) MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
if [ -z "$MANIFEST" ]; then if [ -z "$MANIFEST" ]; then
echo "::warning::No Joomla manifest found (Suite site)" echo "::warning::No Joomla manifest found (WaaS site)"
exit 0 exit 0
fi fi
echo "Manifest: ${MANIFEST}" echo "Manifest: ${MANIFEST}"
@@ -295,30 +295,6 @@ jobs:
;; ;;
esac esac
- name: Check changelog has unreleased entries (PRs to main)
if: github.base_ref == 'main'
run: |
if [ ! -f "CHANGELOG.md" ]; then
echo "::error::CHANGELOG.md not found — required for releases"
exit 1
fi
# Extract content between [Unreleased] and next ## heading
ENTRIES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found && /^- /{count++} END{print count+0}' CHANGELOG.md)
if [ "$ENTRIES" -eq 0 ]; then
echo "::error::CHANGELOG.md has no entries under [Unreleased]. Add changelog entries before releasing."
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "No entries found under \`[Unreleased]\` in CHANGELOG.md." >> $GITHUB_STEP_SUMMARY
echo "Add entries describing what changed before merging to main." >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Changelog: ${ENTRIES} unreleased entries found"
echo "## Changelog Check: Passed" >> $GITHUB_STEP_SUMMARY
echo "${ENTRIES} entries under [Unreleased]" >> $GITHUB_STEP_SUMMARY
- name: Validate Joomla language files - name: Validate Joomla language files
if: steps.platform.outputs.platform == 'joomla' if: steps.platform.outputs.platform == 'joomla'
run: | run: |
-240
View File
@@ -9,243 +9,3 @@
# PATH: /templates/workflows/universal/pre-release.yml.template # PATH: /templates/workflows/universal/pre-release.yml.template
# VERSION: 05.01.00 # VERSION: 05.01.00
# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches # BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches
name: "Universal: Pre-Release"
on:
push:
branches:
- dev
- 'fix/**'
- 'patch/**'
- 'hotfix/**'
- 'bugfix/**'
- alpha
- beta
- rc
workflow_dispatch:
inputs:
stability:
description: 'Pre-release channel'
required: true
type: choice
options:
- development
- alpha
- beta
- release-candidate
permissions:
contents: write
env:
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 }}
jobs:
build:
name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})"
runs-on: release
if: >-
github.event_name == 'workflow_dispatch' ||
github.event_name == 'push'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.MOKOGITEA_TOKEN }}
ref: ${{ github.ref_name }}
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
run: |
# Use pre-installed /opt/moko-platform if available (updated by cron every 6h)
if [ -f /opt/moko-platform/cli/version_bump.php ] && [ -f /opt/moko-platform/cli/manifest_element.php ] && [ -f /opt/moko-platform/vendor/autoload.php ]; then
echo Using pre-installed /opt/moko-platform
echo MOKO_CLI=/opt/moko-platform/cli >> $GITHUB_ENV
else
echo Falling back to fresh clone
if ! command -v composer > /dev/null 2>&1; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
fi
rm -rf /tmp/moko-platform-api
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/moko-platform-api
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
echo MOKO_CLI=/tmp/moko-platform-api/cli >> $GITHUB_ENV
fi
- name: Detect platform
id: platform
run: |
# Auto-detect and update platform if not set in manifest
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
php ${MOKO_CLI}/manifest_read.php --path . --github-output
- name: Resolve metadata and bump version
id: meta
run: |
# Auto-detect stability from branch name on push, or use input on dispatch
if [ "${{ github.event_name }}" = "push" ]; then
case "${{ github.ref_name }}" in
rc) STABILITY="release-candidate" ;;
alpha) STABILITY="alpha" ;;
beta) STABILITY="beta" ;;
*) STABILITY="development" ;;
esac
else
STABILITY="${{ inputs.stability || 'development' }}"
fi
case "$STABILITY" in
development) SUFFIX="-dev"; TAG="development" ;;
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
beta) SUFFIX="-beta"; TAG="beta" ;;
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
esac
# Bump version via CLI: patch for dev/alpha/beta, minor for RC
case "$STABILITY" in
release-candidate) BUMP="minor" ;;
*) BUMP="patch" ;;
esac
php ${MOKO_CLI}/version_bump.php --path . $([ "$BUMP" = "minor" ] && echo "--minor") 2>/dev/null || true
# Set stability suffix and verify consistency
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01")
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
php ${MOKO_CLI}/version_set_platform.php \
--path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
# Ensure licensing tags (updateservers, dlid) if enabled in manifest.xml
php ${MOKO_CLI}/manifest_licensing.php --path . --fix 2>/dev/null || true
# Append suffix for output
if [ -n "$SUFFIX" ]; then
VERSION="${VERSION}${SUFFIX}"
fi
# Commit version bump
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
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"
git add -A
git diff --cached --quiet || {
git commit -m "chore(version): pre-release bump to ${VERSION} [skip ci]"
git push origin HEAD 2>&1
}
# Auto-detect element via manifest_element.php
php ${MOKO_CLI}/manifest_element.php \
--path . --version "$VERSION" --stability "$STABILITY" \
--repo "${GITEA_REPO}" --github-output
# Read back element outputs
EXT_ELEMENT=$(grep '^ext_element=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
ZIP_NAME=$(grep '^zip_name=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
[ -z "$ZIP_NAME" ] && ZIP_NAME="${EXT_ELEMENT}-${VERSION}.zip"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
- name: Create release
id: release
run: |
TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php ${MOKO_CLI}/release_create.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
- name: Update release notes from CHANGELOG.md
run: |
TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
# Extract [Unreleased] section from changelog (everything between [Unreleased] and next ## heading)
if [ -f "CHANGELOG.md" ]; then
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
else
NOTES="Release ${VERSION}"
fi
# Update release body via API
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
"${API_BASE}/releases/tags/${TAG}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -n "$RELEASE_ID" ]; then
python3 -c "
import json, urllib.request
body = open('/dev/stdin').read()
payload = json.dumps({'body': body}).encode()
req = urllib.request.Request(
'${API_BASE}/releases/${RELEASE_ID}',
data=payload, method='PATCH',
headers={
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
'Content-Type': 'application/json'
})
urllib.request.urlopen(req)
" <<< "$NOTES"
echo "Release notes updated from CHANGELOG.md"
fi
- name: Build package and upload
id: package
run: |
VERSION="${{ steps.meta.outputs.version }}"
TAG="${{ steps.meta.outputs.tag }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php ${MOKO_CLI}/release_package.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --output /tmp || true
# updates.xml is generated dynamically by MokoGitea license server
# No need to build, commit, or sync updates.xml from workflows
- name: "Delete lesser pre-release channels (cascade)"
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
php ${MOKO_CLI}/release_cascade.php \
--stability "${{ steps.meta.outputs.stability }}" \
--token "${TOKEN}" \
--api-base "${API_BASE}"
- name: Summary
if: always()
run: |
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
SHA256="${{ steps.package.outputs.sha256_zip }}"
echo "## Pre-Release Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Channel | ${STABILITY} |" >> $GITHUB_STEP_SUMMARY
echo "| Package | \`${ZIP_NAME}\` |" >> $GITHUB_STEP_SUMMARY
echo "| SHA-256 | \`${SHA256:-n/a}\` |" >> $GITHUB_STEP_SUMMARY
-66
View File
@@ -1,66 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoPlatform.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.mokogitea/workflows/rc-revert.yml
# VERSION: 09.23.00
# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge
name: "RC Revert"
on:
pull_request:
types: [closed]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
revert:
name: Rename rc/ back to dev/
runs-on: ubuntu-latest
if: >-
github.event.pull_request.merged == false &&
startsWith(github.event.pull_request.head.ref, 'rc/')
steps:
- name: Rename branch
run: |
BRANCH="${{ github.event.pull_request.head.ref }}"
SUFFIX="${BRANCH#rc/}"
DEV_BRANCH="dev/${SUFFIX}"
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Create dev/ branch from rc/ branch
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"new_branch_name\": \"${DEV_BRANCH}\", \"old_branch_name\": \"${BRANCH}\"}" \
"${API}" 2>/dev/null || true)
if [ "$STATUS" = "201" ]; then
echo "Created branch: ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
else
echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"
exit 1
fi
# Delete rc/ branch
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${API}/${ENCODED}" 2>/dev/null || true)
if [ "$STATUS" = "204" ]; then
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
else
echo "::warning::Failed to delete ${BRANCH} (HTTP ${STATUS})"
fi
echo "### RC Reverted" >> $GITHUB_STEP_SUMMARY
echo "${BRANCH} → ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
+3 -19
View File
@@ -4,10 +4,10 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Security # INGROUP: MokoStandards.Security
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.gitea/workflows/security-audit.yml # PATH: /.gitea/workflows/security-audit.yml
# VERSION: 09.23.00 # VERSION: 01.00.00
# BRIEF: Dependency vulnerability scanning for composer and npm packages # BRIEF: Dependency vulnerability scanning for composer and npm packages
name: "Universal: Security Audit" name: "Universal: Security Audit"
@@ -80,19 +80,3 @@ jobs:
-H "Priority: high" \ -H "Priority: high" \
-d "Security audit found vulnerabilities. Review dependency updates." \ -d "Security audit found vulnerabilities. Review dependency updates." \
"${NTFY_URL}/${NTFY_TOPIC}" || true "${NTFY_URL}/${NTFY_TOPIC}" || true
- name: Joomla version audit
if: always()
run: |
if [ -f "monitoring/joomla-version-audit.php" ] && [ -n "$JOOMLA_SITES" ]; then
echo "$JOOMLA_SITES" > /tmp/sites.json
php monitoring/joomla-version-audit.php --sites /tmp/sites.json || true
echo "### Joomla Version Audit" >> $GITHUB_STEP_SUMMARY
rm -f /tmp/sites.json
else
echo "Joomla audit skipped (no script or JOOMLA_SITES_JSON not configured)"
fi
env:
JOOMLA_SITES: ${{ vars.JOOMLA_SITES_JSON }}
+1 -14
View File
@@ -14,7 +14,7 @@
INGROUP: MokoSuite.Documentation INGROUP: MokoSuite.Documentation
REPO: https://github.com/mokoconsulting-tech/mokosuite REPO: https://github.com/mokoconsulting-tech/mokosuite
PATH: ./CHANGELOG.md PATH: ./CHANGELOG.md
VERSION: 02.34.77 VERSION: 02.34.50
BRIEF: Version history using `Keep a Changelog` BRIEF: Version history using `Keep a Changelog`
--> -->
@@ -23,19 +23,6 @@
## [Unreleased] ## [Unreleased]
### Added ### Added
- plg_system_mokosuite_dbip — IP geolocation plugin using DB-IP MMDB databases (CDN auto-download, local file mode, bundled MaxMind reader)
- Admin sidebar menu restructure — each Moko component gets its own collapsible section, com_mokosuitehq pinned first
- rc-revert workflow for release candidate rollbacks
- Ntfy push notifications for ticket events and security alerts (#205) — configurable server/topic/token
- Canned responses admin UI with edit modal, category filter, drag-and-drop reorder (#138)
- Ticket categories drag-and-drop reorder (#139)
- File attachments on tickets and replies (#141) — upload/download/delete with type and size validation
- Satisfaction ratings on resolved tickets (#140) — 1-5 star widget with optional feedback
- Helpdesk REST API (#142) — GET/POST/PATCH tickets, POST replies, filters, pagination
- Component config options UI (#149) — general, notification (email + ntfy), helpdesk settings
- Automation rule visual builder (#137) — condition/action dropdowns, edit existing, reorder, XSS-safe DOM
- Admin login and failed login security notifications (#147)
- Automation engine with Joomla event triggers (#151) — user_login, user_register, content_save, extension_install, behavior modes (append/always_new/skip_if_open), create_ticket action
- RSA-signed heartbeat authentication — private key in monitor plugin manifest, public key on MokoSuiteHQ - RSA-signed heartbeat authentication — private key in monitor plugin manifest, public key on MokoSuiteHQ
- Monitor plugin base_url set via manifest (hidden from admin UI), propagated via update server - Monitor plugin base_url set via manifest (hidden from admin UI), propagated via update server
- Send Heartbeat button on health token field for manual heartbeat testing - Send Heartbeat button on health token field for manual heartbeat testing
+1 -1
View File
@@ -14,7 +14,7 @@
DEFGROUP: Joomla.Plugin DEFGROUP: Joomla.Plugin
INGROUP: MokoSuite.Documentation INGROUP: MokoSuite.Documentation
REPO: https://github.com/mokoconsulting-tech/mokosuite REPO: https://github.com/mokoconsulting-tech/mokosuite
VERSION: 02.34.77 VERSION: 02.34.50
PATH: ./CODE_OF_CONDUCT.md PATH: ./CODE_OF_CONDUCT.md
BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default
--> -->
+1 -1
View File
@@ -19,7 +19,7 @@
DEFGROUP: mokoconsulting-tech.MokoSuiteBrand DEFGROUP: mokoconsulting-tech.MokoSuiteBrand
INGROUP: MokoStandards.Governance INGROUP: MokoStandards.Governance
REPO: https://github.com/mokoconsulting-tech/MokoSuiteBrand REPO: https://github.com/mokoconsulting-tech/MokoSuiteBrand
VERSION: 02.34.77 VERSION: 02.34.50
PATH: /GOVERNANCE.md PATH: /GOVERNANCE.md
BRIEF: Project governance rules, roles, and decision process for MokoSuiteBrand BRIEF: Project governance rules, roles, and decision process for MokoSuiteBrand
--> -->
+1 -1
View File
@@ -15,7 +15,7 @@
INGROUP: MokoSuite.Documentation INGROUP: MokoSuite.Documentation
REPO: https://github.com/mokoconsulting-tech/mokosuite REPO: https://github.com/mokoconsulting-tech/mokosuite
PATH: ./LICENSE.md PATH: ./LICENSE.md
VERSION: 02.34.77 VERSION: 02.34.50
BRIEF: Project license (GPL-3.0-or-later) BRIEF: Project license (GPL-3.0-or-later)
--> -->
GNU GENERAL PUBLIC LICENSE GNU GENERAL PUBLIC LICENSE
+1 -1
View File
@@ -9,7 +9,7 @@
DEFGROUP: Joomla.Plugin DEFGROUP: Joomla.Plugin
INGROUP: MokoSuite INGROUP: MokoSuite
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuite REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuite
VERSION: 02.34.77 VERSION: 02.34.50
PATH: /README.md PATH: /README.md
BRIEF: MokoSuite platform plugin for Joomla BRIEF: MokoSuite platform plugin for Joomla
--> -->
+1 -1
View File
@@ -23,7 +23,7 @@ DEFGROUP: [PROJECT_NAME]
INGROUP: [PROJECT_NAME].Documentation INGROUP: [PROJECT_NAME].Documentation
REPO: [REPOSITORY_URL] REPO: [REPOSITORY_URL]
PATH: /SECURITY.md PATH: /SECURITY.md
VERSION: 02.34.77 VERSION: 02.34.50
BRIEF: Security vulnerability reporting and handling policy BRIEF: Security vulnerability reporting and handling policy
--> -->
+2 -2
View File
@@ -11,13 +11,13 @@
INGROUP: MokoSuite.Build INGROUP: MokoSuite.Build
REPO: https://github.com/mokoconsulting-tech/mokosuite REPO: https://github.com/mokoconsulting-tech/mokosuite
FILE: build-guide.md FILE: build-guide.md
VERSION: 02.34.77 VERSION: 02.34.50
PATH: /docs/guides/ PATH: /docs/guides/
BRIEF: Build and packaging guide for the MokoSuite system plugin BRIEF: Build and packaging guide for the MokoSuite system plugin
NOTE: Defines environment setup, repository layout, packaging rules, and release preparation NOTE: Defines environment setup, repository layout, packaging rules, and release preparation
--> -->
# MokoSuite Build Guide (VERSION: 02.34.77) # MokoSuite Build Guide (VERSION: 02.34.50)
## 1. Purpose ## 1. Purpose
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin DEFGROUP: Joomla.Plugin
INGROUP: MokoSuite.Guides INGROUP: MokoSuite.Guides
REPO: https://github.com/mokoconsulting-tech/mokosuite REPO: https://github.com/mokoconsulting-tech/mokosuite
VERSION: 02.34.77 VERSION: 02.34.50
PATH: /docs/guides/configuration-guide.md PATH: /docs/guides/configuration-guide.md
BRIEF: Configuration guide for the MokoSuite system plugin BRIEF: Configuration guide for the MokoSuite system plugin
NOTE: Defines plugin parameters, expected behaviors, and recommended defaults NOTE: Defines plugin parameters, expected behaviors, and recommended defaults
--> -->
# MokoSuite Configuration Guide (VERSION: 02.34.77) # MokoSuite Configuration Guide (VERSION: 02.34.50)
## 1. Objective ## 1. Objective
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin DEFGROUP: Joomla.Plugin
INGROUP: MokoSuite.Guides INGROUP: MokoSuite.Guides
REPO: https://github.com/mokoconsulting-tech/mokosuite REPO: https://github.com/mokoconsulting-tech/mokosuite
VERSION: 02.34.77 VERSION: 02.34.50
PATH: /docs/guides/installation-guide.md PATH: /docs/guides/installation-guide.md
BRIEF: Installation guide for the MokoSuite system plugin BRIEF: Installation guide for the MokoSuite system plugin
NOTE: First document in the guide set NOTE: First document in the guide set
--> -->
# MokoSuite Installation Guide (VERSION: 02.34.77) # MokoSuite Installation Guide (VERSION: 02.34.50)
## Introduction ## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin DEFGROUP: Joomla.Plugin
INGROUP: MokoSuite.Guides INGROUP: MokoSuite.Guides
REPO: https://github.com/mokoconsulting-tech/mokosuite REPO: https://github.com/mokoconsulting-tech/mokosuite
VERSION: 02.34.77 VERSION: 02.34.50
PATH: /docs/guides/operations-guide.md PATH: /docs/guides/operations-guide.md
BRIEF: Operational guide for administering and managing the MokoSuite system plugin BRIEF: Operational guide for administering and managing the MokoSuite system plugin
NOTE: Defines lifecycle, responsibilities, and operational behaviors NOTE: Defines lifecycle, responsibilities, and operational behaviors
--> -->
# MokoSuite Operations Guide (VERSION: 02.34.77) # MokoSuite Operations Guide (VERSION: 02.34.50)
## Introduction ## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin DEFGROUP: Joomla.Plugin
INGROUP: MokoSuite.Guides INGROUP: MokoSuite.Guides
REPO: https://github.com/mokoconsulting-tech/mokosuite REPO: https://github.com/mokoconsulting-tech/mokosuite
VERSION: 02.34.77 VERSION: 02.34.50
PATH: /docs/guides/rollback-and-recovery-guide.md PATH: /docs/guides/rollback-and-recovery-guide.md
BRIEF: Rollback and recovery guide for restoring stable operation after plugin related incidents BRIEF: Rollback and recovery guide for restoring stable operation after plugin related incidents
NOTE: Completes the core guide set for Suite plugin governance NOTE: Completes the core guide set for Suite plugin governance
--> -->
# MokoSuite Rollback and Recovery Guide (VERSION: 02.34.77) # MokoSuite Rollback and Recovery Guide (VERSION: 02.34.50)
## Introduction ## Introduction
+2 -2
View File
@@ -7,13 +7,13 @@
DEFGROUP: Joomla.Plugin DEFGROUP: Joomla.Plugin
INGROUP: MokoSuite.Guides INGROUP: MokoSuite.Guides
REPO: https://github.com/mokoconsulting-tech/mokosuite REPO: https://github.com/mokoconsulting-tech/mokosuite
VERSION: 02.34.77 VERSION: 02.34.50
PATH: /docs/guides/testing-guide.md PATH: /docs/guides/testing-guide.md
BRIEF: Testing guide for MokoSuite v02.01.08 BRIEF: Testing guide for MokoSuite v02.01.08
NOTE: Covers manual test procedures for language overrides, install/uninstall, and configuration NOTE: Covers manual test procedures for language overrides, install/uninstall, and configuration
--> -->
# MokoSuite Testing Guide (VERSION: 02.34.77) # MokoSuite Testing Guide (VERSION: 02.34.50)
## 1. Prerequisites ## 1. Prerequisites
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin DEFGROUP: Joomla.Plugin
INGROUP: MokoSuite.Guides INGROUP: MokoSuite.Guides
REPO: https://github.com/mokoconsulting-tech/mokosuite REPO: https://github.com/mokoconsulting-tech/mokosuite
VERSION: 02.34.77 VERSION: 02.34.50
PATH: /docs/guides/troubleshooting-guide.md PATH: /docs/guides/troubleshooting-guide.md
BRIEF: Troubleshooting guide for diagnosing and resolving issues related to the MokoSuite plugin BRIEF: Troubleshooting guide for diagnosing and resolving issues related to the MokoSuite plugin
NOTE: Designed for administrators and Suite operations teams NOTE: Designed for administrators and Suite operations teams
--> -->
# MokoSuite Troubleshooting Guide (VERSION: 02.34.77) # MokoSuite Troubleshooting Guide (VERSION: 02.34.50)
## Introduction ## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin DEFGROUP: Joomla.Plugin
INGROUP: MokoSuite.Guides INGROUP: MokoSuite.Guides
REPO: https://github.com/mokoconsulting-tech/mokosuite REPO: https://github.com/mokoconsulting-tech/mokosuite
VERSION: 02.34.77 VERSION: 02.34.50
PATH: /docs/guides/upgrade-and-versioning-guide.md PATH: /docs/guides/upgrade-and-versioning-guide.md
BRIEF: Guide for updating, versioning, and maintaining the MokoSuite plugin BRIEF: Guide for updating, versioning, and maintaining the MokoSuite plugin
NOTE: Defines release flow, version rules, and upgrade validation NOTE: Defines release flow, version rules, and upgrade validation
--> -->
# MokoSuite Upgrade and Versioning Guide (VERSION: 02.34.77) # MokoSuite Upgrade and Versioning Guide (VERSION: 02.34.50)
## Introduction ## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin DEFGROUP: Joomla.Plugin
INGROUP: MokoSuite.Documentation INGROUP: MokoSuite.Documentation
REPO: https://github.com/mokoconsulting-tech/mokosuite REPO: https://github.com/mokoconsulting-tech/mokosuite
VERSION: 02.34.77 VERSION: 02.34.50
PATH: /docs/index.md PATH: /docs/index.md
BRIEF: Master index of all documentation for the MokoSuite plugin BRIEF: Master index of all documentation for the MokoSuite plugin
NOTE: Automatically maintained index for all guide canvases NOTE: Automatically maintained index for all guide canvases
--> -->
# MokoSuite Documentation Index (VERSION: 02.34.77) # MokoSuite Documentation Index (VERSION: 02.34.50)
## Introduction ## Introduction
+2 -2
View File
@@ -11,12 +11,12 @@
INGROUP: MokoSuite INGROUP: MokoSuite
REPO: https://github.com/mokoconsulting-tech/mokosuite REPO: https://github.com/mokoconsulting-tech/mokosuite
PATH: /docs/plugin-basic.md PATH: /docs/plugin-basic.md
VERSION: 02.34.77 VERSION: 02.34.50
BRIEF: Baseline documentation for the MokoSuite system plugin BRIEF: Baseline documentation for the MokoSuite system plugin
NOTE: Foundational reference for internal and external stakeholders NOTE: Foundational reference for internal and external stakeholders
--> -->
# MokoSuite Plugin Overview (VERSION: 02.34.77) # MokoSuite Plugin Overview (VERSION: 02.34.50)
## Introduction ## Introduction
+1 -1
View File
@@ -10,7 +10,7 @@ DEFGROUP: MokoSuite.Documentation
INGROUP: MokoStandards.Templates INGROUP: MokoStandards.Templates
REPO: https://github.com/mokoconsulting-tech/MokoSuite REPO: https://github.com/mokoconsulting-tech/MokoSuite
PATH: /docs/update-server.md PATH: /docs/update-server.md
VERSION: 02.34.77 VERSION: 02.34.50
BRIEF: How this extension's Joomla update server file (update.xml) is managed BRIEF: How this extension's Joomla update server file (update.xml) is managed
--> -->
@@ -1,16 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<config> <config>
<fieldset name="general" label="General" description="General component settings.">
<field name="brand_name" type="text" default="MokoSuite"
label="Brand Name"
description="Displayed in the admin sidebar, dashboard, and emails."
hint="MokoSuite" />
<field name="support_email" type="email" default=""
label="Support Email"
description="Reply-to address for outbound notification emails."
hint="support@example.com" />
</fieldset>
<fieldset name="notifications" label="Email Notifications" description="Configure email recipients for ticket and security notifications."> <fieldset name="notifications" label="Email Notifications" description="Configure email recipients for ticket and security notifications.">
<field name="admin_emails" type="text" default="" <field name="admin_emails" type="text" default=""
label="Admin Email Addresses" label="Admin Email Addresses"
@@ -27,31 +16,6 @@
<option value="1">JYES</option> <option value="1">JYES</option>
<option value="0">JNO</option> <option value="0">JNO</option>
</field> </field>
<field name="spacer_ntfy" type="spacer" label="Push Notifications (ntfy)" />
<field name="ntfy_enabled" type="radio" default="0"
label="Enable ntfy Push"
description="Send push notifications via ntfy for ticket and security events."
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="ntfy_server" type="url" default="https://ntfy.mokoconsulting.tech"
label="ntfy Server URL"
description="Full URL to your ntfy server."
showon="ntfy_enabled:1" />
<field name="ntfy_topic" type="text" default="mokosuite-tickets"
label="Ticket Topic"
description="ntfy topic name for helpdesk ticket notifications."
showon="ntfy_enabled:1" />
<field name="ntfy_security_topic" type="text" default="mokosuite-security"
label="Security Topic"
description="ntfy topic name for security alert notifications. Falls back to ticket topic if empty."
showon="ntfy_enabled:1" />
<field name="ntfy_token" type="password" default=""
label="ntfy Auth Token"
description="Bearer token for authenticated ntfy topics. Leave empty for public topics."
showon="ntfy_enabled:1" />
</fieldset> </fieldset>
<fieldset name="helpdesk" label="Helpdesk Settings" description="Default helpdesk behavior."> <fieldset name="helpdesk" label="Helpdesk Settings" description="Default helpdesk behavior.">
@@ -69,44 +33,6 @@
<option value="1">JYES</option> <option value="1">JYES</option>
<option value="0">JNO</option> <option value="0">JNO</option>
</field> </field>
<field name="satisfaction_enabled" type="radio" default="1"
label="Satisfaction Ratings"
description="Show rating prompt on resolved tickets."
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="max_attachment_size" type="number" default="10"
label="Max Attachment Size (MB)"
description="Maximum upload size per file in megabytes." />
</fieldset>
<fieldset name="email_to_ticket" label="Email-to-Ticket (IMAP)" description="Create tickets from incoming emails via IMAP polling.">
<field name="imap_host" type="text" default=""
label="IMAP Server"
description="IMAP hostname (e.g. imap.gmail.com)"
hint="imap.gmail.com" />
<field name="imap_port" type="number" default="993"
label="Port"
description="IMAP port (993 for SSL, 143 for plain)" />
<field name="imap_ssl" type="radio" default="1"
label="Use SSL"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="imap_user" type="text" default=""
label="Username"
description="IMAP login username or email address." />
<field name="imap_password" type="password" default=""
label="Password"
description="IMAP password or app-specific password." />
<field name="imap_folder" type="text" default="INBOX"
label="Inbox Folder"
description="IMAP folder to poll for new messages." />
<field name="imap_processed_folder" type="text" default="INBOX.Processed"
label="Processed Folder"
description="Move processed emails to this folder. Leave empty to just mark as read." />
</fieldset> </fieldset>
<fieldset name="permissions" label="COM_MOKOSUITE_ACL_TITLE" <fieldset name="permissions" label="COM_MOKOSUITE_ACL_TITLE"
@@ -72,9 +72,6 @@ CREATE TABLE IF NOT EXISTS `#__mokosuite_tickets` (
`sla_response_due` DATETIME DEFAULT NULL, `sla_response_due` DATETIME DEFAULT NULL,
`sla_resolution_due` DATETIME DEFAULT NULL, `sla_resolution_due` DATETIME DEFAULT NULL,
`sla_responded` TINYINT NOT NULL DEFAULT 0, `sla_responded` TINYINT NOT NULL DEFAULT 0,
`satisfaction_rating` TINYINT UNSIGNED DEFAULT NULL,
`satisfaction_feedback` TEXT DEFAULT NULL,
`satisfaction_rated_at` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
KEY `idx_status` (`status`), KEY `idx_status` (`status`),
KEY `idx_status_id` (`status_id`), KEY `idx_status_id` (`status_id`),
@@ -114,32 +111,15 @@ CREATE TABLE IF NOT EXISTS `#__mokosuite_ticket_canned` (
PRIMARY KEY (`id`) PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokosuite_ticket_attachments` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`ticket_id` INT UNSIGNED NOT NULL,
`reply_id` INT UNSIGNED DEFAULT NULL,
`filename` VARCHAR(255) NOT NULL,
`filepath` VARCHAR(512) NOT NULL,
`filesize` INT UNSIGNED NOT NULL DEFAULT 0,
`mimetype` VARCHAR(100) NOT NULL DEFAULT '',
`uploaded_by` INT NOT NULL DEFAULT 0,
`created` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_ticket` (`ticket_id`),
KEY `idx_reply` (`reply_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokosuite_ticket_automation` ( CREATE TABLE IF NOT EXISTS `#__mokosuite_ticket_automation` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`title` VARCHAR(255) NOT NULL, `title` VARCHAR(255) NOT NULL,
`trigger_event` VARCHAR(50) NOT NULL DEFAULT 'ticket_created', `trigger_event` VARCHAR(50) NOT NULL DEFAULT 'ticket_created',
`conditions` TEXT NOT NULL, `conditions` TEXT NOT NULL DEFAULT '[]',
`actions` TEXT NOT NULL, `actions` TEXT NOT NULL DEFAULT '[]',
`behavior` ENUM('append','always_new','skip_if_open') NOT NULL DEFAULT 'append',
`enabled` TINYINT NOT NULL DEFAULT 1, `enabled` TINYINT NOT NULL DEFAULT 1,
`ordering` INT NOT NULL DEFAULT 0, `ordering` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`), PRIMARY KEY (`id`)
KEY `idx_trigger` (`trigger_event`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokosuite_ticket_assignees` ( CREATE TABLE IF NOT EXISTS `#__mokosuite_ticket_assignees` (
@@ -526,19 +526,6 @@ class DisplayController extends BaseController
$this->jsonResponse(['success' => true, 'message' => 'Category deleted.']); $this->jsonResponse(['success' => true, 'message' => 'Category deleted.']);
} }
public function reorderCategory()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokosuite.tickets')) { $this->jsonForbidden(); }
$order = json_decode(Factory::getApplication()->getInput()->getRaw('order', '[]'), true);
if (!is_array($order)) { $this->jsonResponse(['success' => false, 'message' => 'Invalid order']); return; }
$db = Factory::getDbo();
foreach ($order as $i => $id) {
$db->setQuery('UPDATE ' . $db->quoteName('#__mokosuite_ticket_categories') . ' SET ordering = ' . (int) $i . ' WHERE id = ' . (int) $id)->execute();
}
$this->jsonResponse(['success' => true, 'message' => 'Order saved.']);
}
public function saveCanned() public function saveCanned()
{ {
Session::checkToken() or die(Text::_('JINVALID_TOKEN')); Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
@@ -566,83 +553,6 @@ class DisplayController extends BaseController
$this->jsonResponse(['success' => true, 'message' => 'Canned response deleted.']); $this->jsonResponse(['success' => true, 'message' => 'Canned response deleted.']);
} }
public function reorderCanned()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokosuite.tickets')) { $this->jsonForbidden(); }
$order = json_decode(Factory::getApplication()->getInput()->getRaw('order', '[]'), true);
if (!is_array($order)) { $this->jsonResponse(['success' => false, 'message' => 'Invalid order']); return; }
$db = Factory::getDbo();
foreach ($order as $i => $id) {
$db->setQuery('UPDATE ' . $db->quoteName('#__mokosuite_ticket_canned') . ' SET ordering = ' . (int) $i . ' WHERE id = ' . (int) $id)->execute();
}
$this->jsonResponse(['success' => true, 'message' => 'Order saved.']);
}
public function uploadAttachment()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokosuite.tickets')) { $this->jsonForbidden(); }
$input = Factory::getApplication()->getInput();
$ticketId = $input->getInt('ticket_id', 0);
$replyId = $input->getInt('reply_id', 0) ?: null;
if (!$ticketId) { $this->jsonResponse(['success' => false, 'message' => 'Missing ticket_id']); return; }
$files = $input->files->get('attachments', [], 'raw');
if (empty($files) || empty($files['name'])) { $this->jsonResponse(['success' => false, 'message' => 'No files uploaded']); return; }
$saved = \Moko\Component\MokoSuite\Administrator\Service\AttachmentService::upload($ticketId, $replyId, $files);
$this->jsonResponse(['success' => true, 'message' => count($saved) . ' file(s) uploaded', 'count' => count($saved)]);
}
public function downloadAttachment()
{
if (!$this->checkAcl('mokosuite.tickets')) { $this->jsonForbidden(); }
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$db = Factory::getDbo();
$db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuite_ticket_attachments')->where('id = ' . $id));
$att = $db->loadObject();
if (!$att) { throw new \RuntimeException('Attachment not found', 404); }
$path = \Moko\Component\MokoSuite\Administrator\Service\AttachmentService::getAbsolutePath($att);
if (!file_exists($path)) { throw new \RuntimeException('File not found', 404); }
$app = Factory::getApplication();
$app->setHeader('Content-Type', $att->mimetype ?: 'application/octet-stream');
$app->setHeader('Content-Disposition', 'attachment; filename="' . $att->filename . '"');
$app->setHeader('Content-Length', (string) filesize($path));
$app->sendHeaders();
readfile($path);
$app->close();
}
public function deleteAttachment()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokosuite.tickets')) { $this->jsonForbidden(); }
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$ok = \Moko\Component\MokoSuite\Administrator\Service\AttachmentService::delete($id);
$this->jsonResponse(['success' => $ok, 'message' => $ok ? 'Attachment deleted' : 'Not found']);
}
public function rateTicket()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
$input = Factory::getApplication()->getInput();
$ticketId = $input->getInt('ticket_id', 0);
$rating = $input->getInt('rating', 0);
$feedback = $input->getString('feedback', '');
if (!$ticketId || $rating < 1 || $rating > 5) {
$this->jsonResponse(['success' => false, 'message' => 'Invalid rating (1-5)']);
return;
}
$db = Factory::getDbo();
$db->setQuery(
'UPDATE ' . $db->quoteName('#__mokosuite_tickets')
. ' SET satisfaction_rating = ' . $rating
. ', satisfaction_feedback = ' . $db->quote($feedback)
. ', satisfaction_rated_at = ' . $db->quote(Factory::getDate()->toSql())
. ' WHERE id = ' . $ticketId
)->execute();
$this->jsonResponse(['success' => true, 'message' => 'Thank you for your feedback!']);
}
public function saveAutomation() public function saveAutomation()
{ {
Session::checkToken() or die(Text::_('JINVALID_TOKEN')); Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
@@ -654,7 +564,6 @@ class DisplayController extends BaseController
'trigger_event' => $input->getString('trigger_event', 'ticket_created'), 'trigger_event' => $input->getString('trigger_event', 'ticket_created'),
'conditions' => $input->getRaw('conditions', '[]'), 'conditions' => $input->getRaw('conditions', '[]'),
'actions' => $input->getRaw('actions', '[]'), 'actions' => $input->getRaw('actions', '[]'),
'behavior' => $input->getString('behavior', 'append'),
'enabled' => 1, 'enabled' => 1,
'ordering' => 0, 'ordering' => 0,
]; ];
@@ -685,19 +594,6 @@ class DisplayController extends BaseController
$this->jsonResponse(['success' => true, 'message' => 'Rule updated.']); $this->jsonResponse(['success' => true, 'message' => 'Rule updated.']);
} }
public function reorderAutomation()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); }
$order = json_decode(Factory::getApplication()->getInput()->getRaw('order', '[]'), true);
if (!is_array($order)) { $this->jsonResponse(['success' => false, 'message' => 'Invalid order']); return; }
$db = Factory::getDbo();
foreach ($order as $i => $id) {
$db->setQuery('UPDATE ' . $db->quoteName('#__mokosuite_ticket_automation') . ' SET ordering = ' . (int) $i . ' WHERE id = ' . (int) $id)->execute();
}
$this->jsonResponse(['success' => true, 'message' => 'Order saved.']);
}
// ================================================================== // ==================================================================
// Settings Import/Export (#132) // Settings Import/Export (#132)
// ================================================================== // ==================================================================
@@ -1,177 +0,0 @@
<?php
/**
* @package MokoSuite
* @subpackage com_mokosuite
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuite\Administrator\Service;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Filesystem\File;
use Joomla\CMS\Filesystem\Folder;
use Joomla\CMS\Log\Log;
class AttachmentService
{
private const STORAGE_DIR = JPATH_ROOT . '/media/com_mokosuite/attachments';
private const ALLOWED_EXTENSIONS = [
'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg',
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'txt', 'rtf',
'zip', 'gz', 'tar',
];
private const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
/**
* Upload file(s) for a ticket or reply.
*
* @param int $ticketId Ticket ID
* @param int|null $replyId Reply ID (null for ticket-level attachments)
* @param array $files $_FILES array entry (single or multi)
* @return array Saved attachment records
*/
public static function upload(int $ticketId, ?int $replyId, array $files): array
{
$saved = [];
// Normalize single file to array format
if (!is_array($files['name'])) {
$files = [
'name' => [$files['name']],
'type' => [$files['type']],
'tmp_name' => [$files['tmp_name']],
'error' => [$files['error']],
'size' => [$files['size']],
];
}
$ticketDir = self::STORAGE_DIR . '/' . $ticketId;
if (!is_dir($ticketDir)) {
Folder::create($ticketDir);
}
$userId = (int) Factory::getUser()->id;
$db = Factory::getDbo();
for ($i = 0, $count = count($files['name']); $i < $count; $i++)
{
if ($files['error'][$i] !== UPLOAD_ERR_OK) {
continue;
}
$originalName = File::makeSafe($files['name'][$i]);
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
// Validate extension
if (!in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
Log::add("Attachment rejected: disallowed extension .{$ext}", Log::WARNING, 'mokosuite');
continue;
}
// Validate size
if ($files['size'][$i] > self::MAX_FILE_SIZE) {
Log::add("Attachment rejected: file too large ({$files['size'][$i]} bytes)", Log::WARNING, 'mokosuite');
continue;
}
// Generate unique filename to prevent overwrites
$storedName = uniqid('att_', true) . '.' . $ext;
$destPath = $ticketDir . '/' . $storedName;
if (!File::upload($files['tmp_name'][$i], $destPath)) {
Log::add("Attachment upload failed: {$originalName}", Log::ERROR, 'mokosuite');
continue;
}
$record = (object) [
'ticket_id' => $ticketId,
'reply_id' => $replyId,
'filename' => $originalName,
'filepath' => $ticketId . '/' . $storedName,
'filesize' => $files['size'][$i],
'mimetype' => $files['type'][$i],
'uploaded_by' => $userId,
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuite_ticket_attachments', $record, 'id');
$saved[] = $record;
}
return $saved;
}
/**
* Get attachments for a ticket.
*/
public static function getForTicket(int $ticketId): array
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select('a.*, u.name AS uploader_name')
->from($db->quoteName('#__mokosuite_ticket_attachments', 'a'))
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = a.uploaded_by')
->where($db->quoteName('a.ticket_id') . ' = ' . $ticketId)
->order('a.created ASC')
);
return $db->loadObjectList() ?: [];
}
/**
* Get the absolute filesystem path for an attachment.
*/
public static function getAbsolutePath(object $attachment): string
{
return self::STORAGE_DIR . '/' . $attachment->filepath;
}
/**
* Delete an attachment (file + DB record).
*/
public static function delete(int $attachmentId): bool
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select('*')
->from('#__mokosuite_ticket_attachments')
->where('id = ' . $attachmentId)
);
$att = $db->loadObject();
if (!$att) {
return false;
}
$path = self::STORAGE_DIR . '/' . $att->filepath;
if (file_exists($path)) {
File::delete($path);
}
$db->setQuery(
$db->getQuery(true)
->delete('#__mokosuite_ticket_attachments')
->where('id = ' . $attachmentId)
)->execute();
return true;
}
/**
* Format file size for display.
*/
public static function formatSize(int $bytes): string
{
if ($bytes < 1024) return $bytes . ' B';
if ($bytes < 1048576) return round($bytes / 1024, 1) . ' KB';
return round($bytes / 1048576, 1) . ' MB';
}
}
@@ -1,241 +0,0 @@
<?php
/**
* @package MokoSuite
* @subpackage com_mokosuite
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuite\Administrator\Service;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Log\Log;
/**
* Automation rule engine evaluates trigger/condition/action rules.
*
* Called from event hooks (system plugin, task plugin) whenever
* a triggering event occurs. Loads matching rules, checks conditions,
* and executes actions.
*
* @since 02.35.00
*/
class AutomationEngine
{
/**
* Fire all matching rules for a given trigger event.
*
* @param string $triggerEvent Event name (ticket_created, user_login, etc.)
* @param array $context Context data (ticket object, user data, etc.)
*/
public static function fire(string $triggerEvent, array $context = []): void
{
try
{
$rules = self::getActiveRules($triggerEvent);
foreach ($rules as $rule)
{
$conditions = json_decode($rule->conditions, true) ?: [];
$actions = json_decode($rule->actions, true) ?: [];
if (self::evaluateConditions($conditions, $context))
{
self::executeActions($actions, $rule, $context);
}
}
}
catch (\Throwable $e)
{
Log::add('Automation engine error: ' . $e->getMessage(), Log::ERROR, 'mokosuite');
}
}
/**
* Get active automation rules for a trigger event.
*/
private static function getActiveRules(string $event): array
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select('*')
->from('#__mokosuite_ticket_automation')
->where($db->quoteName('trigger_event') . ' = ' . $db->quote($event))
->where($db->quoteName('enabled') . ' = 1')
->order('ordering ASC')
);
return $db->loadObjectList() ?: [];
}
/**
* Evaluate all conditions (AND logic).
*/
private static function evaluateConditions(array $conditions, array $context): bool
{
foreach ($conditions as $c)
{
$field = $c['field'] ?? '';
$op = $c['op'] ?? 'eq';
$expected = $c['value'] ?? '';
$actual = $context[$field] ?? '';
switch ($op)
{
case 'eq': if ((string) $actual !== (string) $expected) return false; break;
case 'neq': if ((string) $actual === (string) $expected) return false; break;
case 'gt': if ((float) $actual <= (float) $expected) return false; break;
case 'lt': if ((float) $actual >= (float) $expected) return false; break;
case 'in':
$values = array_map('trim', explode(',', $expected));
if (!in_array((string) $actual, $values, true)) return false;
break;
case 'not_in':
$values = array_map('trim', explode(',', $expected));
if (in_array((string) $actual, $values, true)) return false;
break;
}
}
return true;
}
/**
* Execute actions for a matched rule.
*/
private static function executeActions(array $actions, object $rule, array $context): void
{
$db = Factory::getDbo();
$ticketId = (int) ($context['ticket_id'] ?? $context['id'] ?? 0);
foreach ($actions as $action)
{
$type = $action['type'] ?? '';
$value = $action['value'] ?? '';
try
{
switch ($type)
{
case 'set_status':
if ($ticketId) {
$db->setQuery("UPDATE {$db->quoteName('#__mokosuite_tickets')} SET status = {$db->quote($value)}, modified = {$db->quote(Factory::getDate()->toSql())} WHERE id = {$ticketId}")->execute();
}
break;
case 'set_priority':
if ($ticketId) {
$db->setQuery("UPDATE {$db->quoteName('#__mokosuite_tickets')} SET priority = {$db->quote($value)}, modified = {$db->quote(Factory::getDate()->toSql())} WHERE id = {$ticketId}")->execute();
}
break;
case 'assign':
if ($ticketId) {
$db->setQuery("UPDATE {$db->quoteName('#__mokosuite_tickets')} SET assigned_to = {$db->quote($value)}, modified = {$db->quote(Factory::getDate()->toSql())} WHERE id = {$ticketId}")->execute();
}
break;
case 'add_note':
if ($ticketId) {
$note = (object) [
'ticket_id' => $ticketId,
'user_id' => 0,
'body' => $value ?: '[Automation: ' . ($rule->title ?? '') . ']',
'is_internal' => 1,
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuite_ticket_replies', $note);
}
break;
case 'send_email':
NotificationService::securityAlert(
'automation',
'Automation: ' . ($rule->title ?? ''),
$value ?: 'Rule triggered for ticket #' . $ticketId
);
break;
case 'send_ntfy':
NotificationService::pushNtfySecurity(
'automation',
'Automation: ' . ($rule->title ?? ''),
$value ?: 'Rule triggered for ticket #' . $ticketId
);
break;
case 'close':
if ($ticketId) {
$db->setQuery("UPDATE {$db->quoteName('#__mokosuite_tickets')} SET status = 'closed', closed = {$db->quote(Factory::getDate()->toSql())}, modified = {$db->quote(Factory::getDate()->toSql())} WHERE id = {$ticketId}")->execute();
}
break;
case 'create_ticket':
self::createTicketFromAutomation($rule, $context, $value);
break;
}
}
catch (\Throwable $e)
{
Log::add("Automation action {$type} failed: " . $e->getMessage(), Log::WARNING, 'mokosuite');
}
}
}
/**
* Create a ticket from automation (with behavior: append/always_new/skip_if_open).
*/
private static function createTicketFromAutomation(object $rule, array $context, string $subject): void
{
$db = Factory::getDbo();
$behavior = $rule->behavior ?? 'append';
$userId = (int) ($context['user_id'] ?? 0);
$catId = (int) ($context['category_id'] ?? 0);
if ($behavior !== 'always_new' && $userId > 0)
{
// Check for existing open ticket
$query = $db->getQuery(true)
->select('id')
->from('#__mokosuite_tickets')
->where('created_by = ' . $userId)
->where("status NOT IN ('closed', 'resolved')");
if ($catId > 0) {
$query->where('category_id = ' . $catId);
}
$db->setQuery($query, 0, 1);
$existingId = (int) $db->loadResult();
if ($existingId > 0)
{
if ($behavior === 'skip_if_open') return;
// append — add reply to existing ticket
$reply = (object) [
'ticket_id' => $existingId,
'user_id' => 0,
'body' => $subject ?: '[Automation: ' . ($rule->title ?? '') . ']',
'is_internal' => 1,
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuite_ticket_replies', $reply);
return;
}
}
// Create new ticket
$ticket = (object) [
'subject' => $subject ?: 'Automation: ' . ($rule->title ?? ''),
'body' => $context['body'] ?? '',
'status' => 'open',
'priority' => $context['priority'] ?? 'normal',
'category_id' => $catId ?: null,
'created_by' => $userId,
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuite_tickets', $ticket, 'id');
}
}
@@ -70,9 +70,6 @@ class NotificationService
Log::add('Notification send failed to ' . $email . ': ' . $e->getMessage(), Log::WARNING, 'mokosuite'); Log::add('Notification send failed to ' . $email . ': ' . $e->getMessage(), Log::WARNING, 'mokosuite');
} }
} }
// Push notification via ntfy
self::pushNtfy($event, $ticket, $subject);
} }
catch (\Throwable $e) catch (\Throwable $e)
{ {
@@ -335,159 +332,6 @@ class NotificationService
} }
} }
// ==================================================================
// Ntfy Push Notifications (#205)
// ==================================================================
/**
* Send a push notification via ntfy for ticket events.
*/
private static function pushNtfy(string $event, object $ticket, string $title): void
{
$config = self::getNotificationConfig();
$ntfyEnabled = $config['ntfy_enabled'] ?? '0';
if (!$ntfyEnabled)
{
return;
}
$ntfyServer = rtrim($config['ntfy_server'] ?? 'https://ntfy.mokoconsulting.tech', '/');
$ntfyTopic = $config['ntfy_topic'] ?? 'mokosuite-tickets';
$ntfyToken = $config['ntfy_token'] ?? '';
$tagMap = [
'ticket_created' => 'ticket,new',
'ticket_replied' => 'speech_balloon',
'status_changed' => 'arrows_counterclockwise',
'ticket_assigned' => 'bust_in_silhouette',
];
$priorityMap = [
'ticket_created' => '4',
'ticket_replied' => '3',
'status_changed' => '3',
'ticket_assigned' => '3',
];
$siteUrl = rtrim(Uri::root(), '/');
$ticketUrl = $siteUrl . '/administrator/index.php?option=com_mokosuite&view=ticket&id=' . ($ticket->id ?? 0);
$message = self::buildNtfyMessage($event, $ticket);
$headers = [
'Title: ' . $title,
'Priority: ' . ($priorityMap[$event] ?? '3'),
'Tags: ' . ($tagMap[$event] ?? 'ticket'),
'Click: ' . $ticketUrl,
];
if ($ntfyToken !== '')
{
$headers[] = 'Authorization: Bearer ' . $ntfyToken;
}
$url = $ntfyServer . '/' . $ntfyTopic;
try
{
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $message);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode < 200 || $httpCode >= 300)
{
Log::add("Ntfy push failed (HTTP {$httpCode}) for event {$event}", Log::WARNING, 'mokosuite');
}
}
catch (\Throwable $e)
{
Log::add('Ntfy push error: ' . $e->getMessage(), Log::WARNING, 'mokosuite');
}
}
/**
* Build a short ntfy message body for ticket events.
*/
private static function buildNtfyMessage(string $event, object $ticket): string
{
$subject = $ticket->subject ?? 'Ticket #' . ($ticket->id ?? '?');
switch ($event)
{
case 'ticket_created':
$priority = ucfirst($ticket->priority ?? 'normal');
return "New ticket: {$subject}\nPriority: {$priority}";
case 'ticket_replied':
return "Reply on: {$subject}";
case 'status_changed':
$status = ucwords(str_replace('_', ' ', $ticket->status ?? ''));
return "Status → {$status}: {$subject}";
case 'ticket_assigned':
return "Assigned to you: {$subject}";
default:
return $subject;
}
}
/**
* Send a push notification via ntfy for security events.
*/
public static function pushNtfySecurity(string $event, string $title, string $body): void
{
$config = self::getNotificationConfig();
$ntfyEnabled = $config['ntfy_enabled'] ?? '0';
if (!$ntfyEnabled)
{
return;
}
$ntfyServer = rtrim($config['ntfy_server'] ?? 'https://ntfy.mokoconsulting.tech', '/');
$ntfyTopic = $config['ntfy_security_topic'] ?? $config['ntfy_topic'] ?? 'mokosuite-security';
$ntfyToken = $config['ntfy_token'] ?? '';
$headers = [
'Title: [Security] ' . $title,
'Priority: 5',
'Tags: warning,shield',
];
if ($ntfyToken !== '')
{
$headers[] = 'Authorization: Bearer ' . $ntfyToken;
}
$url = $ntfyServer . '/' . $ntfyTopic;
try
{
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
curl_exec($ch);
curl_close($ch);
}
catch (\Throwable $e)
{
Log::add('Ntfy security push error: ' . $e->getMessage(), Log::WARNING, 'mokosuite');
}
}
// ================================================================== // ==================================================================
// Security Event Notifications (#131) // Security Event Notifications (#131)
// ================================================================== // ==================================================================
@@ -563,9 +407,6 @@ class NotificationService
Log::add('Security alert send failed: ' . $e->getMessage(), Log::WARNING, 'mokosuite'); Log::add('Security alert send failed: ' . $e->getMessage(), Log::WARNING, 'mokosuite');
} }
} }
// Also push via ntfy
self::pushNtfySecurity($event, $subject, $body);
} }
catch (\Throwable $e) catch (\Throwable $e)
{ {
@@ -23,7 +23,6 @@ class HtmlView extends BaseHtmlView
protected $priorities = []; protected $priorities = [];
protected $customFields = []; protected $customFields = [];
protected $fieldValues = []; protected $fieldValues = [];
protected $attachments = [];
public function display($tpl = null) public function display($tpl = null)
{ {
@@ -44,9 +43,6 @@ class HtmlView extends BaseHtmlView
$this->fieldValues = $model->getFieldValues($id); $this->fieldValues = $model->getFieldValues($id);
} }
// Load attachments
$this->attachments = \Moko\Component\MokoSuite\Administrator\Service\AttachmentService::getForTicket($id);
if (!$this->ticket) if (!$this->ticket)
{ {
Factory::getApplication()->enqueueMessage('Ticket not found.', 'error'); Factory::getApplication()->enqueueMessage('Ticket not found.', 'error');
@@ -9,110 +9,81 @@ $token = Session::getFormToken();
$saveUrl = Route::_('index.php?option=com_mokosuite&task=display.saveAutomation&format=json'); $saveUrl = Route::_('index.php?option=com_mokosuite&task=display.saveAutomation&format=json');
$deleteUrl = Route::_('index.php?option=com_mokosuite&task=display.deleteAutomation&format=json'); $deleteUrl = Route::_('index.php?option=com_mokosuite&task=display.deleteAutomation&format=json');
$toggleUrl = Route::_('index.php?option=com_mokosuite&task=display.toggleAutomation&format=json'); $toggleUrl = Route::_('index.php?option=com_mokosuite&task=display.toggleAutomation&format=json');
$reorderUrl = Route::_('index.php?option=com_mokosuite&task=display.reorderAutomation&format=json');
$triggerLabels = [ $triggerLabels = ['ticket_created' => 'On Ticket Created', 'ticket_replied' => 'On Reply', 'status_changed' => 'On Status Change', 'scheduled' => 'Scheduled (Cron)'];
'ticket_created' => 'On Ticket Created',
'ticket_replied' => 'On Reply',
'status_changed' => 'On Status Change',
'ticket_assigned' => 'On Assignment',
'user_login' => 'On User Login',
'user_register' => 'On User Register',
'user_login_failed' => 'On Failed Login',
'content_save' => 'On Article Save',
'extension_install' => 'On Extension Install',
'scheduled' => 'Scheduled (Cron)',
];
$conditionFields = ['status', 'priority', 'category_id', 'assigned_to', 'sla_responded', 'age_hours'];
$conditionOps = ['eq' => '=', 'neq' => '≠', 'gt' => '>', 'lt' => '<', 'in' => 'in', 'not_in' => 'not in'];
$actionTypes = ['set_status', 'set_priority', 'assign', 'add_note', 'send_email', 'send_ntfy', 'close'];
?> ?>
<div id="mokosuite-automation"> <div id="mokosuite-automation">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<h4><?php echo count($rules); ?> Automation Rules</h4> <h4><?php echo count($rules); ?> Automation Rules</h4>
<button type="button" class="btn btn-primary btn-sm" onclick="openRuleModal(0)"> <button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newRuleModal">
<span class="icon-plus"></span> Add Rule <span class="icon-plus"></span> Add Rule
</button> </button>
</div> </div>
<div id="rules-list"> <?php foreach ($rules as $r): ?>
<?php foreach ($rules as $r): ?> <?php $conditions = json_decode($r->conditions, true) ?: []; $actions = json_decode($r->actions, true) ?: []; ?>
<?php $conditions = json_decode($r->conditions, true) ?: []; $actions = json_decode($r->actions, true) ?: []; ?> <div class="card mb-2 <?php echo !$r->enabled ? 'opacity-50' : ''; ?>" data-id="<?php echo $r->id; ?>">
<div class="card mb-2 rule-card <?php echo !$r->enabled ? 'opacity-50' : ''; ?>" data-id="<?php echo $r->id; ?>" draggable="true"> <div class="card-body py-2">
<div class="card-body py-2"> <div class="d-flex justify-content-between align-items-start">
<div class="d-flex justify-content-between align-items-start"> <div>
<div class="flex-grow-1" style="cursor:pointer;" onclick="openRuleModal(<?php echo $r->id; ?>)"> <div class="d-flex align-items-center gap-2">
<div class="d-flex align-items-center gap-2"> <div class="form-check form-switch">
<span class="icon-menu text-muted" style="cursor:grab;"></span> <input type="checkbox" class="form-check-input rule-toggle" data-id="<?php echo $r->id; ?>" <?php echo $r->enabled ? 'checked' : ''; ?>>
<div class="form-check form-switch" onclick="event.stopPropagation();">
<input type="checkbox" class="form-check-input rule-toggle" data-id="<?php echo $r->id; ?>" <?php echo $r->enabled ? 'checked' : ''; ?>>
</div>
<strong><?php echo htmlspecialchars($r->title); ?></strong>
<span class="badge bg-secondary"><?php echo $triggerLabels[$r->trigger_event] ?? $r->trigger_event; ?></span>
</div>
<div class="small text-muted mt-1 ms-4">
<?php if (!empty($conditions)): ?>
<span class="text-primary">IF</span>
<?php foreach ($conditions as $i => $c): ?>
<?php echo $i > 0 ? ' AND ' : ''; ?><code><?php echo htmlspecialchars($c['field'] ?? ''); ?></code> <?php echo $conditionOps[$c['op'] ?? ''] ?? $c['op'] ?? ''; ?> <em><?php echo htmlspecialchars($c['value'] ?? ''); ?></em>
<?php endforeach; ?>
<?php endif; ?>
<span class="text-success ms-1">THEN</span>
<?php foreach ($actions as $a): ?>
<code><?php echo htmlspecialchars($a['type'] ?? ''); ?></code><?php if (!empty($a['value'])): ?>=<em><?php echo htmlspecialchars(mb_substr($a['value'], 0, 30)); ?></em><?php endif; ?>
<?php endforeach; ?>
</div> </div>
<strong><?php echo htmlspecialchars($r->title); ?></strong>
<span class="badge bg-secondary"><?php echo $triggerLabels[$r->trigger_event] ?? $r->trigger_event; ?></span>
</div>
<div class="small text-muted mt-1">
<span class="text-primary">IF</span>
<?php foreach ($conditions as $i => $c): ?>
<?php echo $i > 0 ? ' AND ' : ''; ?><?php echo htmlspecialchars($c['field'] ?? ''); ?> <?php echo htmlspecialchars($c['op'] ?? ''); ?> <?php echo htmlspecialchars($c['value'] ?? ''); ?>
<?php endforeach; ?>
<span class="text-success ms-2">THEN</span>
<?php foreach ($actions as $a): ?>
<?php echo htmlspecialchars($a['type'] ?? ''); ?>=<?php echo htmlspecialchars(mb_substr($a['value'] ?? '', 0, 30)); ?>
<?php endforeach; ?>
</div> </div>
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-rule" data-id="<?php echo $r->id; ?>" onclick="event.stopPropagation();">
<span class="icon-trash"></span>
</button>
</div> </div>
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-rule" data-id="<?php echo $r->id; ?>">
<span class="icon-trash"></span>
</button>
</div> </div>
</div> </div>
<?php endforeach; ?>
<?php if (empty($rules)): ?>
<div class="alert alert-info">No automation rules. Click "Add Rule" to create one.</div>
<?php endif; ?>
</div> </div>
<?php endforeach; ?>
<?php if (empty($rules)): ?>
<div class="alert alert-info">No automation rules. Click "Add Rule" to create one.</div>
<?php endif; ?>
</div> </div>
<!-- Rule Modal --> <!-- New Rule Modal -->
<div class="modal fade" id="ruleModal" tabindex="-1"> <div class="modal fade" id="newRuleModal" tabindex="-1">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"><h5 id="ruleModalTitle">Add Automation Rule</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div> <div class="modal-header"><h5>Add Automation Rule</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body"> <div class="modal-body">
<input type="hidden" id="rule-id" value="0"> <div class="mb-3">
<div class="row mb-3"> <label class="form-label">Title</label>
<div class="col-5"> <input type="text" id="rule-title" class="form-control" required>
<label class="form-label">Title</label> </div>
<input type="text" id="rule-title" class="form-control" required> <div class="mb-3">
</div> <label class="form-label">Trigger</label>
<div class="col-4"> <select id="rule-trigger" class="form-select">
<label class="form-label">Trigger</label> <?php foreach ($triggerLabels as $k => $v): ?><option value="<?php echo $k; ?>"><?php echo $v; ?></option><?php endforeach; ?>
<select id="rule-trigger" class="form-select"> </select>
<?php foreach ($triggerLabels as $k => $v): ?><option value="<?php echo $k; ?>"><?php echo $v; ?></option><?php endforeach; ?> </div>
</select> <div class="mb-3">
</div> <label class="form-label">Conditions (JSON)</label>
<div class="col-3"> <textarea id="rule-conditions" class="form-control font-monospace" rows="3" placeholder='[{"field":"status","op":"eq","value":"resolved"}]'></textarea>
<label class="form-label">Behavior</label> <small class="text-muted">Fields: status, priority, category_id, assigned_to, sla_responded, age_hours. Ops: eq, neq, gt, lt, in, not_in</small>
<select id="rule-behavior" class="form-select"> </div>
<option value="append">Append to existing</option> <div class="mb-3">
<option value="always_new">Always new ticket</option> <label class="form-label">Actions (JSON)</label>
<option value="skip_if_open">Skip if open</option> <textarea id="rule-actions" class="form-control font-monospace" rows="3" placeholder='[{"type":"set_status","value":"closed"}]'></textarea>
</select> <small class="text-muted">Types: set_status, set_priority, assign, add_note, send_email</small>
</div>
</div> </div>
<label class="form-label">Conditions <small class="text-muted">(all must match)</small></label>
<div id="conditions-builder" class="mb-3"></div>
<button type="button" class="btn btn-sm btn-outline-secondary mb-3" onclick="addConditionRow()"><span class="icon-plus"></span> Add Condition</button>
<label class="form-label">Actions</label>
<div id="actions-builder" class="mb-3"></div>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="addActionRow()"><span class="icon-plus"></span> Add Action</button>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
@@ -124,174 +95,47 @@ $actionTypes = ['set_status', 'set_priority', 'assign', 'add_note', 'send_email'
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
var tokenKey = '<?php echo $token; ?>'; var token = '<?php echo $token; ?>';
var condFields = <?php echo json_encode($conditionFields); ?>;
var condOps = <?php echo json_encode($conditionOps); ?>;
var actTypes = <?php echo json_encode($actionTypes); ?>;
// Rule data store for editing // Save new rule
var ruleData = {};
<?php foreach ($rules as $r): ?>
ruleData[<?php echo $r->id; ?>] = {
title: <?php echo json_encode($r->title); ?>,
trigger_event: <?php echo json_encode($r->trigger_event); ?>,
behavior: <?php echo json_encode($r->behavior ?? 'append'); ?>,
conditions: <?php echo $r->conditions ?: '[]'; ?>,
actions: <?php echo $r->actions ?: '[]'; ?>
};
<?php endforeach; ?>
// ── Builder helpers ─────────────────────────────────────────
function makeSelect(cls, options, selected) {
var sel = document.createElement('select');
sel.className = 'form-select ' + cls;
options.forEach(function(o) {
var opt = document.createElement('option');
opt.value = o.value;
opt.textContent = o.label;
if (o.value === selected) opt.selected = true;
sel.appendChild(opt);
});
return sel;
}
function makeRemoveBtn() {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-outline-danger';
btn.innerHTML = '<span class="icon-minus"></span>';
btn.addEventListener('click', function() { this.parentNode.remove(); });
return btn;
}
window.addConditionRow = function(field, op, value) {
var div = document.createElement('div');
div.className = 'input-group input-group-sm mb-1';
div.appendChild(makeSelect('cond-field', condFields.map(function(f){return {value:f, label:f}}), field));
div.appendChild(makeSelect('cond-op', Object.keys(condOps).map(function(k){return {value:k, label:condOps[k]}}), op));
var inp = document.createElement('input');
inp.type = 'text'; inp.className = 'form-control cond-value'; inp.placeholder = 'value'; inp.value = value || '';
div.appendChild(inp);
div.appendChild(makeRemoveBtn());
document.getElementById('conditions-builder').appendChild(div);
};
window.addActionRow = function(type, value) {
var div = document.createElement('div');
div.className = 'input-group input-group-sm mb-1';
div.appendChild(makeSelect('act-type', actTypes.map(function(t){return {value:t, label:t}}), type));
var inp = document.createElement('input');
inp.type = 'text'; inp.className = 'form-control act-value'; inp.placeholder = 'value'; inp.value = value || '';
div.appendChild(inp);
div.appendChild(makeRemoveBtn());
document.getElementById('actions-builder').appendChild(div);
};
// ── Open modal ──────────────────────────────────────────────
window.openRuleModal = function(id) {
document.getElementById('rule-id').value = id;
document.getElementById('conditions-builder').innerHTML = '';
document.getElementById('actions-builder').innerHTML = '';
if (id > 0 && ruleData[id]) {
document.getElementById('ruleModalTitle').textContent = 'Edit Automation Rule';
document.getElementById('rule-title').value = ruleData[id].title;
document.getElementById('rule-trigger').value = ruleData[id].trigger_event;
document.getElementById('rule-behavior').value = ruleData[id].behavior || 'append';
ruleData[id].conditions.forEach(function(c) { addConditionRow(c.field, c.op, c.value); });
ruleData[id].actions.forEach(function(a) { addActionRow(a.type, a.value); });
} else {
document.getElementById('ruleModalTitle').textContent = 'Add Automation Rule';
document.getElementById('rule-title').value = '';
document.getElementById('rule-trigger').value = 'ticket_created';
document.getElementById('rule-behavior').value = 'append';
addConditionRow();
addActionRow();
}
new bootstrap.Modal(document.getElementById('ruleModal')).show();
};
// ── Save rule ───────────────────────────────────────────────
document.getElementById('btn-save-rule').addEventListener('click', function() { document.getElementById('btn-save-rule').addEventListener('click', function() {
var conditions = [];
document.querySelectorAll('#conditions-builder .input-group').forEach(function(row) {
var f = row.querySelector('.cond-field').value;
var o = row.querySelector('.cond-op').value;
var v = row.querySelector('.cond-value').value;
if (f && v) conditions.push({field:f, op:o, value:v});
});
var actions = [];
document.querySelectorAll('#actions-builder .input-group').forEach(function(row) {
var t = row.querySelector('.act-type').value;
var v = row.querySelector('.act-value').value;
if (t) actions.push({type:t, value:v});
});
var fd = new FormData(); var fd = new FormData();
fd.append('id', document.getElementById('rule-id').value); fd.append('id', '0');
fd.append('title', document.getElementById('rule-title').value); fd.append('title', document.getElementById('rule-title').value);
fd.append('trigger_event', document.getElementById('rule-trigger').value); fd.append('trigger_event', document.getElementById('rule-trigger').value);
fd.append('behavior', document.getElementById('rule-behavior').value); fd.append('conditions', document.getElementById('rule-conditions').value || '[]');
fd.append('conditions', JSON.stringify(conditions)); fd.append('actions', document.getElementById('rule-actions').value || '[]');
fd.append('actions', JSON.stringify(actions)); fd.append(token, '1');
fd.append(tokenKey, '1');
fetch('<?php echo $saveUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}}) fetch('<?php echo $saveUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()}) .then(function(r){return r.json()})
.then(function(d){ if (d.success) location.reload(); else Joomla.renderMessages({error:[d.message]}); }); .then(function(d){ if (d.success) location.reload(); else Joomla.renderMessages({error:[d.message]}); });
}); });
// ── Toggle ────────────────────────────────────────────────── // Toggle rule
document.querySelectorAll('.rule-toggle').forEach(function(cb) { document.querySelectorAll('.rule-toggle').forEach(function(cb) {
cb.addEventListener('change', function() { cb.addEventListener('change', function() {
var fd = new FormData(); var fd = new FormData();
fd.append('id', this.dataset.id); fd.append('id', this.dataset.id);
fd.append('enabled', this.checked ? '1' : '0'); fd.append('enabled', this.checked ? '1' : '0');
fd.append(tokenKey, '1'); fd.append(token, '1');
fetch('<?php echo $toggleUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}}) fetch('<?php echo $toggleUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()}) .then(function(r){return r.json()})
.then(function(d){ if (!d.success) Joomla.renderMessages({error:[d.message]}); else this.closest('.card').classList.toggle('opacity-50', !this.checked); }.bind(this)); .then(function(d){ if (!d.success) Joomla.renderMessages({error:[d.message]}); });
}); });
}); });
// ── Delete ────────────────────────────────────────────────── // Delete rule
document.querySelectorAll('.btn-delete-rule').forEach(function(btn) { document.querySelectorAll('.btn-delete-rule').forEach(function(btn) {
btn.addEventListener('click', function() { btn.addEventListener('click', function() {
if (!confirm('Delete this rule?')) return; if (!confirm('Delete this rule?')) return;
var card = this.closest('.card'); var card = this.closest('.card');
var fd = new FormData(); var fd = new FormData();
fd.append('id', this.dataset.id); fd.append('id', this.dataset.id);
fd.append(tokenKey, '1'); fd.append(token, '1');
fetch('<?php echo $deleteUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}}) fetch('<?php echo $deleteUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()}) .then(function(r){return r.json()})
.then(function(d){ if (d.success) card.remove(); else Joomla.renderMessages({error:[d.message]}); }); .then(function(d){ if (d.success) card.remove(); else Joomla.renderMessages({error:[d.message]}); });
}); });
}); });
// ── Drag-and-drop reorder ───────────────────────────────────
var list = document.getElementById('rules-list');
var dragCard = null;
list.addEventListener('dragstart', function(e) {
dragCard = e.target.closest('.rule-card');
if (dragCard) dragCard.style.opacity = '0.5';
});
list.addEventListener('dragend', function() { if (dragCard) dragCard.style.opacity = ''; dragCard = null; });
list.addEventListener('dragover', function(e) {
e.preventDefault();
var target = e.target.closest('.rule-card');
if (target && target !== dragCard) {
var rect = target.getBoundingClientRect();
if ((e.clientY - rect.top) > rect.height / 2) target.parentNode.insertBefore(dragCard, target.nextSibling);
else target.parentNode.insertBefore(dragCard, target);
}
});
list.addEventListener('drop', function(e) {
e.preventDefault();
var ids = [];
document.querySelectorAll('.rule-card').forEach(function(c) { ids.push(c.dataset.id); });
var fd = new FormData();
fd.append('order', JSON.stringify(ids));
fd.append(tokenKey, '1');
fetch('<?php echo $reorderUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}});
});
}); });
</script> </script>
@@ -9,71 +9,43 @@ $categories = $this->categories;
$token = Session::getFormToken(); $token = Session::getFormToken();
$saveUrl = Route::_('index.php?option=com_mokosuite&task=display.saveCanned&format=json'); $saveUrl = Route::_('index.php?option=com_mokosuite&task=display.saveCanned&format=json');
$deleteUrl = Route::_('index.php?option=com_mokosuite&task=display.deleteCanned&format=json'); $deleteUrl = Route::_('index.php?option=com_mokosuite&task=display.deleteCanned&format=json');
$reorderUrl = Route::_('index.php?option=com_mokosuite&task=display.reorderCanned&format=json');
// Build category map for filter display
$catMap = [0 => 'All Categories'];
foreach ($categories as $cat)
{
$catMap[$cat->id] = $cat->title;
}
?> ?>
<div id="mokosuite-canned"> <div id="mokosuite-canned">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex align-items-center gap-3"> <h4><?php echo count($responses); ?> Canned Responses</h4>
<h4 class="mb-0"><?php echo count($responses); ?> Canned Responses</h4> <button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newCannedModal">
<select id="canned-filter-category" class="form-select form-select-sm" style="width:auto;">
<option value="">All Categories</option>
<?php foreach ($categories as $cat): ?>
<option value="<?php echo $cat->id; ?>"><?php echo htmlspecialchars($cat->title); ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="button" class="btn btn-primary btn-sm" onclick="openCannedModal(0)">
<span class="icon-plus"></span> Add Response <span class="icon-plus"></span> Add Response
</button> </button>
</div> </div>
<div id="canned-list"> <?php foreach ($responses as $r): ?>
<?php foreach ($responses as $r): ?> <div class="card mb-2" data-id="<?php echo $r->id; ?>">
<div class="card mb-2 canned-card" data-id="<?php echo $r->id; ?>" data-category="<?php echo (int) $r->category_id; ?>" style="cursor:grab;"> <div class="card-body py-2">
<div class="card-body py-2"> <div class="d-flex justify-content-between align-items-start">
<div class="d-flex justify-content-between align-items-start"> <div>
<div class="flex-grow-1" style="cursor:pointer;" onclick="openCannedModal(<?php echo $r->id; ?>)"> <strong><?php echo htmlspecialchars($r->title); ?></strong>
<div class="d-flex align-items-center gap-2"> <p class="text-muted small mb-0 mt-1"><?php echo htmlspecialchars(mb_substr($r->body, 0, 150)); ?></p>
<span class="icon-menu text-muted" style="cursor:grab;" title="Drag to reorder"></span>
<strong><?php echo htmlspecialchars($r->title); ?></strong>
<?php if (!empty($r->category_id) && isset($catMap[$r->category_id])): ?>
<span class="badge bg-secondary"><?php echo htmlspecialchars($catMap[$r->category_id]); ?></span>
<?php endif; ?>
</div>
<p class="text-muted small mb-0 mt-1 ms-4"><?php echo htmlspecialchars(mb_substr(strip_tags($r->body), 0, 150)); ?></p>
</div>
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-canned" data-id="<?php echo $r->id; ?>">
<span class="icon-trash"></span>
</button>
</div> </div>
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-canned" data-id="<?php echo $r->id; ?>">
<span class="icon-trash"></span>
</button>
</div> </div>
</div> </div>
<?php endforeach; ?>
<?php if (empty($responses)): ?>
<div class="alert alert-info" id="canned-empty">No canned responses yet. Click "Add Response" to create one.</div>
<?php endif; ?>
</div> </div>
<?php endforeach; ?>
<?php if (empty($responses)): ?>
<div class="alert alert-info">No canned responses yet. Click "Add Response" to create one.</div>
<?php endif; ?>
</div> </div>
<!-- Canned Response Modal (create + edit) --> <!-- New Canned Modal -->
<div class="modal fade" id="cannedModal" tabindex="-1"> <div class="modal fade" id="newCannedModal" tabindex="-1">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header"><h5>Add Canned Response</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<h5 id="cannedModalTitle">Add Canned Response</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body"> <div class="modal-body">
<input type="hidden" id="canned-id" value="0">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Title</label> <label class="form-label">Title</label>
<input type="text" id="canned-title" class="form-control" required> <input type="text" id="canned-title" class="form-control" required>
@@ -81,7 +53,7 @@ foreach ($categories as $cat)
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Category (optional)</label> <label class="form-label">Category (optional)</label>
<select id="canned-category" class="form-select"> <select id="canned-category" class="form-select">
<option value="">No category</option> <option value="">All categories</option>
<?php foreach ($categories as $cat): ?> <?php foreach ($categories as $cat): ?>
<option value="<?php echo $cat->id; ?>"><?php echo htmlspecialchars($cat->title); ?></option> <option value="<?php echo $cat->id; ?>"><?php echo htmlspecialchars($cat->title); ?></option>
<?php endforeach; ?> <?php endforeach; ?>
@@ -89,7 +61,7 @@ foreach ($categories as $cat)
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Response Text</label> <label class="form-label">Response Text</label>
<textarea id="canned-body" class="form-control" rows="8" required></textarea> <textarea id="canned-body" class="form-control" rows="6" required></textarea>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
@@ -102,46 +74,15 @@ foreach ($categories as $cat)
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
var tokenKey = '<?php echo $token; ?>'; var token = '<?php echo $token; ?>';
// ── Response data store (for edit modal) ────────────────────
var responseData = {};
<?php foreach ($responses as $r): ?>
responseData[<?php echo $r->id; ?>] = {
title: <?php echo json_encode($r->title); ?>,
body: <?php echo json_encode($r->body); ?>,
category_id: <?php echo json_encode($r->category_id ?? ''); ?>
};
<?php endforeach; ?>
// ── Open modal for create (id=0) or edit ────────────────────
window.openCannedModal = function(id) {
document.getElementById('canned-id').value = id;
if (id > 0 && responseData[id]) {
document.getElementById('cannedModalTitle').textContent = 'Edit Canned Response';
document.getElementById('canned-title').value = responseData[id].title;
document.getElementById('canned-body').value = responseData[id].body;
document.getElementById('canned-category').value = responseData[id].category_id || '';
} else {
document.getElementById('cannedModalTitle').textContent = 'Add Canned Response';
document.getElementById('canned-title').value = '';
document.getElementById('canned-body').value = '';
document.getElementById('canned-category').value = '';
}
new bootstrap.Modal(document.getElementById('cannedModal')).show();
};
// ── Save (create or update) ─────────────────────────────────
document.getElementById('btn-save-canned').addEventListener('click', function() { document.getElementById('btn-save-canned').addEventListener('click', function() {
var title = document.getElementById('canned-title').value.trim();
if (!title) { Joomla.renderMessages({error:['Title is required']}); return; }
var fd = new FormData(); var fd = new FormData();
fd.append('id', document.getElementById('canned-id').value); fd.append('id', '0');
fd.append('title', title); fd.append('title', document.getElementById('canned-title').value);
fd.append('body', document.getElementById('canned-body').value); fd.append('body', document.getElementById('canned-body').value);
fd.append('category_id', document.getElementById('canned-category').value); fd.append('category_id', document.getElementById('canned-category').value);
fd.append(tokenKey, '1'); fd.append(token, '1');
fetch('<?php echo $saveUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}}) fetch('<?php echo $saveUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()}) .then(function(r){return r.json()})
.then(function(d){ .then(function(d){
@@ -150,78 +91,17 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
}); });
// ── Delete ──────────────────────────────────────────────────
document.querySelectorAll('.btn-delete-canned').forEach(function(btn) { document.querySelectorAll('.btn-delete-canned').forEach(function(btn) {
btn.addEventListener('click', function(e) { btn.addEventListener('click', function() {
e.stopPropagation();
if (!confirm('Delete this canned response?')) return; if (!confirm('Delete this canned response?')) return;
var card = this.closest('.card'); var card = this.closest('.card');
var fd = new FormData(); var fd = new FormData();
fd.append('id', this.dataset.id); fd.append('id', this.dataset.id);
fd.append(tokenKey, '1'); fd.append(token, '1');
fetch('<?php echo $deleteUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}}) fetch('<?php echo $deleteUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()}) .then(function(r){return r.json()})
.then(function(d){ if (d.success) card.remove(); else Joomla.renderMessages({error:[d.message]}); }); .then(function(d){ if (d.success) card.remove(); else Joomla.renderMessages({error:[d.message]}); });
}); });
}); });
// ── Category filter ─────────────────────────────────────────
document.getElementById('canned-filter-category').addEventListener('change', function() {
var catId = this.value;
document.querySelectorAll('.canned-card').forEach(function(card) {
if (!catId || card.dataset.category === catId) {
card.style.display = '';
} else {
card.style.display = 'none';
}
});
});
// ── Drag-and-drop reorder ───────────────────────────────────
var list = document.getElementById('canned-list');
var dragCard = null;
list.addEventListener('dragstart', function(e) {
dragCard = e.target.closest('.canned-card');
if (dragCard) {
dragCard.style.opacity = '0.5';
e.dataTransfer.effectAllowed = 'move';
}
});
list.addEventListener('dragend', function() {
if (dragCard) dragCard.style.opacity = '';
dragCard = null;
});
list.addEventListener('dragover', function(e) {
e.preventDefault();
var target = e.target.closest('.canned-card');
if (target && target !== dragCard) {
var rect = target.getBoundingClientRect();
var after = (e.clientY - rect.top) > rect.height / 2;
if (after) {
target.parentNode.insertBefore(dragCard, target.nextSibling);
} else {
target.parentNode.insertBefore(dragCard, target);
}
}
});
list.addEventListener('drop', function(e) {
e.preventDefault();
// Persist new order
var ids = [];
document.querySelectorAll('.canned-card').forEach(function(c) { ids.push(c.dataset.id); });
var fd = new FormData();
fd.append('order', JSON.stringify(ids));
fd.append(tokenKey, '1');
fetch('<?php echo $reorderUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}});
});
// Make cards draggable
document.querySelectorAll('.canned-card').forEach(function(card) {
card.setAttribute('draggable', 'true');
});
}); });
</script> </script>
@@ -9,7 +9,6 @@ $users = $this->users;
$token = Session::getFormToken(); $token = Session::getFormToken();
$saveUrl = Route::_('index.php?option=com_mokosuite&task=display.saveCategory&format=json'); $saveUrl = Route::_('index.php?option=com_mokosuite&task=display.saveCategory&format=json');
$deleteUrl = Route::_('index.php?option=com_mokosuite&task=display.deleteCategory&format=json'); $deleteUrl = Route::_('index.php?option=com_mokosuite&task=display.deleteCategory&format=json');
$reorderUrl = Route::_('index.php?option=com_mokosuite&task=display.reorderCategory&format=json');
?> ?>
<div id="mokosuite-categories"> <div id="mokosuite-categories">
@@ -23,11 +22,10 @@ $reorderUrl = Route::_('index.php?option=com_mokosuite&task=display.reorderCateg
<div class="card"> <div class="card">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped mb-0" id="cat-table"> <table class="table table-striped mb-0" id="cat-table">
<thead><tr><th style="width:30px"></th><th>Title</th><th>SLA Response</th><th>SLA Resolution</th><th>Auto-Assign</th><th>Active</th><th></th></tr></thead> <thead><tr><th>Title</th><th>SLA Response</th><th>SLA Resolution</th><th>Auto-Assign</th><th>Active</th><th></th></tr></thead>
<tbody> <tbody>
<?php foreach ($categories as $c): ?> <?php foreach ($categories as $c): ?>
<tr data-id="<?php echo $c->id; ?>" draggable="true"> <tr data-id="<?php echo $c->id; ?>">
<td><span class="icon-menu text-muted" style="cursor:grab;"></span></td>
<td><input type="text" class="form-control form-control-sm cat-field" data-field="title" value="<?php echo htmlspecialchars($c->title); ?>"></td> <td><input type="text" class="form-control form-control-sm cat-field" data-field="title" value="<?php echo htmlspecialchars($c->title); ?>"></td>
<td><input type="number" class="form-control form-control-sm cat-field" data-field="sla_response_minutes" value="<?php echo $c->sla_response_minutes; ?>" style="width:80px"> min</td> <td><input type="number" class="form-control form-control-sm cat-field" data-field="sla_response_minutes" value="<?php echo $c->sla_response_minutes; ?>" style="width:80px"> min</td>
<td><input type="number" class="form-control form-control-sm cat-field" data-field="sla_resolution_minutes" value="<?php echo $c->sla_resolution_minutes; ?>" style="width:80px"> min</td> <td><input type="number" class="form-control form-control-sm cat-field" data-field="sla_resolution_minutes" value="<?php echo $c->sla_resolution_minutes; ?>" style="width:80px"> min</td>
@@ -124,39 +122,5 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
tr.querySelector('input').focus(); tr.querySelector('input').focus();
}); });
// Drag-and-drop reorder
var tbody = document.querySelector('#cat-table tbody');
var dragRow = null;
tbody.addEventListener('dragstart', function(e) {
dragRow = e.target.closest('tr');
if (dragRow) dragRow.style.opacity = '0.5';
});
tbody.addEventListener('dragend', function() {
if (dragRow) dragRow.style.opacity = '';
dragRow = null;
});
tbody.addEventListener('dragover', function(e) {
e.preventDefault();
var target = e.target.closest('tr');
if (target && target !== dragRow) {
var rect = target.getBoundingClientRect();
if ((e.clientY - rect.top) > rect.height / 2) {
target.parentNode.insertBefore(dragRow, target.nextSibling);
} else {
target.parentNode.insertBefore(dragRow, target);
}
}
});
tbody.addEventListener('drop', function(e) {
e.preventDefault();
var ids = [];
tbody.querySelectorAll('tr[data-id]').forEach(function(r) { if (r.dataset.id !== '0') ids.push(r.dataset.id); });
var fd = new FormData();
fd.append('order', JSON.stringify(ids));
fd.append(token, '1');
fetch('<?php echo $reorderUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}});
});
}); });
</script> </script>
@@ -8,17 +8,6 @@ use Joomla\CMS\Session\Session;
$t = $this->ticket; $t = $this->ticket;
$canned = $this->cannedResponses; $canned = $this->cannedResponses;
$token = Session::getFormToken(); $token = Session::getFormToken();
$attachments = $this->attachments;
$downloadUrl = Route::_('index.php?option=com_mokosuite&task=display.downloadAttachment');
$uploadUrl = Route::_('index.php?option=com_mokosuite&task=display.uploadAttachment&format=json');
$deleteAttUrl = Route::_('index.php?option=com_mokosuite&task=display.deleteAttachment&format=json');
// Group attachments by reply_id (null = ticket-level)
$attByReply = [];
foreach ($attachments as $att) {
$key = $att->reply_id ?? 0;
$attByReply[$key][] = $att;
}
$statuses = $this->statuses ?? []; $statuses = $this->statuses ?? [];
$priorities = $this->priorities ?? []; $priorities = $this->priorities ?? [];
@@ -36,21 +25,7 @@ $priorities = $this->priorities ?? [];
</div> </div>
<span class="badge bg-dark">Original</span> <span class="badge bg-dark">Original</span>
</div> </div>
<div class="card-body"> <div class="card-body"><?php echo nl2br($this->escape($t->body)); ?></div>
<?php echo nl2br($this->escape($t->body)); ?>
<?php if (!empty($attByReply[0])): ?>
<hr>
<div class="small">
<strong>Attachments:</strong>
<?php foreach ($attByReply[0] as $att): ?>
<a href="<?php echo $downloadUrl . '&id=' . $att->id; ?>" class="d-inline-block me-3">
<span class="icon-download"></span> <?php echo $this->escape($att->filename); ?>
<span class="text-muted">(<?php echo \Moko\Component\MokoSuite\Administrator\Service\AttachmentService::formatSize($att->filesize); ?>)</span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div> </div>
<!-- Replies --> <!-- Replies -->
@@ -65,21 +40,7 @@ $priorities = $this->priorities ?? [];
<span class="badge bg-warning text-dark">Internal Note</span> <span class="badge bg-warning text-dark">Internal Note</span>
<?php endif; ?> <?php endif; ?>
</div> </div>
<div class="card-body"> <div class="card-body"><?php echo nl2br($this->escape($reply->body)); ?></div>
<?php echo nl2br($this->escape($reply->body)); ?>
<?php if (!empty($attByReply[$reply->id])): ?>
<hr>
<div class="small">
<strong>Attachments:</strong>
<?php foreach ($attByReply[$reply->id] as $att): ?>
<a href="<?php echo $downloadUrl . '&id=' . $att->id; ?>" class="d-inline-block me-3">
<span class="icon-download"></span> <?php echo $this->escape($att->filename); ?>
<span class="text-muted">(<?php echo \Moko\Component\MokoSuite\Administrator\Service\AttachmentService::formatSize($att->filesize); ?>)</span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
@@ -98,10 +59,6 @@ $priorities = $this->priorities ?? [];
</div> </div>
<?php endif; ?> <?php endif; ?>
<textarea id="reply-body" class="form-control mb-2" rows="5" placeholder="Type your reply..."></textarea> <textarea id="reply-body" class="form-control mb-2" rows="5" placeholder="Type your reply..."></textarea>
<div class="mb-2">
<input type="file" id="reply-attachments" class="form-control form-control-sm" multiple
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.zip">
</div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="button" class="btn btn-primary" id="btn-reply" <button type="button" class="btn btn-primary" id="btn-reply"
data-url="<?php echo Route::_('index.php?option=com_mokosuite&task=display.addTicketReply&format=json'); ?>" data-url="<?php echo Route::_('index.php?option=com_mokosuite&task=display.addTicketReply&format=json'); ?>"
@@ -188,45 +145,6 @@ $priorities = $this->priorities ?? [];
</div> </div>
<?php endif; ?> <?php endif; ?>
<!-- Satisfaction Rating -->
<?php
$isClosed = in_array($t->status, ['resolved', 'closed'], true);
$hasRating = !empty($t->satisfaction_rating);
?>
<?php if ($hasRating): ?>
<div class="card mb-3">
<div class="card-header"><strong>Satisfaction</strong></div>
<div class="card-body text-center">
<div class="mb-1">
<?php for ($s = 1; $s <= 5; $s++): ?>
<span style="font-size:1.5rem;color:<?php echo $s <= $t->satisfaction_rating ? '#f5a623' : '#dee2e6'; ?>;">&#9733;</span>
<?php endfor; ?>
</div>
<div class="text-muted small"><?php echo $t->satisfaction_rating; ?>/5</div>
<?php if (!empty($t->satisfaction_feedback)): ?>
<p class="small mt-2 mb-0"><?php echo $this->escape($t->satisfaction_feedback); ?></p>
<?php endif; ?>
</div>
</div>
<?php elseif ($isClosed): ?>
<div class="card mb-3" id="rating-card">
<div class="card-header"><strong>Rate this Support</strong></div>
<div class="card-body text-center">
<div class="mb-2" id="star-rating">
<?php for ($s = 1; $s <= 5; $s++): ?>
<span class="star-btn" data-value="<?php echo $s; ?>" style="font-size:2rem;cursor:pointer;color:#dee2e6;">&#9733;</span>
<?php endfor; ?>
</div>
<textarea id="rating-feedback" class="form-control form-control-sm mb-2" rows="2" placeholder="Optional feedback..."></textarea>
<button type="button" class="btn btn-primary btn-sm" id="btn-rate"
data-url="<?php echo Route::_('index.php?option=com_mokosuite&task=display.rateTicket&format=json'); ?>"
data-ticket="<?php echo $t->id; ?>" data-token="<?php echo $token; ?>" disabled>
Submit Rating
</button>
</div>
</div>
<?php endif; ?>
<!-- Status actions --> <!-- Status actions -->
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header"><strong>Actions</strong></div> <div class="card-header"><strong>Actions</strong></div>
@@ -272,39 +190,22 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
} }
// Reply buttons (with attachment upload) // Reply buttons
document.querySelectorAll('#btn-reply, #btn-internal').forEach(function(btn) { document.querySelectorAll('#btn-reply, #btn-internal').forEach(function(btn) {
btn.addEventListener('click', function() { btn.addEventListener('click', function() {
var body = document.getElementById('reply-body').value.trim(); var body = document.getElementById('reply-body').value.trim();
var fileInput = document.getElementById('reply-attachments'); if (!body) return;
if (!body && (!fileInput || !fileInput.files.length)) return;
var el = this; var el = this;
el.disabled = true; el.disabled = true;
var fd = new FormData(); var fd = new FormData();
fd.append('ticket_id', el.dataset.ticket); fd.append('ticket_id', el.dataset.ticket);
fd.append('body', body || '(attachment)'); fd.append('body', body);
fd.append('is_internal', el.dataset.internal || '0'); fd.append('is_internal', el.dataset.internal || '0');
fd.append(el.dataset.token, '1'); fd.append(el.dataset.token, '1');
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}}) fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()}) .then(function(r){return r.json()})
.then(function(d){ .then(function(d){ if(d.success) location.reload(); else Joomla.renderMessages({error:[d.message]}); })
if (!d.success) { Joomla.renderMessages({error:[d.message]}); el.disabled = false; return; } .finally(function(){ el.disabled = false; });
// Upload attachments if any
if (fileInput && fileInput.files.length > 0) {
var afd = new FormData();
afd.append('ticket_id', el.dataset.ticket);
if (d.reply_id) afd.append('reply_id', d.reply_id);
for (var i = 0; i < fileInput.files.length; i++) {
afd.append('attachments[' + i + ']', fileInput.files[i]);
}
afd.append(el.dataset.token, '1');
fetch('<?php echo $uploadUrl; ?>', {method:'POST', body:afd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(){ location.reload(); });
} else {
location.reload();
}
})
.catch(function(){ el.disabled = false; });
}); });
}); });
@@ -323,42 +224,5 @@ document.addEventListener('DOMContentLoaded', function() {
.finally(function(){ el.disabled = false; }); .finally(function(){ el.disabled = false; });
}); });
}); });
// Star rating
var selectedRating = 0;
document.querySelectorAll('.star-btn').forEach(function(star) {
star.addEventListener('mouseenter', function() {
var val = parseInt(this.dataset.value);
document.querySelectorAll('.star-btn').forEach(function(s) {
s.style.color = parseInt(s.dataset.value) <= val ? '#f5a623' : '#dee2e6';
});
});
star.addEventListener('mouseleave', function() {
document.querySelectorAll('.star-btn').forEach(function(s) {
s.style.color = parseInt(s.dataset.value) <= selectedRating ? '#f5a623' : '#dee2e6';
});
});
star.addEventListener('click', function() {
selectedRating = parseInt(this.dataset.value);
document.getElementById('btn-rate').disabled = false;
});
});
var rateBtn = document.getElementById('btn-rate');
if (rateBtn) {
rateBtn.addEventListener('click', function() {
if (!selectedRating) return;
var el = this;
el.disabled = true;
var fd = new FormData();
fd.append('ticket_id', el.dataset.ticket);
fd.append('rating', selectedRating);
fd.append('feedback', document.getElementById('rating-feedback').value);
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> </script>
@@ -1,261 +0,0 @@
<?php
/**
* @package MokoSuite
* @subpackage com_mokosuite
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoSuite\Api\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Controller\BaseController;
/**
* Helpdesk Tickets REST API controller.
*
* GET /api/index.php/v1/mokosuite/tickets - list tickets
* GET /api/index.php/v1/mokosuite/tickets/{id} - get single ticket with replies
* POST /api/index.php/v1/mokosuite/tickets - create ticket
* PATCH /api/index.php/v1/mokosuite/tickets/{id} - update ticket fields
* POST /api/index.php/v1/mokosuite/tickets/{id}/reply - add reply
*
* @since 02.35.00
*/
class TicketsController extends BaseController
{
/**
* GET /tickets list tickets with optional filters.
*/
public function displayList(): void
{
$this->requireAuth('core.manage', 'com_mokosuite');
$app = Factory::getApplication();
$db = Factory::getDbo();
$input = $app->getInput();
$query = $db->getQuery(true)
->select('t.*, s.title AS status_title, p.title AS priority_title, c.title AS category_title, u.name AS created_by_name')
->from($db->quoteName('#__mokosuite_tickets', 't'))
->leftJoin($db->quoteName('#__mokosuite_ticket_statuses', 's') . ' ON s.id = t.status_id')
->leftJoin($db->quoteName('#__mokosuite_ticket_priorities', 'p') . ' ON p.id = t.priority_id')
->leftJoin($db->quoteName('#__mokosuite_ticket_categories', 'c') . ' ON c.id = t.category_id')
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
->order('t.created DESC');
// Filters
$status = $input->getString('status', '');
if ($status) {
$query->where($db->quoteName('t.status') . ' = ' . $db->quote($status));
}
$categoryId = $input->getInt('category_id', 0);
if ($categoryId) {
$query->where($db->quoteName('t.category_id') . ' = ' . $categoryId);
}
$assignedTo = $input->getInt('assigned_to', 0);
if ($assignedTo) {
$query->where($db->quoteName('t.assigned_to') . ' = ' . $assignedTo);
}
$limit = min($input->getInt('limit', 25), 100);
$offset = $input->getInt('offset', 0);
$db->setQuery($query, $offset, $limit);
$tickets = $db->loadObjectList() ?: [];
// Total count
$countQuery = $db->getQuery(true)->select('COUNT(*)')->from('#__mokosuite_tickets');
$db->setQuery($countQuery);
$total = (int) $db->loadResult();
$this->sendJson(200, [
'tickets' => $tickets,
'total' => $total,
'limit' => $limit,
'offset' => $offset,
]);
}
/**
* GET /tickets/{id} single ticket with replies and attachments.
*/
public function displayItem(): void
{
$this->requireAuth('core.manage', 'com_mokosuite');
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$db = Factory::getDbo();
// Ticket
$db->setQuery(
$db->getQuery(true)
->select('t.*, s.title AS status_title, p.title AS priority_title, c.title AS category_title, u.name AS created_by_name')
->from($db->quoteName('#__mokosuite_tickets', 't'))
->leftJoin($db->quoteName('#__mokosuite_ticket_statuses', 's') . ' ON s.id = t.status_id')
->leftJoin($db->quoteName('#__mokosuite_ticket_priorities', 'p') . ' ON p.id = t.priority_id')
->leftJoin($db->quoteName('#__mokosuite_ticket_categories', 'c') . ' ON c.id = t.category_id')
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
->where('t.id = ' . $id)
);
$ticket = $db->loadObject();
if (!$ticket) {
$this->sendJson(404, ['error' => 'Ticket not found']);
return;
}
// Replies
$db->setQuery(
$db->getQuery(true)
->select('r.*, u.name AS user_name')
->from($db->quoteName('#__mokosuite_ticket_replies', 'r'))
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id')
->where('r.ticket_id = ' . $id)
->order('r.created ASC')
);
$ticket->replies = $db->loadObjectList() ?: [];
// Attachments
$ticket->attachments = \Moko\Component\MokoSuite\Administrator\Service\AttachmentService::getForTicket($id);
$this->sendJson(200, $ticket);
}
/**
* POST /tickets create a new ticket.
*/
public function create(): void
{
$this->requireAuth('core.manage', 'com_mokosuite');
$input = Factory::getApplication()->getInput();
$db = Factory::getDbo();
$subject = $input->getString('subject', '');
$body = $input->getRaw('body', '');
if (empty($subject)) {
$this->sendJson(400, ['error' => 'Subject is required']);
return;
}
$ticket = (object) [
'subject' => $subject,
'body' => $body,
'status' => 'open',
'status_id' => $input->getInt('status_id', 0) ?: null,
'priority' => $input->getString('priority', 'normal'),
'priority_id' => $input->getInt('priority_id', 0) ?: null,
'category_id' => $input->getInt('category_id', 0) ?: null,
'created_by' => (int) Factory::getUser()->id,
'assigned_to' => $input->getInt('assigned_to', 0) ?: null,
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuite_tickets', $ticket, 'id');
// Trigger notification
\Moko\Component\MokoSuite\Administrator\Service\NotificationService::notify('ticket_created', $ticket);
$this->sendJson(201, ['id' => (int) $ticket->id, 'message' => 'Ticket created']);
}
/**
* PATCH /tickets/{id} update ticket fields.
*/
public function update(): void
{
$this->requireAuth('core.manage', 'com_mokosuite');
$input = Factory::getApplication()->getInput();
$id = $input->getInt('id', 0);
$db = Factory::getDbo();
$fields = [];
$updatable = ['status', 'status_id', 'priority', 'priority_id', 'category_id', 'assigned_to'];
foreach ($updatable as $field) {
$value = $input->get($field, null, 'raw');
if ($value !== null) {
$fields[$field] = $value;
}
}
if (empty($fields)) {
$this->sendJson(400, ['error' => 'No fields to update']);
return;
}
$sets = [];
foreach ($fields as $k => $v) {
$sets[] = $db->quoteName($k) . ' = ' . $db->quote($v);
}
$sets[] = 'modified = ' . $db->quote(Factory::getDate()->toSql());
$db->setQuery('UPDATE ' . $db->quoteName('#__mokosuite_tickets') . ' SET ' . implode(', ', $sets) . ' WHERE id = ' . $id)->execute();
$this->sendJson(200, ['id' => $id, 'message' => 'Ticket updated', 'updated' => array_keys($fields)]);
}
/**
* POST /tickets/{id}/reply add a reply.
*/
public function reply(): void
{
$this->requireAuth('core.manage', 'com_mokosuite');
$input = Factory::getApplication()->getInput();
$ticketId = $input->getInt('id', 0);
$body = $input->getRaw('body', '');
if (!$ticketId || empty($body)) {
$this->sendJson(400, ['error' => 'ticket_id and body are required']);
return;
}
$db = Factory::getDbo();
$reply = (object) [
'ticket_id' => $ticketId,
'user_id' => (int) Factory::getUser()->id,
'body' => $body,
'is_internal' => $input->getInt('is_internal', 0),
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuite_ticket_replies', $reply, 'id');
// Notify
$db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuite_tickets')->where('id = ' . $ticketId));
$ticket = $db->loadObject();
if ($ticket) {
\Moko\Component\MokoSuite\Administrator\Service\NotificationService::notify('ticket_replied', $ticket, ['reply_body' => $body]);
}
$this->sendJson(201, ['reply_id' => (int) $reply->id, 'message' => 'Reply added']);
}
// ── Helpers ──────────────────────────────────────────────────
private function requireAuth(string $action, string $asset): void
{
$user = Factory::getUser();
if (!$user->authorise($action, $asset)) {
$this->sendJson(403, ['error' => 'Not authorized']);
}
}
private function sendJson(int $code, $payload): void
{
$app = Factory::getApplication();
$app->setHeader('Content-Type', 'application/json', true);
$app->setHeader('Status', (string) $code, true);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$app->close();
}
}
+1 -1
View File
@@ -20,7 +20,7 @@
<license>GPL-3.0-or-later</license> <license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl> <authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.77-dev</version> <version>02.34.50-dev</version>
<description>MokoSuite admin dashboard and REST API. Provides a control panel for managing MokoSuite feature plugins, site health monitoring, and remote management endpoints.</description> <description>MokoSuite admin dashboard and REST API. Provides a control panel for managing MokoSuite feature plugins, site health monitoring, and remote management endpoints.</description>
<namespace path="src">Moko\Component\MokoSuite</namespace> <namespace path="src">Moko\Component\MokoSuite</namespace>
@@ -7,7 +7,7 @@
<license>GPL-3.0-or-later</license> <license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl> <authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.77-dev</version> <version>02.34.50-dev</version>
<description>MOD_MOKOSUITE_CACHE_DESC</description> <description>MOD_MOKOSUITE_CACHE_DESC</description>
<namespace path="src">Moko\Module\MokoSuiteCache</namespace> <namespace path="src">Moko\Module\MokoSuiteCache</namespace>
@@ -16,16 +16,11 @@ $tempUrl = 'index.php?option=com_mokosuite&task=clearTemp&format=json';
?> ?>
<style> <style>
.mokosuite-cleaner { display:flex; align-items:center; gap:0; padding:0 0.25rem; line-height:2rem; } .mokosuite-cleaner { display:flex; align-items:center; gap:0; padding:0 0.25rem; }
.mokosuite-cleaner-label { font-size:0.8rem; color:var(--template-text-dark,#495057); white-space:nowrap; padding-inline-end:0.35rem; line-height:2rem; } .mokosuite-cleaner-label { font-size:0.8rem; color:var(--template-text-dark,#495057); white-space:nowrap; padding-inline-end:0.35rem; }
.mokosuite-cleaner-btn { cursor:pointer; padding:0.2rem 0.5rem; font-size:0.8rem; border-radius:3px; text-decoration:none; color:var(--template-text-dark,#495057); transition:background 0.15s; white-space:nowrap; line-height:2rem; } .mokosuite-cleaner-btn { cursor:pointer; padding:0.2rem 0.5rem; font-size:0.8rem; border-radius:3px; text-decoration:none; color:var(--template-text-dark,#495057); transition:background 0.15s; white-space:nowrap; }
.mokosuite-cleaner-btn:hover { background:rgba(0,0,0,0.08); color:var(--template-text-dark,#212529); text-decoration:none; } .mokosuite-cleaner-btn:hover { background:rgba(0,0,0,0.08); color:var(--template-text-dark,#212529); text-decoration:none; }
.mokosuite-cleaner-btn .icon-bolt, .mokosuite-cleaner-sep { color:var(--template-text-dark,#adb5bd); padding:0 0.1rem; font-size:0.8rem; }
.mokosuite-cleaner-btn .icon-trash,
.mokosuite-cleaner-btn .icon-check,
.mokosuite-cleaner-btn .icon-times,
.mokosuite-cleaner-btn .icon-spinner { display:inline-block; width:1em; text-align:center; vertical-align:middle; }
.mokosuite-cleaner-sep { color:var(--template-text-dark,#adb5bd); padding:0 0.1rem; font-size:0.8rem; line-height:2rem; }
</style> </style>
<div class="header-item-content mokosuite-cleaner"> <div class="header-item-content mokosuite-cleaner">
@@ -7,7 +7,7 @@
<license>GPL-3.0-or-later</license> <license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl> <authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.77-dev</version> <version>02.34.50-dev</version>
<description>MOD_MOKOSUITE_CATEGORIES_DESC</description> <description>MOD_MOKOSUITE_CATEGORIES_DESC</description>
<namespace path="src">Moko\Module\MokoSuiteCategories</namespace> <namespace path="src">Moko\Module\MokoSuiteCategories</namespace>
@@ -7,7 +7,7 @@
<license>GPL-3.0-or-later</license> <license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl> <authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.77-dev</version> <version>02.34.50-dev</version>
<description>MOD_MOKOSUITE_CPANEL_DESC</description> <description>MOD_MOKOSUITE_CPANEL_DESC</description>
<namespace path="src">Moko\Module\MokoSuiteCpanel</namespace> <namespace path="src">Moko\Module\MokoSuiteCpanel</namespace>
@@ -22,7 +22,7 @@ $healthOk = $healthOk ?? true;
$counts = $counts ?? (object) ['articles' => 0, 'users' => 0, 'extensions' => 0, 'updates' => 0]; $counts = $counts ?? (object) ['articles' => 0, 'users' => 0, 'extensions' => 0, 'updates' => 0];
$disk = $disk ?? (object) ['free_mb' => null, 'total_mb' => null]; $disk = $disk ?? (object) ['free_mb' => null, 'total_mb' => null];
$currentIp = $currentIp ?? ''; $currentIp = $currentIp ?? '';
$collapsed = $params->get('collapsed', 0); $collapsed = $params->get('collapsed', 1);
$showHealth = $params->get('show_health', 1); $showHealth = $params->get('show_health', 1);
$showStats = $params->get('show_stats', 1); $showStats = $params->get('show_stats', 1);
$showDisk = $params->get('show_disk', 1); $showDisk = $params->get('show_disk', 1);
@@ -7,7 +7,7 @@
<license>GPL-3.0-or-later</license> <license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl> <authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.77-dev</version> <version>02.34.50-dev</version>
<description>MokoSuite admin sidebar menu — renders a dedicated MokoSuite section in the admin menu before Joomla's default menu.</description> <description>MokoSuite admin sidebar menu — renders a dedicated MokoSuite section in the admin menu before Joomla's default menu.</description>
<namespace path="src">Moko\Module\MokoSuiteMenu</namespace> <namespace path="src">Moko\Module\MokoSuiteMenu</namespace>
@@ -2,9 +2,9 @@
/** /**
* MokoSuite Admin Sidebar Menu * MokoSuite Admin Sidebar Menu
* *
* Each installed Moko component gets its own top-level collapsible section. * Renders MokoSuite static views first, then auto-discovers installed
* com_mokosuitehq is always pinned first. com_mokosuite uses static views * Moko components from #__menu and renders their submenu items as
* as children. All other components auto-discover their submenu items. * nested MetisMenu collapsible sections.
*/ */
defined('_JEXEC') or die; defined('_JEXEC') or die;
@@ -17,8 +17,8 @@ $app = Factory::getApplication();
$currentOption = $app->getInput()->get('option', ''); $currentOption = $app->getInput()->get('option', '');
$currentView = $app->getInput()->get('view', ''); $currentView = $app->getInput()->get('view', '');
// ── Static views for com_mokosuite ────────────────────────────────── // ── Static MokoSuite views ────────────────────────────────────────────
$mokosuiteStaticViews = [ $mokosuiteItems = [
['icon' => 'icon-cogs', 'title' => 'Dashboard', 'link' => 'index.php?option=com_mokosuite'], ['icon' => 'icon-cogs', 'title' => 'Dashboard', 'link' => 'index.php?option=com_mokosuite'],
['icon' => 'fa-solid fa-handshake-angle', 'title' => 'Helpdesk', 'link' => 'index.php?option=com_mokosuite&view=tickets'], ['icon' => 'fa-solid fa-handshake-angle', 'title' => 'Helpdesk', 'link' => 'index.php?option=com_mokosuite&view=tickets'],
['icon' => 'icon-puzzle-piece', 'title' => 'Extensions', 'link' => 'index.php?option=com_mokosuite&view=extensions'], ['icon' => 'icon-puzzle-piece', 'title' => 'Extensions', 'link' => 'index.php?option=com_mokosuite&view=extensions'],
@@ -30,25 +30,27 @@ $mokosuiteStaticViews = [
['icon' => 'icon-power-off', 'title' => 'Feature Plugins', 'link' => 'index.php?option=com_plugins&filter[folder]=system&filter[search]=mokosuite'], ['icon' => 'icon-power-off', 'title' => 'Feature Plugins', 'link' => 'index.php?option=com_plugins&filter[folder]=system&filter[search]=mokosuite'],
]; ];
// ── Auto-discover all Moko components from #__menu ────────────────── // ── Auto-discover Moko component menus from #__menu ──────────────────
$mokoComponents = []; $mokoComponents = [];
try try
{ {
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
// Find all Moko component menu items (exclude com_mokosuite — handled above)
$db->setQuery( $db->setQuery(
"SELECT m.id, m.title, m.link, m.level, m.parent_id, m.img, e.element" "SELECT m.id, m.title, m.link, m.level, m.parent_id, m.img, e.element"
. " FROM " . $db->quoteName('#__menu') . " m" . " FROM " . $db->quoteName('#__menu') . " m"
. " LEFT JOIN " . $db->quoteName('#__extensions') . " e ON m.component_id = e.extension_id" . " LEFT JOIN " . $db->quoteName('#__extensions') . " e ON m.component_id = e.extension_id"
. " WHERE m.client_id = 1 AND m.level >= 1 AND m.published = 1" . " WHERE m.client_id = 1 AND m.level >= 1 AND m.published = 1"
. " AND e.element LIKE 'com_moko%'" . " AND e.element LIKE 'com_moko%'"
. " AND e.element != 'com_mokosuite'"
. " AND e.enabled = 1" . " AND e.enabled = 1"
. " ORDER BY e.element, m.level, m.lft" . " ORDER BY e.element, m.level, m.lft"
); );
$menuItems = $db->loadObjectList() ?: []; $menuItems = $db->loadObjectList() ?: [];
// Load language files for discovered components // Load sys.ini language files for discovered components
$lang = Factory::getLanguage(); $lang = Factory::getLanguage();
$loadedLangs = []; $loadedLangs = [];
foreach ($menuItems as $m) foreach ($menuItems as $m)
@@ -90,112 +92,100 @@ catch (\Throwable $e)
// Silent — menu works without auto-discovered components // Silent — menu works without auto-discovered components
} }
// Override com_mokosuite children with static views // ── Determine active state ───────────────────────────────────────────
if (isset($mokoComponents['com_mokosuite'])) $mokosuiteActive = ($currentOption === 'com_mokosuite');
{ $anyMokoActive = $mokosuiteActive;
$mokoComponents['com_mokosuite']['children'] = $mokosuiteStaticViews;
$mokoComponents['com_mokosuite']['icon'] = 'icon-shield-alt';
}
else
{
// com_mokosuite not in admin menu — add it manually
$mokoComponents['com_mokosuite'] = [
'id' => 0,
'title' => 'MokoSuite',
'link' => 'index.php?option=com_mokosuite',
'icon' => 'icon-shield-alt',
'element' => 'com_mokosuite',
'children' => $mokosuiteStaticViews,
];
}
// ── Sort: com_mokosuitehq first, then alphabetical by title ───────── foreach ($mokoComponents as $comp)
$hq = null;
$rest = [];
foreach ($mokoComponents as $key => $comp)
{ {
if ($key === 'com_mokosuitehq') $parsed = [];
parse_str(parse_url($comp['link'], PHP_URL_QUERY) ?? '', $parsed);
if (($parsed['option'] ?? '') === $currentOption)
{ {
$hq = $comp; $anyMokoActive = true;
}
else
{
$rest[$key] = $comp;
} }
} }
usort($rest, fn($a, $b) => strcasecmp($a['title'], $b['title'])); $topClass = 'item parent item-level-1' . ($anyMokoActive ? ' mm-active' : '');
$topCollapse = 'collapse-level-1 mm-collapse' . ($anyMokoActive ? ' mm-show' : '');
$sorted = [];
if ($hq !== null)
{
$sorted[] = $hq;
}
foreach ($rest as $comp)
{
$sorted[] = $comp;
}
?> ?>
<style> <style>
.sidebar-wrapper .mokosuite-ext-item > a { padding-inline-start: 1.5rem; } .sidebar-wrapper .item-level-1 > a { padding-inline-start: 1.5rem; }
.sidebar-wrapper .mokosuite-ext-child > a { padding-inline-start: 2.5rem; } .sidebar-wrapper .mokosuite-menu-item > a { padding-inline-start: 2rem; }
.sidebar-wrapper .mokosuite-menu-child > a { padding-inline-start: 2.5rem; }
</style> </style>
<ul class="nav flex-column main-nav"> <ul class="nav flex-column main-nav">
<?php foreach ($sorted as $comp): ?> <li class="<?php echo $topClass; ?>">
<?php <a class="has-arrow" href="#" aria-label="MokoSuite">
$compParsed = []; <span class="icon-shield-alt" aria-hidden="true"></span>
parse_str(parse_url($comp['link'], PHP_URL_QUERY) ?? '', $compParsed); <span class="sidebar-item-title">MokoSuite</span>
$compOption = $compParsed['option'] ?? '';
$compActive = ($compOption === $currentOption);
// For com_mokosuite static children, also check the plugins filter link
if (!$compActive && $comp['element'] === 'com_mokosuite' && $currentOption === 'com_plugins')
{
$compActive = true;
}
$hasChildren = !empty($comp['children']);
$liClass = 'item mokosuite-ext-item' . ($hasChildren ? ' parent item-level-1' : '') . ($compActive ? ' mm-active' : '');
$aClass = ($hasChildren ? 'has-arrow' : 'no-dropdown') . ($compActive ? ' mm-active' : '');
$childCollapse = 'collapse-level-1 mm-collapse' . ($compActive ? ' mm-show' : '');
?>
<li class="<?php echo $liClass; ?>">
<a class="<?php echo $aClass; ?>" href="<?php echo $hasChildren ? '#' : Route::_($comp['link']); ?>"<?php echo ($compActive && !$hasChildren) ? ' aria-current="page"' : ''; ?>>
<span class="<?php echo $comp['icon']; ?>" aria-hidden="true" style="display:inline-block!important;width:1.25em;text-align:center;margin-inline-end:0.4em;"></span>
<span class="sidebar-item-title"><?php echo $comp['title']; ?></span>
</a> </a>
<?php if ($hasChildren): ?> <ul class="<?php echo $topCollapse; ?>" style="padding-inline-start:0.5rem;">
<ul class="<?php echo $childCollapse; ?>" style="padding-inline-start:0.5rem;">
<?php foreach ($comp['children'] as $child): ?> <?php // ── MokoSuite static items ── ?>
<?php foreach ($mokosuiteItems as $item): ?>
<?php <?php
$childParsed = []; $active = false;
parse_str(parse_url($child['link'], PHP_URL_QUERY) ?? '', $childParsed); $parsed = [];
$childOption = $childParsed['option'] ?? ''; parse_str(parse_url($item['link'], PHP_URL_QUERY) ?? '', $parsed);
$childView = $childParsed['view'] ?? ''; if (($parsed['option'] ?? '') === $currentOption)
$childActive = false;
if ($childOption === $currentOption)
{ {
$childActive = empty($childView) $active = empty($parsed['view'])
? ($currentView === '' || $currentView === 'dashboard') ? ($currentView === '' || $currentView === 'dashboard')
: ($currentView === $childView); : ($currentView === ($parsed['view'] ?? ''));
} }
$liClass = 'item mokosuite-menu-item' . ($active ? ' mm-active' : '');
$childLiClass = 'item mokosuite-ext-child' . ($childActive ? ' mm-active' : ''); $aClass = 'no-dropdown' . ($active ? ' mm-active' : '');
$childAClass = 'no-dropdown' . ($childActive ? ' mm-active' : '');
?> ?>
<li class="<?php echo $childLiClass; ?>"> <li class="<?php echo $liClass; ?>">
<a class="<?php echo $childAClass; ?>" href="<?php echo Route::_($child['link']); ?>"<?php echo $childActive ? ' aria-current="page"' : ''; ?>> <a class="<?php echo $aClass; ?>" href="<?php echo Route::_($item['link']); ?>"<?php echo $active ? ' aria-current="page"' : ''; ?>>
<span class="<?php echo $child['icon']; ?>" aria-hidden="true" style="display:inline-block!important;width:1.25em;text-align:center;margin-inline-end:0.4em;"></span> <span class="<?php echo $item['icon']; ?>" aria-hidden="true" style="display:inline-block!important;width:1.25em;text-align:center;margin-inline-end:0.4em;"></span>
<span class="sidebar-item-title"><?php echo $child['title']; ?></span> <span class="sidebar-item-title"><?php echo $item['title']; ?></span>
</a> </a>
</li> </li>
<?php endforeach; ?> <?php endforeach; ?>
<?php // ── Auto-discovered Moko components with submenus ── ?>
<?php foreach ($mokoComponents as $comp): ?>
<?php
$compParsed = [];
parse_str(parse_url($comp['link'], PHP_URL_QUERY) ?? '', $compParsed);
$compActive = ($compParsed['option'] ?? '') === $currentOption;
$hasChildren = !empty($comp['children']);
$compLiClass = 'item mokosuite-menu-item' . ($hasChildren ? ' parent' : '') . ($compActive ? ' mm-active' : '');
$compAClass = ($hasChildren ? 'has-arrow' : 'no-dropdown') . ($compActive ? ' mm-active' : '');
$childCollapse = 'collapse-level-2 mm-collapse' . ($compActive ? ' mm-show' : '');
?>
<li class="<?php echo $compLiClass; ?>">
<a class="<?php echo $compAClass; ?>" href="<?php echo $hasChildren ? '#' : Route::_($comp['link']); ?>"<?php echo ($compActive && !$hasChildren) ? ' aria-current="page"' : ''; ?>>
<span class="<?php echo $comp['icon']; ?>" aria-hidden="true" style="display:inline-block!important;width:1.25em;text-align:center;margin-inline-end:0.4em;"></span>
<span class="sidebar-item-title"><?php echo $comp['title']; ?></span>
</a>
<?php if ($hasChildren): ?>
<ul class="<?php echo $childCollapse; ?>" style="padding-inline-start:0.75rem;">
<?php foreach ($comp['children'] as $child): ?>
<?php
$childParsed = [];
parse_str(parse_url($child['link'], PHP_URL_QUERY) ?? '', $childParsed);
$childActive = ($childParsed['option'] ?? '') === $currentOption
&& ($childParsed['view'] ?? '') === $currentView;
$childLiClass = 'item mokosuite-menu-child' . ($childActive ? ' mm-active' : '');
$childAClass = 'no-dropdown' . ($childActive ? ' mm-active' : '');
?>
<li class="<?php echo $childLiClass; ?>">
<a class="<?php echo $childAClass; ?>" href="<?php echo Route::_($child['link']); ?>"<?php echo $childActive ? ' aria-current="page"' : ''; ?>>
<span class="<?php echo $child['icon']; ?>" aria-hidden="true" style="display:inline-block!important;width:1.25em;text-align:center;margin-inline-end:0.4em;"></span>
<span class="sidebar-item-title"><?php echo $child['title']; ?></span>
</a>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul> </ul>
<?php endif; ?>
</li> </li>
<?php endforeach; ?>
</ul> </ul>
@@ -22,7 +22,7 @@
* DEFGROUP: Joomla.Plugin * DEFGROUP: Joomla.Plugin
* INGROUP: MokoSuite * INGROUP: MokoSuite
* REPO: https://github.com/mokoconsulting-tech/mokosuite * REPO: https://github.com/mokoconsulting-tech/mokosuite
* VERSION: 02.34.77 * VERSION: 02.34.50
* PATH: /src/Extension/MokoSuite.php * PATH: /src/Extension/MokoSuite.php
* NOTE: Core system plugin for MokoSuite admin tools suite * NOTE: Core system plugin for MokoSuite admin tools suite
*/ */
@@ -163,7 +163,6 @@ class MokoSuite extends CMSPlugin implements BootableExtensionInterface
{ {
$this->handleOneTimeLogin(); $this->handleOneTimeLogin();
$this->checkSetupRequired(); $this->checkSetupRequired();
$this->ensureAdminModulesActive();
} }
} }
@@ -2158,206 +2157,19 @@ class MokoSuite extends CMSPlugin implements BootableExtensionInterface
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Admin Module Self-Healing // Download Key Preservation
// ------------------------------------------------------------------ // ------------------------------------------------------------------
/** /**
* Ensure MokoSuite admin modules are published with correct positions. * Preserve download keys across Joomla extension updates.
* *
* Runs once per session to self-heal if modules were accidentally * Joomla's installer can wipe the extra_query column (which holds
* unpublished or had their position cleared. * download keys / dlid) when rebuilding or reinstalling update sites.
* This method keeps a backup of all non-empty extra_query values and
* restores any that get cleared.
*
* @return void
*
* @since 02.34.12
*/ */
private function ensureAdminModulesActive(): void
{
$session = \Joomla\CMS\Factory::getSession();
if ($session->get('mokosuite.modules_checked', false))
{
return;
}
$session->set('mokosuite.modules_checked', true);
$modules = [
'mod_mokosuite_cpanel' => ['position' => 'top', 'title' => 'MokoSuite', 'access' => 6, 'ordering' => 0],
'mod_mokosuite_menu' => ['position' => 'menu', 'title' => 'MokoSuite Menu', 'access' => 3, 'ordering' => 0],
'mod_mokosuite_cache' => ['position' => 'status', 'title' => 'MokoSuite Cache Cleaner', 'access' => 3, 'ordering' => 0],
];
try
{
$db = \Joomla\CMS\Factory::getDbo();
$app = \Joomla\CMS\Factory::getApplication();
foreach ($modules as $element => $config)
{
// Check if extension is installed
$db->setQuery(
$db->getQuery(true)
->select('extension_id')
->from('#__extensions')
->where('element = ' . $db->quote($element))
->where('type = ' . $db->quote('module'))
);
if (!(int) $db->loadResult()) continue;
// Find existing module instance
$db->setQuery(
$db->getQuery(true)
->select('id, published, position')
->from('#__modules')
->where('module = ' . $db->quote($element))
->where('client_id = 1')
->setLimit(1)
);
$mod = $db->loadObject();
$model = $app->bootComponent('com_modules')
->getMVCFactory()
->createModel('Module', 'Administrator', ['ignore_request' => true]);
if ($mod)
{
// Check if repair needed
$needsFix = (int) $mod->published !== 1 || $mod->position !== $config['position'];
if (!$needsFix)
{
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from('#__modules_menu')
->where('moduleid = ' . (int) $mod->id)
);
$needsFix = (int) $db->loadResult() === 0;
}
if ($needsFix)
{
$data = $model->getItem($mod->id)->getProperties();
$data['published'] = 1;
$data['position'] = $config['position'];
$data['ordering'] = $config['ordering'] ?? 0;
$data['assignment'] = 0;
$model->save($data);
}
}
else
{
// Module instance deleted — recreate it
$data = [
'title' => $config['title'],
'module' => $element,
'position' => $config['position'],
'published' => 1,
'access' => $config['access'],
'ordering' => $config['ordering'] ?? 0,
'showtitle' => 0,
'client_id' => 1,
'language' => '*',
'params' => '{}',
'assignment' => 0,
];
$model->save($data);
}
}
}
catch (\Throwable $e)
{
// Silent — don't break the admin if self-heal fails
}
}
// ------------------------------------------------------------------
// Automation Engine Event Hooks (#151)
// ------------------------------------------------------------------
/**
* Fire automation rules for user registration.
*/
public function onUserAfterSave($user, $isnew, $success, $msg): void
{
if (!$isnew || !$success) return;
class_exists(\Moko\Component\MokoSuite\Administrator\Service\AutomationEngine::class, true) && \Moko\Component\MokoSuite\Administrator\Service\AutomationEngine::fire('user_register', [
'user_id' => (int) ($user['id'] ?? 0),
'username' => $user['username'] ?? '',
'email' => $user['email'] ?? '',
'name' => $user['name'] ?? '',
]);
}
/**
* Fire automation rules on article save.
*/
public function onContentAfterSave($context, $article, $isNew): void
{
if ($context !== 'com_content.article') return;
class_exists(\Moko\Component\MokoSuite\Administrator\Service\AutomationEngine::class, true) && \Moko\Component\MokoSuite\Administrator\Service\AutomationEngine::fire('content_save', [
'article_id' => (int) ($article->id ?? 0),
'title' => $article->title ?? '',
'is_new' => $isNew ? '1' : '0',
'catid' => (int) ($article->catid ?? 0),
'user_id' => (int) ($article->modified_by ?? $article->created_by ?? 0),
]);
}
// ------------------------------------------------------------------
// Security Event Notifications (#147)
// ------------------------------------------------------------------
/**
* Notify on successful admin login.
*/
public function onUserAfterLogin($options): void
{
if (!($options['user'] ?? null)) return;
$user = $options['user'];
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$name = $user->username ?? $user->name ?? 'unknown';
// Fire automation for any login
class_exists(\Moko\Component\MokoSuite\Administrator\Service\AutomationEngine::class, true) && \Moko\Component\MokoSuite\Administrator\Service\AutomationEngine::fire('user_login', [
'user_id' => (int) ($user->id ?? 0),
'username' => $name,
'ip' => $ip,
'client' => $this->app->isClient('administrator') ? 'admin' : 'site',
]);
// Security notification for backend logins only
if (!$this->app->isClient('administrator')) return;
class_exists(\Moko\Component\MokoSuite\Administrator\Service\NotificationService::class, true) && \Moko\Component\MokoSuite\Administrator\Service\NotificationService::securityAlert(
'admin_login',
"Admin login: {$name}",
"User: {$name}\nIP: {$ip}\nTime: " . gmdate('Y-m-d H:i:s') . " UTC"
);
}
/**
* Track failed login attempts and notify after threshold.
*/
public function onUserLoginFailure($response): void
{
if (!$this->app->isClient('administrator')) return;
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$username = $response['username'] ?? 'unknown';
// Track in session — notify after 3 failures from same IP
$session = \Joomla\CMS\Factory::getSession();
$key = 'mokosuite.login_failures.' . md5($ip);
$count = (int) $session->get($key, 0) + 1;
$session->set($key, $count);
if ($count >= 3 && $count % 3 === 0)
{
class_exists(\Moko\Component\MokoSuite\Administrator\Service\NotificationService::class, true) && \Moko\Component\MokoSuite\Administrator\Service\NotificationService::securityAlert(
'login_failure',
"Failed login attempts: {$count} from {$ip}",
"Username: {$username}\nIP: {$ip}\nAttempts: {$count}\nTime: " . gmdate('Y-m-d H:i:s') . " UTC"
);
}
}
} }
@@ -8,7 +8,7 @@
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: Joomla.Plugin * DEFGROUP: Joomla.Plugin
* INGROUP: MokoSuite * INGROUP: MokoSuite
* VERSION: 02.34.77 * VERSION: 02.34.50
* PATH: /src/Field/CopyableTokenField.php * PATH: /src/Field/CopyableTokenField.php
* BRIEF: Read-only token field with a copy-to-clipboard button * BRIEF: Read-only token field with a copy-to-clipboard button
*/ */
@@ -30,7 +30,7 @@
<license>GNU General Public License version 3 or later; see LICENSE.md</license> <license>GNU General Public License version 3 or later; see LICENSE.md</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl> <authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.77-dev</version> <version>02.34.50-dev</version>
<description>MokoSuite core system plugin — coordinates feature plugins, heartbeat, health checks, and admin customizations.</description> <description>MokoSuite core system plugin — coordinates feature plugins, heartbeat, health checks, and admin customizations.</description>
<namespace path=".">Moko\Plugin\System\MokoSuite</namespace> <namespace path=".">Moko\Plugin\System\MokoSuite</namespace>
<scriptfile>script.php</scriptfile> <scriptfile>script.php</scriptfile>
@@ -22,7 +22,7 @@
* DEFGROUP: Joomla.Plugin * DEFGROUP: Joomla.Plugin
* INGROUP: MokoSuite * INGROUP: MokoSuite
* REPO: https://github.com/mokoconsulting-tech/mokosuite * REPO: https://github.com/mokoconsulting-tech/mokosuite
* VERSION: 02.34.77 * VERSION: 02.34.50
* PATH: /src/script.php * PATH: /src/script.php
* BRIEF: Installation script for MokoSuite plugin * BRIEF: Installation script for MokoSuite plugin
* NOTE: Handles installation, update, and uninstallation tasks including language override deployment * NOTE: Handles installation, update, and uninstallation tasks including language override deployment
@@ -22,7 +22,7 @@
* DEFGROUP: Joomla.Plugin * DEFGROUP: Joomla.Plugin
* INGROUP: MokoSuite * INGROUP: MokoSuite
* REPO: https://github.com/mokoconsulting-tech/mokosuite * REPO: https://github.com/mokoconsulting-tech/mokosuite
* VERSION: 02.34.77 * VERSION: 02.34.50
* PATH: /src/services/provider.php * PATH: /src/services/provider.php
* BRIEF: Service provider for dependency injection in Joomla 5.x * BRIEF: Service provider for dependency injection in Joomla 5.x
* NOTE: Registers the plugin with Joomla's DI container * NOTE: Registers the plugin with Joomla's DI container
@@ -1,29 +0,0 @@
; MokoSuite DB-IP Plugin
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GPL-3.0-or-later
; IP Geolocation by DB-IP — https://db-ip.com
PLG_SYSTEM_MOKOSUITE_DBIP="System - MokoSuite DB-IP"
PLG_SYSTEM_MOKOSUITE_DBIP_DESC="IP geolocation for MokoSuite using DB-IP Lite databases. Ships with country-level data; city-level data is downloaded from CDN or loaded from a local file."
PLG_SYSTEM_MOKOSUITE_DBIP_FIELDSET_BASIC="DB-IP Settings"
PLG_SYSTEM_MOKOSUITE_DBIP_FIELDSET_BASIC_DESC="Configure IP geolocation database source and level."
PLG_SYSTEM_MOKOSUITE_DBIP_SOURCE_LABEL="Database Source"
PLG_SYSTEM_MOKOSUITE_DBIP_SOURCE_DESC="CDN downloads the city database automatically from the configured URL. Local uses a MMDB file you provide on the server."
PLG_SYSTEM_MOKOSUITE_DBIP_SOURCE_CDN="CDN (auto-download)"
PLG_SYSTEM_MOKOSUITE_DBIP_SOURCE_LOCAL="Local file"
PLG_SYSTEM_MOKOSUITE_DBIP_DATABASE_LEVEL_LABEL="Database Level"
PLG_SYSTEM_MOKOSUITE_DBIP_DATABASE_LEVEL_DESC="Country is bundled (~8 MB). City provides region, city, and coordinates but requires a separate download (~125 MB)."
PLG_SYSTEM_MOKOSUITE_DBIP_DATABASE_COUNTRY="Country (bundled)"
PLG_SYSTEM_MOKOSUITE_DBIP_DATABASE_CITY="City (remote download)"
PLG_SYSTEM_MOKOSUITE_DBIP_AUTO_UPDATE_LABEL="Auto-Update Database"
PLG_SYSTEM_MOKOSUITE_DBIP_AUTO_UPDATE_DESC="Automatically download the latest city database monthly when an admin visits the backend."
PLG_SYSTEM_MOKOSUITE_DBIP_CDN_URL_LABEL="CDN Download URL"
PLG_SYSTEM_MOKOSUITE_DBIP_CDN_URL_DESC="URL to download the city-level MMDB file. Default points to the MokoConsulting geoip-data repository."
PLG_SYSTEM_MOKOSUITE_DBIP_LOCAL_PATH_LABEL="Local MMDB Path"
PLG_SYSTEM_MOKOSUITE_DBIP_LOCAL_PATH_DESC="Absolute path to a DB-IP MMDB file on the server (e.g. /home/user/dbip-city-lite.mmdb)."
@@ -1,6 +0,0 @@
; MokoSuite DB-IP Plugin (system strings)
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GPL-3.0-or-later
PLG_SYSTEM_MOKOSUITE_DBIP="System - MokoSuite DB-IP"
PLG_SYSTEM_MOKOSUITE_DBIP_DESC="IP geolocation for MokoSuite using DB-IP Lite databases."
@@ -1,404 +0,0 @@
<?php
declare(strict_types=1);
namespace MaxMind\Db;
use MaxMind\Db\Reader\Decoder;
use MaxMind\Db\Reader\InvalidDatabaseException;
use MaxMind\Db\Reader\Metadata;
use MaxMind\Db\Reader\Util;
/**
* Instances of this class provide a reader for the MaxMind DB format. IP
* addresses can be looked up using the get method.
*/
class Reader
{
/**
* @var int
*/
private static $DATA_SECTION_SEPARATOR_SIZE = 16;
/**
* @var string
*/
private static $METADATA_START_MARKER = "\xAB\xCD\xEFMaxMind.com";
/**
* @var int<0, max>
*/
private static $METADATA_START_MARKER_LENGTH = 14;
/**
* @var int
*/
private static $METADATA_MAX_SIZE = 131072; // 128 * 1024 = 128KiB
/**
* @var Decoder
*/
private $decoder;
/**
* @var resource
*/
private $fileHandle;
/**
* @var int
*/
private $fileSize;
/**
* @var int
*/
private $ipV4Start;
/**
* @var Metadata
*/
private $metadata;
/**
* Constructs a Reader for the MaxMind DB format. The file passed to it must
* be a valid MaxMind DB file such as a DBIP database file.
*
* @param string $database the MaxMind DB file to use
*
* @throws \InvalidArgumentException for invalid database path or unknown arguments
* @throws InvalidDatabaseException
* if the database is invalid or there is an error reading
* from it
*/
public function __construct(string $database)
{
if (\func_num_args() !== 1) {
throw new \ArgumentCountError(
\sprintf('%s() expects exactly 1 parameter, %d given', __METHOD__, \func_num_args())
);
}
if (is_dir($database)) {
// This matches the error that the C extension throws.
throw new InvalidDatabaseException(
"Error opening database file ($database). Is this a valid MaxMind DB file?"
);
}
$fileHandle = @fopen($database, 'rb');
if ($fileHandle === false) {
throw new \InvalidArgumentException(
"The file \"$database\" does not exist or is not readable."
);
}
$this->fileHandle = $fileHandle;
$fstat = fstat($fileHandle);
if ($fstat === false) {
throw new \UnexpectedValueException(
"Error determining the size of \"$database\"."
);
}
$this->fileSize = $fstat['size'];
$start = $this->findMetadataStart($database);
$metadataDecoder = new Decoder($this->fileHandle, $start);
[$metadataArray] = $metadataDecoder->decode($start);
$this->metadata = new Metadata($metadataArray);
$this->decoder = new Decoder(
$this->fileHandle,
$this->metadata->searchTreeSize + self::$DATA_SECTION_SEPARATOR_SIZE
);
$this->ipV4Start = $this->ipV4StartNode();
}
/**
* Retrieves the record for the IP address.
*
* @param string $ipAddress the IP address to look up
*
* @throws \BadMethodCallException if this method is called on a closed database
* @throws \InvalidArgumentException if something other than a single IP address is passed to the method
* @throws InvalidDatabaseException
* if the database is invalid or there is an error reading
* from it
*
* @return mixed the record for the IP address
*/
public function get(string $ipAddress)
{
if (\func_num_args() !== 1) {
throw new \ArgumentCountError(
\sprintf('%s() expects exactly 1 parameter, %d given', __METHOD__, \func_num_args())
);
}
[$record] = $this->getWithPrefixLen($ipAddress);
return $record;
}
/**
* Retrieves the record for the IP address and its associated network prefix length.
*
* @param string $ipAddress the IP address to look up
*
* @throws \BadMethodCallException if this method is called on a closed database
* @throws \InvalidArgumentException if something other than a single IP address is passed to the method
* @throws InvalidDatabaseException
* if the database is invalid or there is an error reading
* from it
*
* @return array{0:mixed, 1:int} an array where the first element is the record and the
* second the network prefix length for the record
*/
public function getWithPrefixLen(string $ipAddress): array
{
if (\func_num_args() !== 1) {
throw new \ArgumentCountError(
\sprintf('%s() expects exactly 1 parameter, %d given', __METHOD__, \func_num_args())
);
}
if (!\is_resource($this->fileHandle)) {
throw new \BadMethodCallException(
'Attempt to read from a closed MaxMind DB.'
);
}
[$pointer, $prefixLen] = $this->findAddressInTree($ipAddress);
if ($pointer === 0) {
return [null, $prefixLen];
}
return [$this->resolveDataPointer($pointer), $prefixLen];
}
/**
* @return array{0:int, 1:int}
*/
private function findAddressInTree(string $ipAddress): array
{
$packedAddr = @inet_pton($ipAddress);
if ($packedAddr === false) {
throw new \InvalidArgumentException(
"The value \"$ipAddress\" is not a valid IP address."
);
}
$rawAddress = unpack('C*', $packedAddr);
if ($rawAddress === false) {
throw new InvalidDatabaseException(
'Could not unpack the unsigned char of the packed in_addr representation.'
);
}
$bitCount = \count($rawAddress) * 8;
// The first node of the tree is always node 0, at the beginning of the
// value
$node = 0;
$metadata = $this->metadata;
// Check if we are looking up an IPv4 address in an IPv6 tree. If this
// is the case, we can skip over the first 96 nodes.
if ($metadata->ipVersion === 6) {
if ($bitCount === 32) {
$node = $this->ipV4Start;
}
} elseif ($metadata->ipVersion === 4 && $bitCount === 128) {
throw new \InvalidArgumentException(
"Error looking up $ipAddress. You attempted to look up an"
. ' IPv6 address in an IPv4-only database.'
);
}
$nodeCount = $metadata->nodeCount;
for ($i = 0; $i < $bitCount && $node < $nodeCount; ++$i) {
$tempBit = 0xFF & $rawAddress[($i >> 3) + 1];
$bit = 1 & ($tempBit >> 7 - ($i % 8));
$node = $this->readNode($node, $bit);
}
if ($node === $nodeCount) {
// Record is empty
return [0, $i];
}
if ($node > $nodeCount) {
// Record is a data pointer
return [$node, $i];
}
throw new InvalidDatabaseException(
'Invalid or corrupt database. Maximum search depth reached without finding a leaf node'
);
}
private function ipV4StartNode(): int
{
// If we have an IPv4 database, the start node is the first node
if ($this->metadata->ipVersion === 4) {
return 0;
}
$node = 0;
for ($i = 0; $i < 96 && $node < $this->metadata->nodeCount; ++$i) {
$node = $this->readNode($node, 0);
}
return $node;
}
private function readNode(int $nodeNumber, int $index): int
{
$baseOffset = $nodeNumber * $this->metadata->nodeByteSize;
switch ($this->metadata->recordSize) {
case 24:
$bytes = Util::read($this->fileHandle, $baseOffset + $index * 3, 3);
$rc = unpack('N', "\x00" . $bytes);
if ($rc === false) {
throw new InvalidDatabaseException(
'Could not unpack the unsigned long of the node.'
);
}
[, $node] = $rc;
return $node;
case 28:
$bytes = Util::read($this->fileHandle, $baseOffset + 3 * $index, 4);
if ($index === 0) {
$middle = (0xF0 & \ord($bytes[3])) >> 4;
} else {
$middle = 0x0F & \ord($bytes[0]);
}
$rc = unpack('N', \chr($middle) . substr($bytes, $index, 3));
if ($rc === false) {
throw new InvalidDatabaseException(
'Could not unpack the unsigned long of the node.'
);
}
[, $node] = $rc;
return $node;
case 32:
$bytes = Util::read($this->fileHandle, $baseOffset + $index * 4, 4);
$rc = unpack('N', $bytes);
if ($rc === false) {
throw new InvalidDatabaseException(
'Could not unpack the unsigned long of the node.'
);
}
[, $node] = $rc;
return $node;
default:
throw new InvalidDatabaseException(
'Unknown record size: '
. $this->metadata->recordSize
);
}
}
/**
* @return mixed
*/
private function resolveDataPointer(int $pointer)
{
$resolved = $pointer - $this->metadata->nodeCount
+ $this->metadata->searchTreeSize;
if ($resolved >= $this->fileSize) {
throw new InvalidDatabaseException(
"The MaxMind DB file's search tree is corrupt"
);
}
[$data] = $this->decoder->decode($resolved);
return $data;
}
/*
* This is an extremely naive but reasonably readable implementation. There
* are much faster algorithms (e.g., Boyer-Moore) for this if speed is ever
* an issue, but I suspect it won't be.
*/
private function findMetadataStart(string $filename): int
{
$handle = $this->fileHandle;
$fileSize = $this->fileSize;
$marker = self::$METADATA_START_MARKER;
$markerLength = self::$METADATA_START_MARKER_LENGTH;
$minStart = $fileSize - min(self::$METADATA_MAX_SIZE, $fileSize);
for ($offset = $fileSize - $markerLength; $offset >= $minStart; --$offset) {
if (fseek($handle, $offset) !== 0) {
break;
}
$value = fread($handle, $markerLength);
if ($value === $marker) {
return $offset + $markerLength;
}
}
throw new InvalidDatabaseException(
"Error opening database file ($filename). "
. 'Is this a valid MaxMind DB file?'
);
}
/**
* @throws \InvalidArgumentException if arguments are passed to the method
* @throws \BadMethodCallException if the database has been closed
*
* @return Metadata object for the database
*/
public function metadata(): Metadata
{
if (\func_num_args()) {
throw new \ArgumentCountError(
\sprintf('%s() expects exactly 0 parameters, %d given', __METHOD__, \func_num_args())
);
}
// Not technically required, but this makes it consistent with
// C extension and it allows us to change our implementation later.
if (!\is_resource($this->fileHandle)) {
throw new \BadMethodCallException(
'Attempt to read from a closed MaxMind DB.'
);
}
return clone $this->metadata;
}
/**
* Closes the MaxMind DB and returns resources to the system.
*
* @throws \Exception
* if an I/O error occurs
*/
public function close(): void
{
if (\func_num_args()) {
throw new \ArgumentCountError(
\sprintf('%s() expects exactly 0 parameters, %d given', __METHOD__, \func_num_args())
);
}
if (!\is_resource($this->fileHandle)) {
throw new \BadMethodCallException(
'Attempt to close a closed MaxMind DB.'
);
}
fclose($this->fileHandle);
}
}
@@ -1,452 +0,0 @@
<?php
declare(strict_types=1);
namespace MaxMind\Db\Reader;
// @codingStandardsIgnoreLine
class Decoder
{
/**
* @var resource
*/
private $fileStream;
/**
* @var int
*/
private $pointerBase;
/**
* This is only used for unit testing.
*
* @var bool
*/
private $pointerTestHack;
/**
* @var bool
*/
private $switchByteOrder;
private const _EXTENDED = 0;
private const _POINTER = 1;
private const _UTF8_STRING = 2;
private const _DOUBLE = 3;
private const _BYTES = 4;
private const _UINT16 = 5;
private const _UINT32 = 6;
private const _MAP = 7;
private const _INT32 = 8;
private const _UINT64 = 9;
private const _UINT128 = 10;
private const _ARRAY = 11;
// 12 is the container type
// 13 is the end marker type
private const _BOOLEAN = 14;
private const _FLOAT = 15;
/**
* @param resource $fileStream
*/
public function __construct(
$fileStream,
int $pointerBase = 0,
bool $pointerTestHack = false
) {
$this->fileStream = $fileStream;
$this->pointerBase = $pointerBase;
$this->pointerTestHack = $pointerTestHack;
$this->switchByteOrder = $this->isPlatformLittleEndian();
}
/**
* @return array<mixed>
*/
public function decode(int $offset): array
{
$ctrlByte = \ord(Util::read($this->fileStream, $offset, 1));
++$offset;
$type = $ctrlByte >> 5;
// Pointers are a special case, we don't read the next $size bytes, we
// use the size to determine the length of the pointer and then follow
// it.
if ($type === self::_POINTER) {
[$pointer, $offset] = $this->decodePointer($ctrlByte, $offset);
// for unit testing
if ($this->pointerTestHack) {
return [$pointer];
}
[$result] = $this->decode($pointer);
return [$result, $offset];
}
if ($type === self::_EXTENDED) {
$nextByte = \ord(Util::read($this->fileStream, $offset, 1));
$type = $nextByte + 7;
if ($type < 8) {
throw new InvalidDatabaseException(
'Something went horribly wrong in the decoder. An extended type '
. 'resolved to a type number < 8 ('
. $type
. ')'
);
}
++$offset;
}
[$size, $offset] = $this->sizeFromCtrlByte($ctrlByte, $offset);
return $this->decodeByType($type, $offset, $size);
}
/**
* @param int<0, max> $size
*
* @return array{0:mixed, 1:int}
*/
private function decodeByType(int $type, int $offset, int $size): array
{
switch ($type) {
case self::_MAP:
return $this->decodeMap($size, $offset);
case self::_ARRAY:
return $this->decodeArray($size, $offset);
case self::_BOOLEAN:
return [$this->decodeBoolean($size), $offset];
}
$newOffset = $offset + $size;
$bytes = Util::read($this->fileStream, $offset, $size);
switch ($type) {
case self::_BYTES:
case self::_UTF8_STRING:
return [$bytes, $newOffset];
case self::_DOUBLE:
$this->verifySize(8, $size);
return [$this->decodeDouble($bytes), $newOffset];
case self::_FLOAT:
$this->verifySize(4, $size);
return [$this->decodeFloat($bytes), $newOffset];
case self::_INT32:
return [$this->decodeInt32($bytes, $size), $newOffset];
case self::_UINT16:
case self::_UINT32:
case self::_UINT64:
case self::_UINT128:
return [$this->decodeUint($bytes, $size), $newOffset];
default:
throw new InvalidDatabaseException(
'Unknown or unexpected type: ' . $type
);
}
}
private function verifySize(int $expected, int $actual): void
{
if ($expected !== $actual) {
throw new InvalidDatabaseException(
"The MaxMind DB file's data section contains bad data (unknown data type or corrupt data)"
);
}
}
/**
* @return array{0:array<mixed>, 1:int}
*/
private function decodeArray(int $size, int $offset): array
{
$array = [];
for ($i = 0; $i < $size; ++$i) {
[$value, $offset] = $this->decode($offset);
$array[] = $value;
}
return [$array, $offset];
}
private function decodeBoolean(int $size): bool
{
return $size !== 0;
}
private function decodeDouble(string $bytes): float
{
// This assumes IEEE 754 doubles, but most (all?) modern platforms
// use them.
$rc = unpack('E', $bytes);
if ($rc === false) {
throw new InvalidDatabaseException(
'Could not unpack a double value from the given bytes.'
);
}
[, $double] = $rc;
return $double;
}
private function decodeFloat(string $bytes): float
{
// This assumes IEEE 754 floats, but most (all?) modern platforms
// use them.
$rc = unpack('G', $bytes);
if ($rc === false) {
throw new InvalidDatabaseException(
'Could not unpack a float value from the given bytes.'
);
}
[, $float] = $rc;
return $float;
}
private function decodeInt32(string $bytes, int $size): int
{
switch ($size) {
case 0:
return 0;
case 1:
case 2:
case 3:
$bytes = str_pad($bytes, 4, "\x00", \STR_PAD_LEFT);
break;
case 4:
break;
default:
throw new InvalidDatabaseException(
"The MaxMind DB file's data section contains bad data (unknown data type or corrupt data)"
);
}
$rc = unpack('l', $this->maybeSwitchByteOrder($bytes));
if ($rc === false) {
throw new InvalidDatabaseException(
'Could not unpack a 32bit integer value from the given bytes.'
);
}
[, $int] = $rc;
return $int;
}
/**
* @return array{0:array<string, mixed>, 1:int}
*/
private function decodeMap(int $size, int $offset): array
{
$map = [];
for ($i = 0; $i < $size; ++$i) {
[$key, $offset] = $this->decode($offset);
[$value, $offset] = $this->decode($offset);
$map[$key] = $value;
}
return [$map, $offset];
}
/**
* @return array{0:int, 1:int}
*/
private function decodePointer(int $ctrlByte, int $offset): array
{
$pointerSize = (($ctrlByte >> 3) & 0x3) + 1;
$buffer = Util::read($this->fileStream, $offset, $pointerSize);
$offset += $pointerSize;
switch ($pointerSize) {
case 1:
$packed = \chr($ctrlByte & 0x7) . $buffer;
$rc = unpack('n', $packed);
if ($rc === false) {
throw new InvalidDatabaseException(
'Could not unpack an unsigned short value from the given bytes (pointerSize is 1).'
);
}
[, $pointer] = $rc;
$pointer += $this->pointerBase;
break;
case 2:
$packed = "\x00" . \chr($ctrlByte & 0x7) . $buffer;
$rc = unpack('N', $packed);
if ($rc === false) {
throw new InvalidDatabaseException(
'Could not unpack an unsigned long value from the given bytes (pointerSize is 2).'
);
}
[, $pointer] = $rc;
$pointer += $this->pointerBase + 2048;
break;
case 3:
$packed = \chr($ctrlByte & 0x7) . $buffer;
// It is safe to use 'N' here, even on 32 bit machines as the
// first bit is 0.
$rc = unpack('N', $packed);
if ($rc === false) {
throw new InvalidDatabaseException(
'Could not unpack an unsigned long value from the given bytes (pointerSize is 3).'
);
}
[, $pointer] = $rc;
$pointer += $this->pointerBase + 526336;
break;
case 4:
// We cannot use unpack here as we might overflow on 32 bit
// machines
$pointerOffset = $this->decodeUint($buffer, $pointerSize);
$pointerBase = $this->pointerBase;
if (\PHP_INT_MAX - $pointerBase >= $pointerOffset) {
$pointer = $pointerOffset + $pointerBase;
} else {
throw new \RuntimeException(
'The database offset is too large to be represented on your platform.'
);
}
break;
default:
throw new InvalidDatabaseException(
'Unexpected pointer size ' . $pointerSize
);
}
return [$pointer, $offset];
}
// @phpstan-ignore-next-line
private function decodeUint(string $bytes, int $byteLength)
{
if ($byteLength === 0) {
return 0;
}
// PHP integers are signed. PHP_INT_SIZE - 1 is the number of
// complete bytes that can be converted to an integer. However,
// we can convert another byte if the leading bit is zero.
$useRealInts = $byteLength <= \PHP_INT_SIZE - 1
|| ($byteLength === \PHP_INT_SIZE && (\ord($bytes[0]) & 0x80) === 0);
if ($useRealInts) {
$integer = 0;
for ($i = 0; $i < $byteLength; ++$i) {
$part = \ord($bytes[$i]);
$integer = ($integer << 8) + $part;
}
return $integer;
}
// We only use gmp or bcmath if the final value is too big
$integerAsString = '0';
for ($i = 0; $i < $byteLength; ++$i) {
$part = \ord($bytes[$i]);
if (\extension_loaded('gmp')) {
$integerAsString = gmp_strval(gmp_add(gmp_mul($integerAsString, '256'), $part));
} elseif (\extension_loaded('bcmath')) {
$integerAsString = bcadd(bcmul($integerAsString, '256'), (string) $part);
} else {
throw new \RuntimeException(
'The gmp or bcmath extension must be installed to read this database.'
);
}
}
return $integerAsString;
}
/**
* @return array{0:int, 1:int}
*/
private function sizeFromCtrlByte(int $ctrlByte, int $offset): array
{
$size = $ctrlByte & 0x1F;
if ($size < 29) {
return [$size, $offset];
}
$bytesToRead = $size - 28;
$bytes = Util::read($this->fileStream, $offset, $bytesToRead);
if ($size === 29) {
$size = 29 + \ord($bytes);
} elseif ($size === 30) {
$rc = unpack('n', $bytes);
if ($rc === false) {
throw new InvalidDatabaseException(
'Could not unpack an unsigned short value from the given bytes.'
);
}
[, $adjust] = $rc;
$size = 285 + $adjust;
} else {
$rc = unpack('N', "\x00" . $bytes);
if ($rc === false) {
throw new InvalidDatabaseException(
'Could not unpack an unsigned long value from the given bytes.'
);
}
[, $adjust] = $rc;
$size = $adjust + 65821;
}
return [$size, $offset + $bytesToRead];
}
private function maybeSwitchByteOrder(string $bytes): string
{
return $this->switchByteOrder ? strrev($bytes) : $bytes;
}
private function isPlatformLittleEndian(): bool
{
$testint = 0x00FF;
$packed = pack('S', $testint);
$rc = unpack('v', $packed);
if ($rc === false) {
throw new InvalidDatabaseException(
'Could not unpack an unsigned short value from the given bytes.'
);
}
return $testint === current($rc);
}
}
@@ -1,11 +0,0 @@
<?php
declare(strict_types=1);
namespace MaxMind\Db\Reader;
/**
* This class should be thrown when unexpected data is found in the database.
*/
// phpcs:disable
class InvalidDatabaseException extends \Exception {}
@@ -1,123 +0,0 @@
<?php
declare(strict_types=1);
namespace MaxMind\Db\Reader;
/**
* This class provides the metadata for the MaxMind DB file.
*/
class Metadata
{
/**
* This is an unsigned 16-bit integer indicating the major version number
* for the database's binary format.
*
* @var int
*/
public $binaryFormatMajorVersion;
/**
* This is an unsigned 16-bit integer indicating the minor version number
* for the database's binary format.
*
* @var int
*/
public $binaryFormatMinorVersion;
/**
* This is an unsigned 64-bit integer that contains the database build
* timestamp as a Unix epoch value.
*
* @var int
*/
public $buildEpoch;
/**
* This is a string that indicates the structure of each data record
* associated with an IP address. The actual definition of these
* structures is left up to the database creator.
*
* @var string
*/
public $databaseType;
/**
* This key will always point to a map (associative array). The keys of
* that map will be language codes, and the values will be a description
* in that language as a UTF-8 string. May be undefined for some
* databases.
*
* @var array<string, string>
*/
public $description;
/**
* This is an unsigned 16-bit integer which is always 4 or 6. It indicates
* whether the database contains IPv4 or IPv6 address data.
*
* @var int
*/
public $ipVersion;
/**
* An array of strings, each of which is a language code. A given record
* may contain data items that have been localized to some or all of
* these languages. This may be undefined.
*
* @var array<string>
*/
public $languages;
/**
* @var int
*/
public $nodeByteSize;
/**
* This is an unsigned 32-bit integer indicating the number of nodes in
* the search tree.
*
* @var int
*/
public $nodeCount;
/**
* This is an unsigned 16-bit integer. It indicates the number of bits in a
* record in the search tree. Note that each node consists of two records.
*
* @var int
*/
public $recordSize;
/**
* @var int
*/
public $searchTreeSize;
/**
* @param array<string, mixed> $metadata
*/
public function __construct(array $metadata)
{
if (\func_num_args() !== 1) {
throw new \ArgumentCountError(
\sprintf('%s() expects exactly 1 parameter, %d given', __METHOD__, \func_num_args())
);
}
$this->binaryFormatMajorVersion
= $metadata['binary_format_major_version'];
$this->binaryFormatMinorVersion
= $metadata['binary_format_minor_version'];
$this->buildEpoch = $metadata['build_epoch'];
$this->databaseType = $metadata['database_type'];
$this->languages = $metadata['languages'];
$this->description = $metadata['description'];
$this->ipVersion = $metadata['ip_version'];
$this->nodeCount = $metadata['node_count'];
$this->recordSize = $metadata['record_size'];
$this->nodeByteSize = $this->recordSize / 4;
$this->searchTreeSize = $this->nodeCount * $this->nodeByteSize;
}
}
@@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace MaxMind\Db\Reader;
class Util
{
/**
* @param resource $stream
* @param int<0, max> $numberOfBytes
*/
public static function read($stream, int $offset, int $numberOfBytes): string
{
if ($numberOfBytes === 0) {
return '';
}
if (fseek($stream, $offset) === 0) {
$value = fread($stream, $numberOfBytes);
// We check that the number of bytes read is equal to the number
// asked for. We use ftell as getting the length of $value is
// much slower.
if ($value !== false && ftell($stream) - $offset === $numberOfBytes) {
return $value;
}
}
throw new InvalidDatabaseException(
'The MaxMind DB file contains bad data'
);
}
}
@@ -1,75 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuite DB-IP</name>
<element>mokosuite_dbip</element>
<author>Moko Consulting</author>
<creationDate>2026-06-07</creationDate>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.77-dev</version>
<description>PLG_SYSTEM_MOKOSUITE_DBIP_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoSuiteDBIP</namespace>
<files>
<folder>src</folder>
<folder>services</folder>
<folder>language</folder>
<folder>lib</folder>
<folder>data</folder>
</files>
<languages folder="language">
<language tag="en-GB">en-GB/plg_system_mokosuite_dbip.ini</language>
<language tag="en-GB">en-GB/plg_system_mokosuite_dbip.sys.ini</language>
</languages>
<config>
<fields name="params">
<fieldset name="basic"
label="PLG_SYSTEM_MOKOSUITE_DBIP_FIELDSET_BASIC"
description="PLG_SYSTEM_MOKOSUITE_DBIP_FIELDSET_BASIC_DESC">
<field name="database_source" type="list" default="cdn"
label="PLG_SYSTEM_MOKOSUITE_DBIP_SOURCE_LABEL"
description="PLG_SYSTEM_MOKOSUITE_DBIP_SOURCE_DESC">
<option value="cdn">PLG_SYSTEM_MOKOSUITE_DBIP_SOURCE_CDN</option>
<option value="local">PLG_SYSTEM_MOKOSUITE_DBIP_SOURCE_LOCAL</option>
</field>
<field name="database_level" type="list" default="country"
label="PLG_SYSTEM_MOKOSUITE_DBIP_DATABASE_LEVEL_LABEL"
description="PLG_SYSTEM_MOKOSUITE_DBIP_DATABASE_LEVEL_DESC">
<option value="country">PLG_SYSTEM_MOKOSUITE_DBIP_DATABASE_COUNTRY</option>
<option value="city">PLG_SYSTEM_MOKOSUITE_DBIP_DATABASE_CITY</option>
</field>
<field name="auto_update" type="radio" default="1"
label="PLG_SYSTEM_MOKOSUITE_DBIP_AUTO_UPDATE_LABEL"
description="PLG_SYSTEM_MOKOSUITE_DBIP_AUTO_UPDATE_DESC"
class="btn-group btn-group-yesno"
showon="database_source:cdn">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="cdn_url" type="url"
default="https://git.mokoconsulting.tech/MokoConsulting/geoip-data/releases/download/latest/dbip-city-lite.mmdb"
label="PLG_SYSTEM_MOKOSUITE_DBIP_CDN_URL_LABEL"
description="PLG_SYSTEM_MOKOSUITE_DBIP_CDN_URL_DESC"
filter="url"
showon="database_source:cdn" />
<field name="local_path" type="text"
default=""
label="PLG_SYSTEM_MOKOSUITE_DBIP_LOCAL_PATH_LABEL"
description="PLG_SYSTEM_MOKOSUITE_DBIP_LOCAL_PATH_DESC"
filter="path"
showon="database_source:local" />
<field name="last_updated" type="hidden" default="" filter="raw" />
</fieldset>
</fields>
</config>
</extension>
@@ -1,33 +0,0 @@
<?php
/**
* @package Moko.Plugin.System.MokoSuiteDBIP
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GPL-3.0-or-later
*/
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\MokoSuiteDBIP\Extension\DBIP;
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 DBIP($dispatcher, (array) PluginHelper::getPlugin('system', 'mokosuite_dbip'));
$plugin->setApplication(Factory::getApplication());
return $plugin;
}
);
}
};
@@ -1,83 +0,0 @@
<?php
/**
* @package Moko.Plugin.System.MokoSuiteDBIP
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GPL-3.0-or-later
*
* IP Geolocation by DB-IP https://db-ip.com
*/
namespace Moko\Plugin\System\MokoSuiteDBIP\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Event\SubscriberInterface;
use Moko\Plugin\System\MokoSuiteDBIP\Helper\DBIPHelper;
class DBIP extends CMSPlugin implements SubscriberInterface
{
protected $autoloadLanguage = true;
public static function getSubscribedEvents(): array
{
return [
'onAfterInitialise' => 'onAfterInitialise',
];
}
/**
* Initialize DB-IP: set local path if configured, auto-download city DB if needed.
*/
public function onAfterInitialise(): void
{
$source = $this->params->get('database_source', 'cdn');
$level = $this->params->get('database_level', 'country');
// If using a local MMDB file, configure the helper
if ($source === 'local')
{
$localPath = $this->params->get('local_path', '');
if ($localPath !== '')
{
DBIPHelper::setLocalPath($localPath);
}
return;
}
// CDN mode: auto-download city DB if selected and needed
if ($level !== 'city' || !$this->params->get('auto_update', 1))
{
return;
}
$cityPath = DBIPHelper::getCityDbPath();
if (file_exists($cityPath))
{
$age = time() - filemtime($cityPath);
if ($age < 86400 * 30)
{
return;
}
}
// Only download during admin page loads
$app = $this->getApplication();
if (!$app->isClient('administrator'))
{
return;
}
$url = $this->params->get(
'cdn_url',
'https://git.mokoconsulting.tech/MokoConsulting/geoip-data/releases/download/latest/dbip-city-lite.mmdb'
);
DBIPHelper::downloadCityDb($url);
}
}
@@ -1,269 +0,0 @@
<?php
/**
* @package Moko.Plugin.System.MokoSuiteDBIP
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GPL-3.0-or-later
*
* IP Geolocation by DB-IP https://db-ip.com
*/
namespace Moko\Plugin\System\MokoSuiteDBIP\Helper;
defined('_JEXEC') or die;
use MaxMind\Db\Reader;
class DBIPHelper
{
private static ?Reader $countryReader = null;
private static ?Reader $cityReader = null;
private static bool $libLoaded = false;
private static string $customLocalPath = '';
/**
* Set a custom local path for the city database.
*/
public static function setLocalPath(string $path): void
{
self::$customLocalPath = $path;
}
/**
* Get the path to the bundled country database.
*/
public static function getCountryDbPath(): string
{
return JPATH_PLUGINS . '/system/mokosuite_dbip/data/dbip-country-lite.mmdb';
}
/**
* Get the path to the city database.
* Uses custom local path if set, otherwise the CDN cache location.
*/
public static function getCityDbPath(): string
{
if (self::$customLocalPath !== '' && file_exists(self::$customLocalPath))
{
return self::$customLocalPath;
}
return JPATH_ADMINISTRATOR . '/cache/mokosuite_dbip/dbip-city-lite.mmdb';
}
/**
* Load the MaxMind DB Reader library.
*/
private static function loadLib(): void
{
if (self::$libLoaded)
{
return;
}
$libPath = JPATH_PLUGINS . '/system/mokosuite_dbip/lib';
require_once $libPath . '/MaxMind/Db/Reader.php';
require_once $libPath . '/MaxMind/Db/Reader/Decoder.php';
require_once $libPath . '/MaxMind/Db/Reader/InvalidDatabaseException.php';
require_once $libPath . '/MaxMind/Db/Reader/Metadata.php';
require_once $libPath . '/MaxMind/Db/Reader/Util.php';
self::$libLoaded = true;
}
/**
* Look up an IP address and return geolocation data.
*
* @param string $ip The IP address to look up.
*
* @return array|null Geolocation data or null if not found.
*
* Result keys (country DB): country_code, country_name, continent_code, continent_name
* Result keys (city DB): + region, city, latitude, longitude, timezone
*/
public static function lookup(string $ip): ?array
{
try
{
self::loadLib();
// Try city database first
$cityPath = self::getCityDbPath();
if (file_exists($cityPath))
{
if (self::$cityReader === null)
{
self::$cityReader = new Reader($cityPath);
}
$record = self::$cityReader->get($ip);
if ($record !== null)
{
return self::normalizeCityRecord($record);
}
}
// Fall back to bundled country database
$countryPath = self::getCountryDbPath();
if (file_exists($countryPath))
{
if (self::$countryReader === null)
{
self::$countryReader = new Reader($countryPath);
}
$record = self::$countryReader->get($ip);
if ($record !== null)
{
return self::normalizeCountryRecord($record);
}
}
}
catch (\Throwable $e)
{
// Silent — don't break the site if DB-IP fails
}
return null;
}
/**
* Look up country only (uses bundled DB, always available).
*/
public static function lookupCountry(string $ip): ?string
{
$result = self::lookup($ip);
return $result['country_code'] ?? null;
}
/**
* Check if the city database is installed.
*/
public static function hasCityDb(): bool
{
return file_exists(self::getCityDbPath());
}
/**
* Download the city database from the configured URL.
*
* @param string $url The download URL for the city MMDB file.
*
* @return bool True on success.
*/
public static function downloadCityDb(string $url): bool
{
$destPath = JPATH_ADMINISTRATOR . '/cache/mokosuite_dbip/dbip-city-lite.mmdb';
$destDir = \dirname($destPath);
if (!is_dir($destDir))
{
mkdir($destDir, 0755, true);
}
$tmpFile = $destPath . '.tmp';
try
{
$ch = curl_init($url);
$fp = fopen($tmpFile, 'wb');
curl_setopt_array($ch, [
\CURLOPT_FILE => $fp,
\CURLOPT_FOLLOWLOCATION => true,
\CURLOPT_TIMEOUT => 300,
\CURLOPT_CONNECTTIMEOUT => 30,
\CURLOPT_USERAGENT => 'MokoSuite-DBIP/1.0',
]);
$success = curl_exec($ch);
$code = curl_getinfo($ch, \CURLINFO_HTTP_CODE);
curl_close($ch);
fclose($fp);
if ($success && $code === 200 && filesize($tmpFile) > 1024)
{
if (self::$cityReader !== null)
{
self::$cityReader->close();
self::$cityReader = null;
}
rename($tmpFile, $destPath);
return true;
}
@unlink($tmpFile);
}
catch (\Throwable $e)
{
@unlink($tmpFile);
}
return false;
}
/**
* Normalize a DB-IP city record into a flat array.
*/
private static function normalizeCityRecord(array $record): array
{
return [
'country_code' => $record['country']['iso_code'] ?? '',
'country_name' => $record['country']['names']['en'] ?? '',
'continent_code' => $record['continent']['code'] ?? '',
'continent_name' => $record['continent']['names']['en'] ?? '',
'region' => $record['subdivisions'][0]['names']['en'] ?? '',
'city' => $record['city']['names']['en'] ?? '',
'latitude' => $record['location']['latitude'] ?? null,
'longitude' => $record['location']['longitude'] ?? null,
'timezone' => $record['location']['time_zone'] ?? '',
];
}
/**
* Normalize a DB-IP country record into a flat array.
*/
private static function normalizeCountryRecord(array $record): array
{
return [
'country_code' => $record['country']['iso_code'] ?? '',
'country_name' => $record['country']['names']['en'] ?? '',
'continent_code' => $record['continent']['code'] ?? '',
'continent_name' => $record['continent']['names']['en'] ?? '',
'region' => '',
'city' => '',
'latitude' => null,
'longitude' => null,
'timezone' => '',
];
}
/**
* Shut down readers.
*/
public static function close(): void
{
if (self::$countryReader !== null)
{
self::$countryReader->close();
self::$countryReader = null;
}
if (self::$cityReader !== null)
{
self::$cityReader->close();
self::$cityReader = null;
}
}
}
@@ -8,7 +8,7 @@
<license>GPL-3.0-or-later</license> <license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl> <authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.77-dev</version> <version>02.34.50-dev</version>
<description>PLG_SYSTEM_MOKOSUITE_DEVTOOLS_DESC</description> <description>PLG_SYSTEM_MOKOSUITE_DEVTOOLS_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoSuiteDevTools</namespace> <namespace path="src">Moko\Plugin\System\MokoSuiteDevTools</namespace>
@@ -8,7 +8,7 @@
<license>GPL-3.0-or-later</license> <license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl> <authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.77-dev</version> <version>02.34.50-dev</version>
<description>PLG_SYSTEM_MOKOSUITE_FIREWALL_DESC</description> <description>PLG_SYSTEM_MOKOSUITE_FIREWALL_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoSuiteFirewall</namespace> <namespace path="src">Moko\Plugin\System\MokoSuiteFirewall</namespace>
@@ -8,7 +8,7 @@
<license>GPL-3.0-or-later</license> <license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl> <authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.77-dev</version> <version>02.34.50-dev</version>
<description>PLG_SYSTEM_MOKOSUITE_LICENSE_DESC</description> <description>PLG_SYSTEM_MOKOSUITE_LICENSE_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoSuiteLicense</namespace> <namespace path="src">Moko\Plugin\System\MokoSuiteLicense</namespace>
<files><folder>src</folder><folder>services</folder><folder>language</folder></files> <files><folder>src</folder><folder>services</folder><folder>language</folder></files>
@@ -8,7 +8,7 @@
<license>GPL-3.0-or-later</license> <license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl> <authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.77-dev</version> <version>02.34.50-dev</version>
<description>PLG_SYSTEM_MOKOSUITE_MONITOR_DESC</description> <description>PLG_SYSTEM_MOKOSUITE_MONITOR_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoSuiteMonitor</namespace> <namespace path="src">Moko\Plugin\System\MokoSuiteMonitor</namespace>
@@ -34,58 +34,10 @@ class Monitor extends CMSPlugin implements SubscriberInterface
public static function getSubscribedEvents(): array public static function getSubscribedEvents(): array
{ {
return [ return [
'onExtensionAfterSave' => 'onExtensionAfterSave', 'onExtensionAfterSave' => 'onExtensionAfterSave',
'onAfterInitialise' => 'onAfterInitialise',
'onExtensionAfterInstall' => 'onExtensionAfterInstall',
]; ];
} }
/**
* Send heartbeat on first admin page load after install/update.
*/
public function onAfterInitialise(): void
{
$app = $this->getApplication();
if (!$app->isClient('administrator')) return;
if (!$this->params->get('heartbeat_enabled', 1)) return;
$session = \Joomla\CMS\Factory::getSession();
if ($session->get('mokosuite.heartbeat_sent', false)) return;
// Check if version changed since last heartbeat
$lastVersion = $this->params->get('_last_heartbeat_version', '');
$currentVersion = $this->getMokoSuiteVersion();
if ($lastVersion !== $currentVersion)
{
$session->set('mokosuite.heartbeat_sent', true);
$this->sendHeartbeat();
// Store version so we don't re-send every session
try
{
$this->params->set('_last_heartbeat_version', $currentVersion);
$extension = new \Joomla\CMS\Table\Extension(Factory::getDbo());
$extension->load(['element' => 'mokosuite_monitor', 'folder' => 'system', 'type' => 'plugin']);
$extension->params = $this->params->toString();
$extension->store();
}
catch (\Throwable $e) {}
}
}
/**
* Send heartbeat immediately after package install/update.
*/
public function onExtensionAfterInstall($installer, $eid): void
{
if (!$this->params->get('heartbeat_enabled', 1)) return;
try { $this->sendHeartbeat(); }
catch (\Throwable $e) {}
}
/** /**
* After saving this plugin or the core plugin, send heartbeat. * After saving this plugin or the core plugin, send heartbeat.
*/ */
@@ -194,47 +146,46 @@ class Monitor extends CMSPlugin implements SubscriberInterface
$endpoint = $baseUrl . '/api/index.php/v1/mokosuitehq/heartbeat'; $endpoint = $baseUrl . '/api/index.php/v1/mokosuitehq/heartbeat';
$json = json_encode($payload, JSON_UNESCAPED_SLASHES); $json = json_encode($payload, JSON_UNESCAPED_SLASHES);
try $ch = curl_init($endpoint);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $json,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_SSL_VERIFYPEER => false,
]);
$response = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error)
{ {
$http = \Joomla\CMS\Http\HttpFactory::getHttp( Log::add('Monitor heartbeat failed: ' . $error, Log::WARNING, 'mokosuite');
new \Joomla\Registry\Registry(['follow_location' => true, 'transport.curl' => ['certpath' => false]]),
['curl', 'stream']
);
$headerMap = [];
foreach ($headers as $h)
{
[$key, $val] = explode(': ', $h, 2);
$headerMap[$key] = $val;
}
$response = $http->post($endpoint, $json, $headerMap, 15);
$code = $response->code;
$body = json_decode($response->body, true);
if ($code >= 200 && $code < 300)
{
$app->enqueueMessage(
'MokoSuiteHQ heartbeat: ' . ($body['status'] ?? 'ok'),
'message'
);
}
else
{
Log::add(
\sprintf('Monitor heartbeat HTTP %d: %s', $code, $body['error'] ?? 'Unknown'),
Log::WARNING,
'mokosuite'
);
$app->enqueueMessage(
'MokoSuiteHQ heartbeat failed (HTTP ' . $code . ')',
'warning'
);
}
} }
catch (\Throwable $e) elseif ($code >= 200 && $code < 300)
{ {
Log::add('Monitor heartbeat failed: ' . $e->getMessage(), Log::WARNING, 'mokosuite'); $body = json_decode($response, true);
$app->enqueueMessage(
'MokoSuiteHQ heartbeat: ' . ($body['status'] ?? 'ok'),
'message'
);
}
else
{
$body = json_decode($response, true);
Log::add(
\sprintf('Monitor heartbeat HTTP %d: %s', $code, $body['error'] ?? 'Unknown'),
Log::WARNING,
'mokosuite'
);
$app->enqueueMessage(
'MokoSuiteHQ heartbeat failed (HTTP ' . $code . ')',
'warning'
);
} }
} }
@@ -306,30 +257,30 @@ class Monitor extends CMSPlugin implements SubscriberInterface
*/ */
private function fetchLocalHealth(string $siteUrl, string $healthToken): ?array private function fetchLocalHealth(string $siteUrl, string $healthToken): ?array
{ {
try $url = $siteUrl . '/?mokosuite=health';
{
$http = \Joomla\CMS\Http\HttpFactory::getHttp(
new \Joomla\Registry\Registry(['follow_location' => true, 'transport.curl' => ['certpath' => false]]),
['curl', 'stream']
);
$response = $http->get( $ch = curl_init($url);
$siteUrl . '/?mokosuite=health', curl_setopt_array($ch, [
['Authorization' => 'Bearer ' . $healthToken, 'Accept' => 'application/json'], CURLOPT_RETURNTRANSFER => true,
10 CURLOPT_TIMEOUT => 10,
); CURLOPT_FOLLOWLOCATION => true,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $healthToken,
'Accept: application/json',
],
]);
if ($response->code !== 200 || empty($response->body)) $response = curl_exec($ch);
{ $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
return null; curl_close($ch);
}
return json_decode($response->body, true) ?: null; if ($code !== 200 || empty($response))
}
catch (\Throwable $e)
{ {
return null; return null;
} }
return json_decode($response, true) ?: null;
} }
/** /**
@@ -339,11 +290,17 @@ class Monitor extends CMSPlugin implements SubscriberInterface
{ {
try try
{ {
$extension = new \Joomla\CMS\Table\Extension(Factory::getDbo()); $db = Factory::getDbo();
$extension->load(['element' => 'pkg_mokosuite', 'type' => 'package']); $db->setQuery(
$manifest = json_decode($extension->manifest_cache ?? '{}'); $db->getQuery(true)
->select($db->quoteName('manifest_cache'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('pkg_mokosuite'))
->where($db->quoteName('type') . ' = ' . $db->quote('package'))
);
$mc = json_decode($db->loadResult() ?? '{}');
return $manifest->version ?? ''; return $mc->version ?? '';
} }
catch (\Throwable $e) catch (\Throwable $e)
{ {
@@ -8,7 +8,7 @@
<license>GPL-3.0-or-later</license> <license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl> <authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.77-dev</version> <version>02.34.50-dev</version>
<description>PLG_SYSTEM_MOKOSUITE_OFFLINE_DESC</description> <description>PLG_SYSTEM_MOKOSUITE_OFFLINE_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoSuiteOffline</namespace> <namespace path="src">Moko\Plugin\System\MokoSuiteOffline</namespace>
@@ -8,7 +8,7 @@
<license>GPL-3.0-or-later</license> <license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl> <authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.77-dev</version> <version>02.34.50-dev</version>
<description>PLG_SYSTEM_MOKOSUITE_TENANT_DESC</description> <description>PLG_SYSTEM_MOKOSUITE_TENANT_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoSuiteTenant</namespace> <namespace path="src">Moko\Plugin\System\MokoSuiteTenant</namespace>
@@ -8,7 +8,7 @@
<license>GPL-3.0-or-later</license> <license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl> <authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.77-dev</version> <version>02.34.50-dev</version>
<description>Runs scheduled helpdesk automation rules — auto-close resolved tickets, SLA breach escalation, and time-based actions.</description> <description>Runs scheduled helpdesk automation rules — auto-close resolved tickets, SLA breach escalation, and time-based actions.</description>
<namespace path="src">Moko\Plugin\Task\MokoSuiteTickets</namespace> <namespace path="src">Moko\Plugin\Task\MokoSuiteTickets</namespace>
@@ -10,16 +10,12 @@ namespace Moko\Plugin\Task\MokoSuiteTickets\Extension;
defined('_JEXEC') or die; defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Plugin\CMSPlugin; use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent; use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent;
use Joomla\Component\Scheduler\Administrator\Task\Status; use Joomla\Component\Scheduler\Administrator\Task\Status;
use Joomla\Component\Scheduler\Administrator\Traits\TaskPluginTrait; use Joomla\Component\Scheduler\Administrator\Traits\TaskPluginTrait;
use Joomla\Event\SubscriberInterface; use Joomla\Event\SubscriberInterface;
use Moko\Component\MokoSuite\Administrator\Model\TicketsModel; use Moko\Component\MokoSuite\Administrator\Model\TicketsModel;
use Moko\Component\MokoSuite\Administrator\Service\AttachmentService;
use Moko\Component\MokoSuite\Administrator\Service\NotificationService;
class TicketAutomation extends CMSPlugin implements SubscriberInterface class TicketAutomation extends CMSPlugin implements SubscriberInterface
{ {
@@ -30,14 +26,6 @@ class TicketAutomation extends CMSPlugin implements SubscriberInterface
'langConstPrefix' => 'PLG_TASK_MOKOSUITE_TICKETS_AUTOMATION', 'langConstPrefix' => 'PLG_TASK_MOKOSUITE_TICKETS_AUTOMATION',
'method' => 'runAutomation', 'method' => 'runAutomation',
], ],
'mokosuite.ticket.imap_poll' => [
'langConstPrefix' => 'PLG_TASK_MOKOSUITE_TICKETS_IMAP_POLL',
'method' => 'runImapPoll',
],
'mokosuite.ticket.autoclose' => [
'langConstPrefix' => 'PLG_TASK_MOKOSUITE_TICKETS_AUTOCLOSE',
'method' => 'runAutoClose',
],
]; ];
protected $autoloadLanguage = true; protected $autoloadLanguage = true;
@@ -74,239 +62,4 @@ class TicketAutomation extends CMSPlugin implements SubscriberInterface
return Status::KNOCKOUT; return Status::KNOCKOUT;
} }
} }
/**
* Poll IMAP inbox and create tickets from unread emails (#136).
*/
private function runImapPoll(ExecuteTaskEvent $event): int
{
$config = $this->getComponentConfig();
$host = $config['imap_host'] ?? '';
$port = (int) ($config['imap_port'] ?? 993);
$user = $config['imap_user'] ?? '';
$pass = $config['imap_password'] ?? '';
$ssl = ($config['imap_ssl'] ?? '1') === '1';
$folder = $config['imap_folder'] ?? 'INBOX';
$processed = $config['imap_processed_folder'] ?? 'INBOX.Processed';
$defaultCat = (int) ($config['default_category'] ?? 0) ?: null;
if (empty($host) || empty($user) || empty($pass))
{
$this->logTask('IMAP not configured — skipping', 'warning');
return Status::OK;
}
if (!function_exists('imap_open'))
{
$this->logTask('php-imap extension not available', 'error');
return Status::KNOCKOUT;
}
$mailbox = '{' . $host . ':' . $port . '/imap' . ($ssl ? '/ssl' : '') . '/novalidate-cert}' . $folder;
$mbox = @imap_open($mailbox, $user, $pass);
if (!$mbox)
{
$this->logTask('IMAP connection failed: ' . imap_last_error(), 'error');
return Status::KNOCKOUT;
}
$db = Factory::getDbo();
$created = 0;
$replied = 0;
$emails = imap_search($mbox, 'UNSEEN');
if ($emails === false)
{
imap_close($mbox);
$this->logTask('No new emails');
return Status::OK;
}
foreach ($emails as $msgNum)
{
try
{
$header = imap_headerinfo($mbox, $msgNum);
$subject = isset($header->subject) ? imap_utf8($header->subject) : '(no subject)';
$fromAddr = $header->from[0]->mailbox . '@' . $header->from[0]->host;
$body = $this->getImapBody($mbox, $msgNum);
// Match sender to Joomla user
$userId = $this->findUserByEmail($fromAddr);
// Check if this is a reply (subject contains [#123])
$ticketId = 0;
if (preg_match('/\[#(\d+)\]/', $subject, $m))
{
$ticketId = (int) $m[1];
}
if ($ticketId > 0)
{
// Add as reply to existing ticket
$reply = (object) [
'ticket_id' => $ticketId,
'user_id' => $userId,
'body' => $body,
'is_internal' => 0,
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuite_ticket_replies', $reply, 'id');
$replied++;
// Notify
$db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuite_tickets')->where('id = ' . $ticketId));
$ticket = $db->loadObject();
if ($ticket) {
NotificationService::notify('ticket_replied', $ticket, ['reply_body' => $body]);
}
}
else
{
// Create new ticket
$ticket = (object) [
'subject' => $subject,
'body' => $body,
'status' => 'open',
'priority' => 'normal',
'category_id' => $defaultCat,
'created_by' => $userId,
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuite_tickets', $ticket, 'id');
$created++;
NotificationService::notify('ticket_created', $ticket);
}
// Mark as seen / move to processed folder
imap_setflag_full($mbox, (string) $msgNum, '\\Seen');
if ($processed && $processed !== $folder)
{
@imap_mail_move($mbox, (string) $msgNum, $processed);
}
}
catch (\Throwable $e)
{
Log::add('IMAP message processing error: ' . $e->getMessage(), Log::WARNING, 'mokosuite');
}
}
imap_expunge($mbox);
imap_close($mbox);
$this->logTask("IMAP poll: {$created} tickets created, {$replied} replies added");
return Status::OK;
}
/**
* Auto-close resolved tickets after configured days.
*/
private function runAutoClose(ExecuteTaskEvent $event): int
{
$config = $this->getComponentConfig();
$days = (int) ($config['autoclose_days'] ?? 7);
if ($days <= 0)
{
$this->logTask('Auto-close disabled (days = 0)');
return Status::OK;
}
$db = Factory::getDbo();
$cutoff = Factory::getDate('-' . $days . ' days')->toSql();
$db->setQuery(
"UPDATE {$db->quoteName('#__mokosuite_tickets')}"
. " SET status = 'closed', closed = {$db->quote(Factory::getDate()->toSql())}"
. " WHERE status = 'resolved'"
. " AND resolved IS NOT NULL"
. " AND resolved < {$db->quote($cutoff)}"
);
$db->execute();
$closed = $db->getAffectedRows();
$this->logTask("Auto-close: {$closed} tickets closed (resolved > {$days} days ago)");
return Status::OK;
}
// ── Helpers ──────────────────────────────────────────────────
private function getComponentConfig(): array
{
try
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select('params')
->from('#__extensions')
->where('element = ' . $db->quote('com_mokosuite'))
->where('type = ' . $db->quote('component'))
);
return json_decode($db->loadResult() ?? '{}', true) ?: [];
}
catch (\Throwable $e)
{
return [];
}
}
private function findUserByEmail(string $email): int
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select('id')
->from('#__users')
->where('email = ' . $db->quote($email))
->setLimit(1)
);
return (int) $db->loadResult();
}
private function getImapBody($mbox, int $msgNum): string
{
$structure = imap_fetchstructure($mbox, $msgNum);
// Simple single-part message
if (empty($structure->parts))
{
$body = imap_fetchbody($mbox, $msgNum, '1');
if ($structure->encoding === 3) $body = base64_decode($body);
if ($structure->encoding === 4) $body = quoted_printable_decode($body);
return trim(strip_tags($body));
}
// Multipart — find text/plain or text/html
$textBody = '';
foreach ($structure->parts as $i => $part)
{
$partNum = (string) ($i + 1);
if ($part->type === 0) // text
{
$content = imap_fetchbody($mbox, $msgNum, $partNum);
if ($part->encoding === 3) $content = base64_decode($content);
if ($part->encoding === 4) $content = quoted_printable_decode($content);
$subtype = strtolower($part->subtype ?? '');
if ($subtype === 'plain' && empty($textBody))
{
$textBody = $content;
}
elseif ($subtype === 'html' && empty($textBody))
{
$textBody = strip_tags($content);
}
}
}
return trim($textBody);
}
} }
@@ -12,7 +12,7 @@
<license>GNU General Public License version 3 or later; see LICENSE</license> <license>GNU General Public License version 3 or later; see LICENSE</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl> <authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.77-dev</version> <version>02.34.50-dev</version>
<description>PLG_TASK_MOKOSUITEDEMO_DESC</description> <description>PLG_TASK_MOKOSUITEDEMO_DESC</description>
<namespace path="src">Moko\Plugin\Task\MokoSuiteDemo</namespace> <namespace path="src">Moko\Plugin\Task\MokoSuiteDemo</namespace>
@@ -10,7 +10,7 @@
* INGROUP: MokoSuite * INGROUP: MokoSuite
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuite * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuite
* PATH: /src/packages/plg_system_mokosuite/Service/DemoResetService.php * PATH: /src/packages/plg_system_mokosuite/Service/DemoResetService.php
* VERSION: 02.34.77 * VERSION: 02.34.50
* BRIEF: Content-only snapshot/restore for demo site reset * BRIEF: Content-only snapshot/restore for demo site reset
*/ */
@@ -12,7 +12,7 @@
<license>GNU General Public License version 3 or later; see LICENSE</license> <license>GNU General Public License version 3 or later; see LICENSE</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl> <authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.77-dev</version> <version>02.34.50-dev</version>
<description>PLG_TASK_MOKOSUITESYNC_DESC</description> <description>PLG_TASK_MOKOSUITESYNC_DESC</description>
<namespace path="src">Moko\Plugin\Task\MokoSuiteSync</namespace> <namespace path="src">Moko\Plugin\Task\MokoSuiteSync</namespace>
@@ -10,7 +10,7 @@
* INGROUP: MokoSuite * INGROUP: MokoSuite
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuite * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuite
* PATH: /src/packages/plg_system_mokosuite/Service/ContentSyncReceiver.php * PATH: /src/packages/plg_system_mokosuite/Service/ContentSyncReceiver.php
* VERSION: 02.34.77 * VERSION: 02.34.50
* BRIEF: Receiver-side content sync applies incoming payload to local DB * BRIEF: Receiver-side content sync applies incoming payload to local DB
*/ */
@@ -10,7 +10,7 @@
* INGROUP: MokoSuite * INGROUP: MokoSuite
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuite * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoSuite
* PATH: /src/packages/plg_system_mokosuite/Service/ContentSyncService.php * PATH: /src/packages/plg_system_mokosuite/Service/ContentSyncService.php
* VERSION: 02.34.77 * VERSION: 02.34.50
* BRIEF: Sender-side content sync builds payload and pushes to remote sites * BRIEF: Sender-side content sync builds payload and pushes to remote sites
*/ */
@@ -7,7 +7,7 @@
<license>GPL-3.0-or-later</license> <license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl> <authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.77-dev</version> <version>02.34.50-dev</version>
<description>Joomla Web Services API routes for MokoSuite site management — health checks, cache, updates, backups, and site info.</description> <description>Joomla Web Services API routes for MokoSuite site management — health checks, cache, updates, backups, and site info.</description>
<namespace path="src">Moko\Plugin\WebServices\MokoSuite</namespace> <namespace path="src">Moko\Plugin\WebServices\MokoSuite</namespace>
<files> <files>
@@ -124,23 +124,5 @@ final class MokoSuiteApi extends CMSPlugin implements SubscriberInterface
'provision', 'provision',
['component' => 'com_mokosuite'] ['component' => 'com_mokosuite']
); );
// Helpdesk Tickets API (#142)
$router->createCRUDRoutes(
'v1/mokosuite/tickets',
'tickets',
['component' => 'com_mokosuite']
);
// Ticket reply (custom route — POST only)
$router->addRoute(
new \Joomla\Router\Route(
['POST'],
'v1/mokosuite/tickets/:id/reply',
'tickets.reply',
['id' => '(\d+)'],
['component' => 'com_mokosuite']
)
);
} }
} }
+1 -2
View File
@@ -2,7 +2,7 @@
<extension type="package" method="upgrade"> <extension type="package" method="upgrade">
<name>Package - MokoSuite</name> <name>Package - MokoSuite</name>
<packagename>mokosuite</packagename> <packagename>mokosuite</packagename>
<version>02.34.77-dev</version> <version>02.34.50-dev</version>
<creationDate>2026-06-02</creationDate> <creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -20,7 +20,6 @@
<file type="plugin" id="plg_system_mokosuite_tenant" group="system">plg_system_mokosuite_tenant.zip</file> <file type="plugin" id="plg_system_mokosuite_tenant" group="system">plg_system_mokosuite_tenant.zip</file>
<file type="plugin" id="plg_system_mokosuite_devtools" group="system">plg_system_mokosuite_devtools.zip</file> <file type="plugin" id="plg_system_mokosuite_devtools" group="system">plg_system_mokosuite_devtools.zip</file>
<file type="plugin" id="plg_system_mokosuite_offline" group="system">plg_system_mokosuite_offline.zip</file> <file type="plugin" id="plg_system_mokosuite_offline" group="system">plg_system_mokosuite_offline.zip</file>
<file type="plugin" id="plg_system_mokosuite_dbip" group="system">plg_system_mokosuite_dbip.zip</file>
<file type="component" id="com_mokosuite">com_mokosuite.zip</file> <file type="component" id="com_mokosuite">com_mokosuite.zip</file>
<file type="module" id="mod_mokosuite_cpanel" client="administrator">mod_mokosuite_cpanel.zip</file> <file type="module" id="mod_mokosuite_cpanel" client="administrator">mod_mokosuite_cpanel.zip</file>
<file type="module" id="mod_mokosuite_menu" client="administrator">mod_mokosuite_menu.zip</file> <file type="module" id="mod_mokosuite_menu" client="administrator">mod_mokosuite_menu.zip</file>
+159 -53
View File
@@ -77,7 +77,6 @@ class Pkg_MokosuiteInstallerScript
$this->enablePlugin('system', 'mokosuite_tenant'); $this->enablePlugin('system', 'mokosuite_tenant');
$this->enablePlugin('system', 'mokosuite_devtools'); $this->enablePlugin('system', 'mokosuite_devtools');
$this->enablePlugin('system', 'mokosuite_offline'); $this->enablePlugin('system', 'mokosuite_offline');
$this->enablePlugin('system', 'mokosuite_dbip');
$this->enablePlugin('webservices', 'mokosuite'); $this->enablePlugin('webservices', 'mokosuite');
$this->enablePlugin('task', 'mokosuitedemo'); $this->enablePlugin('task', 'mokosuitedemo');
$this->enablePlugin('task', 'mokosuitesync'); $this->enablePlugin('task', 'mokosuitesync');
@@ -944,87 +943,194 @@ class Pkg_MokosuiteInstallerScript
*/ */
private function setupCpanelModule(): void private function setupCpanelModule(): void
{ {
$this->ensureAdminModule('mod_mokosuite_cpanel', 'MokoSuite', 'top', 6, 0, '{"show_health":"1","show_plugins":"1"}'); try
} {
$db = Factory::getDbo();
private function setupAdminMenuModule(): void // Enable the module
{ $query = $db->getQuery(true)
$this->ensureAdminModule('mod_mokosuite_menu', 'MokoSuite Menu', 'menu', 3, 0); ->update($db->quoteName('#__extensions'))
} ->set($db->quoteName('enabled') . ' = 1')
->where($db->quoteName('type') . ' = ' . $db->quote('module'))
->where($db->quoteName('element') . ' = ' . $db->quote('mod_mokosuite_cpanel'));
$db->setQuery($query);
$db->execute();
private function setupCacheModule(): void // Check if a module instance already exists in #__modules
{ $query = $db->getQuery(true)
$this->ensureAdminModule('mod_mokosuite_cache', 'MokoSuite Cache Cleaner', 'status', 3, 8); ->select('COUNT(*)')
->from($db->quoteName('#__modules'))
->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokosuite_cpanel'));
$db->setQuery($query);
if ((int) $db->loadResult() > 0)
{
return;
}
// Create the module instance on the cpanel position
$module = (object) [
'title' => 'MokoSuite',
'note' => '',
'content' => '',
'ordering' => 0,
'position' => 'top',
'checked_out' => null,
'checked_out_time' => null,
'publish_up' => null,
'publish_down' => null,
'published' => 1,
'module' => 'mod_mokosuite_cpanel',
'access' => 6, // Super Users only
'showtitle' => 0,
'params' => '{"show_health":"1","show_plugins":"1"}',
'client_id' => 1, // Administrator
'language' => '*',
];
$db->insertObject('#__modules', $module, 'id');
$moduleId = (int) $module->id;
if ($moduleId)
{
// Assign to all admin pages
$map = (object) [
'moduleid' => $moduleId,
'menuid' => 0, // 0 = all pages
];
$db->insertObject('#__modules_menu', $map);
}
}
catch (\Throwable $e)
{
Log::add('CPanel module setup error: ' . $e->getMessage(), Log::WARNING, 'mokosuite');
}
} }
/** /**
* Ensure an admin module is published at the correct position using Joomla's ModuleModel. * Set up the MokoSuite admin sidebar menu module at position 0.
*
* Uses the Joomla MVC save pipeline so that #__modules_menu mappings,
* checked_out, and all internal bookkeeping are handled correctly.
*/ */
private function ensureAdminModule(string $element, string $title, string $position, int $access = 3, int $ordering = 0, string $params = '{}'): void private function setupAdminMenuModule(): void
{ {
try try
{ {
$db = Factory::getDbo(); $db = Factory::getDbo();
// Enable the extension entry // Enable the module extension
$db->setQuery( $db->setQuery(
$db->getQuery(true) $db->getQuery(true)
->update('#__extensions') ->update($db->quoteName('#__extensions'))
->set('enabled = 1') ->set($db->quoteName('enabled') . ' = 1')
->where('type = ' . $db->quote('module')) ->where($db->quoteName('type') . ' = ' . $db->quote('module'))
->where('element = ' . $db->quote($element)) ->where($db->quoteName('element') . ' = ' . $db->quote('mod_mokosuite_menu'))
)->execute(); )->execute();
// Find existing module instance // Check if module instance exists
$db->setQuery( $db->setQuery(
$db->getQuery(true) $db->getQuery(true)
->select('id') ->select('COUNT(*)')
->from('#__modules') ->from($db->quoteName('#__modules'))
->where('module = ' . $db->quote($element)) ->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokosuite_menu'))
->setLimit(1)
); );
$moduleId = (int) $db->loadResult();
// Build save data — Joomla's ModuleModel expects this format if ((int) $db->loadResult() > 0)
$data = [
'title' => $title,
'module' => $element,
'position' => $position,
'published' => 1,
'access' => $access,
'ordering' => $ordering,
'showtitle' => 0,
'client_id' => 1,
'language' => '*',
'params' => $params,
'assignment' => 0, // 0 = all pages
];
if ($moduleId > 0)
{ {
$data['id'] = $moduleId; return;
} }
// Use Joomla's ModuleModel to handle save + menu assignment $module = (object) [
\Joomla\CMS\MVC\Factory\MVCFactory::class; 'title' => 'MokoSuite Menu',
$app = Factory::getApplication(); 'note' => '',
'content' => '',
'ordering' => 0,
'position' => 'menu',
'checked_out' => null,
'checked_out_time' => null,
'publish_up' => null,
'publish_down' => null,
'published' => 1,
'module' => 'mod_mokosuite_menu',
'access' => 3,
'showtitle' => 0,
'params' => '{}',
'client_id' => 1,
'language' => '*',
];
/** @var \Joomla\Component\Modules\Administrator\Model\ModuleModel $model */ $db->insertObject('#__modules', $module, 'id');
$model = $app->bootComponent('com_modules')
->getMVCFactory()
->createModel('Module', 'Administrator', ['ignore_request' => true]);
if (!$model->save($data)) if ((int) $module->id)
{ {
Log::add("Module setup ({$element}): " . $model->getError(), Log::WARNING, 'mokosuite'); $db->insertObject('#__modules_menu', (object) ['moduleid' => (int) $module->id, 'menuid' => 0]);
} }
} }
catch (\Throwable $e) catch (\Throwable $e)
{ {
Log::add("Module setup ({$element}): " . $e->getMessage(), Log::WARNING, 'mokosuite'); Log::add('Admin menu module setup error: ' . $e->getMessage(), Log::WARNING, 'mokosuite');
}
}
/**
* Set up the cache cleaner module in the admin status bar position.
*/
private function setupCacheModule(): void
{
try
{
$db = Factory::getDbo();
// Enable the module extension
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('enabled') . ' = 1')
->where($db->quoteName('type') . ' = ' . $db->quote('module'))
->where($db->quoteName('element') . ' = ' . $db->quote('mod_mokosuite_cache'))
)->execute();
// Check if module instance exists
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__modules'))
->where($db->quoteName('module') . ' = ' . $db->quote('mod_mokosuite_cache'))
);
if ((int) $db->loadResult() > 0)
{
return;
}
$module = (object) [
'title' => 'MokoSuite Cache Cleaner',
'note' => '',
'content' => '',
'ordering' => 8,
'position' => 'status',
'checked_out' => null,
'checked_out_time' => null,
'publish_up' => null,
'publish_down' => null,
'published' => 1,
'module' => 'mod_mokosuite_cache',
'access' => 3,
'showtitle' => 0,
'params' => '{}',
'client_id' => 1,
'language' => '*',
];
$db->insertObject('#__modules', $module, 'id');
if ((int) $module->id)
{
$mm = (object) ['moduleid' => (int) $module->id, 'menuid' => 0];
$db->insertObject('#__modules_menu', $mm, 'moduleid');
}
}
catch (\Throwable $e)
{
Log::add('Cache module setup error: ' . $e->getMessage(), Log::WARNING, 'mokosuite');
} }
} }
+48
View File
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later
FILE INFORMATION
DEFGROUP: Joomla.Component
INGROUP: MokoWaaS
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
VERSION: 02.32.04
PATH: /mokowaas.xml
BRIEF: Component manifest for MokoWaaS admin dashboard and REST API
-->
<extension type="component" method="upgrade">
<name>MokoWaaS</name>
<author>Moko Consulting</author>
<creationDate>2026-06-02</creationDate>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.00</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>
<files folder="admin">
<folder>language</folder>
<folder>services</folder>
<folder>src</folder>
<folder>tmpl</folder>
</files>
</administration>
<api>
<files folder="api">
<folder>src</folder>
</files>
</api>
<media destination="com_mokowaas" folder="media">
<folder>css</folder>
<folder>js</folder>
</media>
</extension>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
<?php
/**
* Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* VERSION: 02.34.00
* PATH: /src/Field/AllowedIpsField.php
* BRIEF: Custom form field that displays the current IP whitelist
*/
namespace Moko\Plugin\System\MokoWaaS\Field;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Form\FormField;
class AllowedIpsField extends FormField
{
protected $type = 'AllowedIps';
protected function getInput()
{
$config = Factory::getApplication()->getConfig();
$allowedRaw = $config->get('mokowaas_allowed_ips', '');
$currentIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
if (empty($allowedRaw))
{
$status = '<span class="badge bg-danger">Not configured</span>';
$ipList = '<em>No IPs set — emergency access is blocked.</em>';
}
else
{
$ips = array_map('trim', explode(',', $allowedRaw));
$status = '<span class="badge bg-success">'
. count($ips) . ' IP(s) configured</span>';
$ipItems = [];
foreach ($ips as $ip)
{
$match = ($ip === $currentIp)
? ' <span class="badge bg-info">your IP</span>'
: '';
$ipItems[] = '<code>' . htmlspecialchars($ip)
. '</code>' . $match;
}
$ipList = implode(', ', $ipItems);
}
$yourIp = '<code>' . htmlspecialchars($currentIp) . '</code>';
return '<div class="alert alert-info mb-0">'
. '<strong>IP Whitelist:</strong> ' . $status . '<br>'
. '<strong>Allowed IPs:</strong> ' . $ipList . '<br>'
. '<strong>Your current IP:</strong> ' . $yourIp . '<br>'
. '<small class="text-muted">Set <code>public '
. '$mokowaas_allowed_ips = \'1.2.3.4,5.6.7.8\';</code>'
. ' in configuration.php to change.</small>'
. '</div>';
}
protected function getLabel()
{
return '';
}
}
@@ -0,0 +1,40 @@
<?php
/**
* Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* VERSION: 02.34.00
* PATH: /src/Field/CurrentIpField.php
* BRIEF: Read-only field that displays the current user's IP address
*/
namespace Moko\Plugin\System\MokoWaaS\Field;
defined('_JEXEC') or die;
use Joomla\CMS\Form\FormField;
class CurrentIpField extends FormField
{
protected $type = 'CurrentIp';
protected function getInput()
{
$currentIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
return '<div class="alert alert-info mb-0 py-2">'
. '<strong>Your current IP:</strong> '
. '<code>' . htmlspecialchars($currentIp) . '</code> '
. '<small class="text-muted">&mdash; add this to the table below to keep your session alive.</small>'
. '</div>';
}
protected function getLabel()
{
return '';
}
}
@@ -0,0 +1,237 @@
<?php
/**
* @package MokoWaaS
* @subpackage plg_system_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* VERSION: 02.34.00
* PATH: /src/Field/DemoTaskInfoField.php
* BRIEF: Read-only field showing scheduled task info with link to manage it
*/
namespace Moko\Plugin\System\MokoWaaS\Field;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Form\FormField;
use Joomla\CMS\Router\Route;
/**
* Displays the demo reset scheduled task status: schedule, next run,
* last run, and a direct link to edit the task in Joomla's Scheduler.
*
* @since 02.29.00
*/
class DemoTaskInfoField extends FormField
{
protected $type = 'DemoTaskInfo';
protected function getInput()
{
// Query the scheduled task — if it exists and is enabled, demo mode is on
try
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__scheduler_tasks'))
->where($db->quoteName('type') . ' = ' . $db->quote('mokowaas.demo.reset'));
$db->setQuery($query);
$task = $db->loadAssoc();
}
catch (\Throwable $e)
{
$task = null;
}
$newTaskLink = Route::_('index.php?option=com_scheduler&task=task.add');
if (!$task)
{
return '<div class="alert alert-info mb-0 py-2">'
. 'No demo reset task configured. '
. '<a href="' . $newTaskLink . '" class="alert-link">Create a Scheduled Task</a> '
. 'and select <strong>MokoWaaS Demo Reset</strong> to enable demo mode.</div>';
}
$taskId = (int) $task['id'];
$state = (int) $task['state'];
$siteTimezone = Factory::getApplication()->get('offset', 'UTC');
// Parse schedule from execution_rules
$rules = json_decode($task['execution_rules'] ?? '{}', true);
$ruleType = $rules['rule-type'] ?? '';
switch ($ruleType)
{
case 'cron-expression':
$schedule = $rules['cron-expression'] ?? '';
$friendlySchedule = $this->friendlySchedule($schedule);
break;
case 'interval-minutes':
$mins = (int) ($rules['interval-minutes'] ?? 0);
if ($mins >= 1440 && $mins % 1440 === 0)
{
$days = $mins / 1440;
$schedule = 'Every ' . $days . ' day' . ($days > 1 ? 's' : '');
}
elseif ($mins >= 60 && $mins % 60 === 0)
{
$hours = $mins / 60;
$schedule = 'Every ' . $hours . ' hour' . ($hours > 1 ? 's' : '');
}
else
{
$schedule = 'Every ' . $mins . ' minute' . ($mins !== 1 ? 's' : '');
}
$friendlySchedule = $schedule;
break;
case 'interval-hours':
$hours = (int) ($rules['interval-hours'] ?? 0);
$schedule = 'Every ' . $hours . ' hour' . ($hours !== 1 ? 's' : '');
$friendlySchedule = $schedule;
break;
case 'interval-days':
$days = (int) ($rules['interval-days'] ?? 0);
$schedule = 'Every ' . $days . ' day' . ($days !== 1 ? 's' : '');
$friendlySchedule = $schedule;
break;
default:
$schedule = $ruleType ?: 'Not set';
$friendlySchedule = 'Custom';
}
// Next execution
$nextExec = $task['next_execution'] ?? '';
$nextFormatted = 'Not scheduled';
$nextBadge = '';
if (!empty($nextExec) && $nextExec !== '0000-00-00 00:00:00')
{
try
{
$dt = new \DateTime($nextExec, new \DateTimeZone('UTC'));
$dt->setTimezone(new \DateTimeZone($siteTimezone));
$nextFormatted = $dt->format('M j, Y g:i A T');
}
catch (\Throwable $e)
{
$nextFormatted = $nextExec;
}
$diff = strtotime($nextExec . ' UTC') - time();
if ($diff <= 0)
{
$nextBadge = '<span class="badge bg-warning text-dark">DUE</span>';
}
elseif ($diff < 3600)
{
$nextBadge = '<span class="badge bg-info">in ' . (int) ceil($diff / 60) . ' min</span>';
}
elseif ($diff < 86400)
{
$nextBadge = '<span class="badge bg-info">in ' . round($diff / 3600, 1) . 'h</span>';
}
else
{
$nextBadge = '<span class="badge bg-secondary">in ' . round($diff / 86400, 1) . 'd</span>';
}
}
// Last execution
$lastExec = $task['last_execution'] ?? '';
$lastFormatted = 'Never';
if (!empty($lastExec) && $lastExec !== '0000-00-00 00:00:00')
{
try
{
$dt = new \DateTime($lastExec, new \DateTimeZone('UTC'));
$dt->setTimezone(new \DateTimeZone($siteTimezone));
$lastFormatted = $dt->format('M j, Y g:i A T');
}
catch (\Throwable $e)
{
$lastFormatted = $lastExec;
}
}
// State badge
$stateBadge = $state === 1
? '<span class="badge bg-success">Enabled</span>'
: '<span class="badge bg-danger">Disabled</span>';
// Link to edit the task
$editLink = Route::_('index.php?option=com_scheduler&task=task.edit&id=' . $taskId);
// Task params — default to On when keys are missing (matches form defaults)
$taskParams = json_decode($task['params'] ?? '{}', true) ?: [];
$bannerOn = !isset($taskParams['banner_enabled']) || (int) $taskParams['banner_enabled'] === 1;
$mediaOn = !isset($taskParams['include_media']) || (int) $taskParams['include_media'] === 1;
$countdownOn = !isset($taskParams['show_countdown']) || (int) $taskParams['show_countdown'] === 1;
// Check if snapshot exists
$snapshotExists = is_dir(JPATH_ROOT . '/mokowaas-snapshots/default');
// Build info card
return '<div class="card card-body bg-light py-2 px-3 mb-0">'
. '<table class="table table-sm table-borderless mb-1" style="max-width:550px">'
. '<tr><td class="text-muted" style="width:130px">Status</td><td>' . $stateBadge . '</td></tr>'
. '<tr><td class="text-muted">Schedule</td><td>' . htmlspecialchars($friendlySchedule) . '</td></tr>'
. '<tr><td class="text-muted">Next Reset</td><td>' . htmlspecialchars($nextFormatted) . ' ' . $nextBadge . '</td></tr>'
. '<tr><td class="text-muted">Last Reset</td><td>' . htmlspecialchars($lastFormatted) . '</td></tr>'
. '<tr><td class="text-muted">Runs</td><td>' . (int) ($task['times_executed'] ?? 0) . ' executed, ' . (int) ($task['times_failed'] ?? 0) . ' failed</td></tr>'
. '<tr><td class="text-muted">Baseline</td><td>' . ($snapshotExists ? '<span class="badge bg-success">Saved</span>' : '<span class="badge bg-warning text-dark">Not taken yet</span>') . '</td></tr>'
. '<tr><td class="text-muted">Banner</td><td>' . ($bannerOn ? 'On' : 'Off') . ($countdownOn ? ' + countdown' : '') . '</td></tr>'
. '<tr><td class="text-muted">Images</td><td>' . ($mediaOn ? 'Included' : 'Excluded') . '</td></tr>'
. '</table>'
. '<a href="' . $editLink . '" class="btn btn-sm btn-outline-primary">'
. '<span class="icon-cog" aria-hidden="true"></span> Manage Scheduled Task</a>'
. '</div>';
}
protected function getLabel()
{
return '<label class="form-label"><strong>Scheduled Reset</strong></label>';
}
/**
* Convert a cron expression to a human-readable string.
*
* @param string $cron Cron expression
*
* @return string
*/
private function friendlySchedule(string $cron): string
{
$map = [
'* * * * *' => 'Every minute',
'*/5 * * * *' => 'Every 5 minutes',
'*/15 * * * *' => 'Every 15 minutes',
'*/30 * * * *' => 'Every 30 minutes',
'0 */1 * * *' => 'Every hour',
'0 */4 * * *' => 'Every 4 hours',
'0 */6 * * *' => 'Every 6 hours',
'0 */12 * * *' => 'Every 12 hours',
'0 0 * * *' => 'Daily at midnight',
'0 6 * * *' => 'Daily at 6:00 AM',
'0 0 * * 0' => 'Weekly (Sunday)',
'0 0 1 * *' => 'Monthly (1st)',
];
return $map[$cron] ?? 'Custom';
}
}
@@ -0,0 +1,156 @@
<?php
/**
* @package MokoWaaS
* @subpackage plg_system_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* VERSION: 02.34.00
* PATH: /src/Field/NextResetField.php
* BRIEF: Read-only field showing next reset time from Joomla scheduled task
*/
namespace Moko\Plugin\System\MokoWaaS\Field;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Form\FormField;
/**
* Pulls the next execution time directly from the Joomla scheduled task
* (#__scheduler_tasks) and displays it formatted in the site timezone.
*
* @since 02.29.00
*/
class NextResetField extends FormField
{
protected $type = 'NextReset';
protected function getInput()
{
// Check if demo mode is enabled
$demoEnabled = false;
if ($this->form)
{
$demoEnabled = (int) $this->form->getValue('demo_mode_enabled', 'params', 0) === 1;
}
if (!$demoEnabled)
{
return '<span class="form-control-plaintext text-muted">Demo mode is off</span>'
. '<input type="hidden" name="' . $this->name . '" value="" />';
}
// Query the actual next_execution from the scheduled task
try
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select([
$db->quoteName('next_execution'),
$db->quoteName('last_execution'),
$db->quoteName('state'),
])
->from($db->quoteName('#__scheduler_tasks'))
->where($db->quoteName('type') . ' = ' . $db->quote('mokowaas.demo.reset'));
$db->setQuery($query);
$task = $db->loadAssoc();
}
catch (\Throwable $e)
{
$task = null;
}
if (!$task)
{
return '<div class="alert alert-secondary mb-0 py-2">No scheduled task found — save to create one automatically.</div>'
. '<input type="hidden" name="' . $this->name . '" value="" />';
}
if ((int) $task['state'] !== 1)
{
return '<div class="alert alert-warning mb-0 py-2">Scheduled task is disabled.</div>'
. '<input type="hidden" name="' . $this->name . '" value="" />';
}
$nextExec = $task['next_execution'];
$lastExec = $task['last_execution'];
if (empty($nextExec) || $nextExec === '0000-00-00 00:00:00')
{
return '<div class="alert alert-secondary mb-0 py-2">Waiting for first run...</div>'
. '<input type="hidden" name="' . $this->name . '" value="" />';
}
// Convert to site timezone
$utcTimestamp = strtotime($nextExec);
$siteTimezone = Factory::getApplication()->get('offset', 'UTC');
try
{
$dt = new \DateTime('@' . $utcTimestamp);
$dt->setTimezone(new \DateTimeZone($siteTimezone));
$formatted = $dt->format('l, F j, Y \a\t g:i A T');
}
catch (\Throwable $e)
{
$formatted = $nextExec . ' UTC';
}
// Relative time
$diff = $utcTimestamp - time();
$relative = '';
if ($diff <= 0)
{
$relative = '<span class="badge bg-warning text-dark">overdue</span>';
}
elseif ($diff < 3600)
{
$mins = (int) ceil($diff / 60);
$relative = '<span class="badge bg-info">in ' . $mins . ' min</span>';
}
elseif ($diff < 86400)
{
$hours = round($diff / 3600, 1);
$relative = '<span class="badge bg-info">in ' . $hours . 'h</span>';
}
else
{
$days = round($diff / 86400, 1);
$relative = '<span class="badge bg-secondary">in ' . $days . 'd</span>';
}
// Last run info
$lastInfo = '';
if (!empty($lastExec) && $lastExec !== '0000-00-00 00:00:00')
{
try
{
$lastDt = new \DateTime($lastExec);
$lastDt->setTimezone(new \DateTimeZone($siteTimezone));
$lastInfo = '<small class="text-muted ms-2">Last run: ' . $lastDt->format('M j, g:i A') . '</small>';
}
catch (\Throwable $e)
{
// skip
}
}
return '<div class="d-flex align-items-center gap-2 flex-wrap">'
. '<span class="form-control-plaintext" style="font-weight:500">'
. '<span class="icon-calendar" aria-hidden="true"></span> '
. htmlspecialchars($formatted) . '</span> '
. $relative
. $lastInfo
. '<input type="hidden" name="' . $this->name . '" value="' . htmlspecialchars($nextExec) . '" />'
. '</div>';
}
}
@@ -0,0 +1,175 @@
<?php
/**
* @package MokoWaaS
* @subpackage plg_system_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* VERSION: 02.34.00
* PATH: /src/Field/SnapshotTablesField.php
* BRIEF: Multi-select list field that loads DB tables with sensible defaults
*/
namespace Moko\Plugin\System\MokoWaaS\Field;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Form\FormField;
/**
* Renders a multi-select list box of all Joomla database tables, with
* content-related tables pre-selected by default.
*
* @since 02.26.00
*/
class SnapshotTablesField extends FormField
{
protected $type = 'SnapshotTables';
/**
* Tables selected by default when no value is stored yet.
*
* @var array
* @since 02.25.00
*/
private const DEFAULT_TABLES = [
'#__content',
'#__categories',
'#__fields',
'#__fields_values',
'#__fields_groups',
'#__menu',
'#__menu_types',
'#__modules',
'#__modules_menu',
'#__users',
'#__user_usergroup_map',
'#__user_profiles',
'#__tags',
'#__contentitem_tag_map',
'#__assets',
];
/**
* Table suffixes grouped by category.
*
* @var array
* @since 02.25.00
*/
private const TABLE_GROUPS = [
'Content' => ['content', 'categories', 'fields', 'fields_values', 'fields_groups', 'tags', 'contentitem_tag_map', 'ucm_content', 'ucm_history'],
'Users' => ['users', 'user_usergroup_map', 'user_profiles', 'usergroups', 'user_keys', 'user_mfa'],
'Menus' => ['menu', 'menu_types'],
'Modules' => ['modules', 'modules_menu'],
'Assets' => ['assets'],
];
protected function getInput()
{
$db = Factory::getDbo();
$prefix = $db->getPrefix();
$tables = $db->getTableList();
// Resolve selected values
$selected = $this->value;
if ($selected === null || $selected === '')
{
$selected = self::DEFAULT_TABLES;
}
elseif (is_string($selected))
{
$selected = array_filter(array_map('trim', explode("\n", $selected)));
}
$selected = (array) $selected;
// Flatten nested arrays from broken save format [["#__content"],["#__categories"]]
$selected = array_map(function ($v) {
return is_array($v) ? reset($v) : $v;
}, $selected);
// Group tables
$grouped = [];
foreach ($tables as $table)
{
if (strpos($table, $prefix) !== 0)
{
continue;
}
$suffix = substr($table, strlen($prefix));
$logical = '#__' . $suffix;
$group = 'Other';
foreach (self::TABLE_GROUPS as $groupName => $patterns)
{
if (in_array($suffix, $patterns, true))
{
$group = $groupName;
break;
}
}
$grouped[$group][] = $logical;
}
// Build HTML select with optgroups
$size = (int) ($this->element['size'] ?? 15);
$html = '<select name="' . $this->name . '" id="' . $this->id . '"'
. ' multiple="multiple" size="' . $size . '"'
. ' class="form-select">';
$priority = ['Content', 'Users', 'Menus', 'Modules', 'Assets'];
foreach ($priority as $g)
{
if (!empty($grouped[$g]))
{
$html .= '<optgroup label="' . $g . '">';
foreach ($grouped[$g] as $t)
{
$sel = in_array($t, $selected, true) ? ' selected="selected"' : '';
$html .= '<option value="' . htmlspecialchars($t) . '"' . $sel . '>' . htmlspecialchars($t) . '</option>';
}
$html .= '</optgroup>';
unset($grouped[$g]);
}
}
if (!empty($grouped['Other']))
{
$html .= '<optgroup label="Other">';
foreach ($grouped['Other'] as $t)
{
$sel = in_array($t, $selected, true) ? ' selected="selected"' : '';
$html .= '<option value="' . htmlspecialchars($t) . '"' . $sel . '>' . htmlspecialchars($t) . '</option>';
}
$html .= '</optgroup>';
}
$html .= '</select>';
// "Reset to defaults" link
$defaultsJson = htmlspecialchars(json_encode(self::DEFAULT_TABLES), ENT_QUOTES, 'UTF-8');
$html .= '<div class="mt-1">'
. '<a href="#" class="small" onclick="'
. 'var sel=document.getElementById(\'' . $this->id . '\');'
. 'var defs=' . $defaultsJson . ';'
. 'Array.from(sel.options).forEach(function(o){o.selected=defs.indexOf(o.value)!==-1;});'
. 'return false;'
. '"><span class="icon-refresh" aria-hidden="true"></span> Reset to defaults</a>'
. '</div>';
return $html;
}
}
@@ -0,0 +1,260 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
This file is part of a Moko Consulting project.
SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later
This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the IMPLIED WARRANTY of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License (./LICENSE.md).
# FILE INFORMATION
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.32.04
PATH: /src/mokowaas.xml
BRIEF: Plugin manifest for MokoWaaS system plugin
NOTE: Defines installation metadata, files, and configuration for Joomla
-->
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoWaaS</name>
<element>mokowaas</element>
<author>Moko Consulting</author>
<creationDate>2026-05-22</creationDate>
<copyright>Copyright (C) 2025 Moko Consulting. All rights reserved.</copyright>
<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.34.00</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>
<files>
<filename plugin="mokowaas">script.php</filename>
<folder>Extension</folder>
<folder>Field</folder>
<folder>Helper</folder>
<folder>Service</folder>
<folder>forms</folder>
<folder>payload</folder>
<folder>services</folder>
<folder>language</folder>
<folder>administrator</folder>
</files>
<media destination="plg_system_mokowaas" folder="media">
<filename>index.html</filename>
<filename>favicon.ico</filename>
<filename>favicon.svg</filename>
<filename>favicon_256.png</filename>
<filename>logo.png</filename>
</media>
<languages folder="language">
<language tag="en-GB">en-GB/plg_system_mokowaas.ini</language>
<language tag="en-US">en-US/plg_system_mokowaas.ini</language>
</languages>
<languages folder="administrator/language">
<language tag="en-GB">en-GB/plg_system_mokowaas.sys.ini</language>
<language tag="en-US">en-US/plg_system_mokowaas.sys.ini</language>
</languages>
<administration>
<files folder="administrator">
<folder>language</folder>
</files>
</administration>
<config>
<fields name="params"
addfieldprefix="Moko\Plugin\System\MokoWaaS\Field"
>
<fieldset name="basic">
<field
name="health_api_token"
type="CopyableToken"
label="PLG_SYSTEM_MOKOWAAS_HEALTH_TOKEN_LABEL"
description="PLG_SYSTEM_MOKOWAAS_HEALTH_TOKEN_DESC"
default=""
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"
description="PLG_SYSTEM_MOKOWAAS_FIELDSET_DEMO_DESC"
addfieldprefix="Moko\Plugin\System\MokoWaaS\Field"
>
<field name="demo_scheduled_task" type="DemoTaskInfo"
label="PLG_SYSTEM_MOKOWAAS_DEMO_TASK_INFO_LABEL"
/>
</fieldset>
<fieldset name="security"
label="PLG_SYSTEM_MOKOWAAS_FIELDSET_SECURITY_LABEL"
description="PLG_SYSTEM_MOKOWAAS_FIELDSET_SECURITY_DESC"
addfieldprefix="Moko\Plugin\System\MokoWaaS\Field"
>
<field
name="emergency_access"
type="radio"
label="PLG_SYSTEM_MOKOWAAS_EMERGENCY_ACCESS_LABEL"
description="PLG_SYSTEM_MOKOWAAS_EMERGENCY_ACCESS_DESC"
default="1"
class="btn-group btn-group-yesno"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="allowed_ips_display"
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>
</extension>