Compare commits

..

10 Commits

129 changed files with 1568 additions and 10325 deletions
-1
View File
@@ -122,7 +122,6 @@ build/
dist/
out/
site/
!src/packages/*/site/
*.map
*.css.map
*.js.map
+1 -1
View File
@@ -9,7 +9,7 @@
<display-name>Package - MokoWaaS</display-name>
<org>MokoConsulting</org>
<description>White-label identity, security hardening, and tenant restriction layer for WaaS-managed Joomla environments</description>
<version>02.33.01</version>
<version>02.34.00</version>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity>
<governance>
-33
View File
@@ -171,39 +171,6 @@ jobs:
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--skip-update-stream
- name: Update release notes from CHANGELOG.md
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
# Extract [Unreleased] section from changelog
if [ -f "CHANGELOG.md" ]; then
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
[ -z "$NOTES" ] && NOTES="Stable release"
else
NOTES="Stable release"
fi
# Update release body via API
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
"${API_BASE}/releases/tags/stable" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -n "$RELEASE_ID" ]; then
python3 -c "
import json, urllib.request
body = open('/dev/stdin').read()
payload = json.dumps({'body': body}).encode()
req = urllib.request.Request(
'${API_BASE}/releases/${RELEASE_ID}',
data=payload, method='PATCH',
headers={
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
'Content-Type': 'application/json'
})
urllib.request.urlopen(req)
" <<< "$NOTES"
echo "Release notes updated from CHANGELOG.md"
fi
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
- name: "Step 9: Mirror release to GitHub"
if: >-
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation
# VERSION: 02.33.01
# VERSION: 02.34.00
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
-24
View File
@@ -295,30 +295,6 @@ jobs:
;;
esac
- name: Check changelog has unreleased entries (PRs to main)
if: github.base_ref == 'main'
run: |
if [ ! -f "CHANGELOG.md" ]; then
echo "::error::CHANGELOG.md not found — required for releases"
exit 1
fi
# Extract content between [Unreleased] and next ## heading
ENTRIES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found && /^- /{count++} END{print count+0}' CHANGELOG.md)
if [ "$ENTRIES" -eq 0 ]; then
echo "::error::CHANGELOG.md has no entries under [Unreleased]. Add changelog entries before releasing."
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "No entries found under \`[Unreleased]\` in CHANGELOG.md." >> $GITHUB_STEP_SUMMARY
echo "Add entries describing what changed before merging to main." >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Changelog: ${ENTRIES} unreleased entries found"
echo "## Changelog Check: Passed" >> $GITHUB_STEP_SUMMARY
echo "${ENTRIES} entries under [Unreleased]" >> $GITHUB_STEP_SUMMARY
- name: Validate Joomla language files
if: steps.platform.outputs.platform == 'joomla'
run: |
+9 -40
View File
@@ -103,17 +103,6 @@ jobs:
# Strip any existing suffix from version before applying stability
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
# RC and stable consolidate dev patches into a clean minor bump
# e.g. 02.33.15 → 02.34.00 (not 02.33.15-rc)
case "$STABILITY" in
release-candidate)
MAJOR=$(echo "$VERSION" | cut -d. -f1)
MINOR=$(echo "$VERSION" | cut -d. -f2)
MINOR=$(printf "%02d" $((10#$MINOR + 1)))
VERSION="${MAJOR}.${MINOR}.00"
;;
esac
php ${MOKO_CLI}/version_set_platform.php \
--path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true
@@ -166,39 +155,19 @@ jobs:
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --branch dev --prerelease
- name: Update release notes from CHANGELOG.md
- name: Ensure prerelease flag
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)
# Get release ID by tag and force prerelease=true
RELEASE_ID=$(curl -s "${API_BASE}/releases/tags/${TAG}" \
-H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" | jq -r '.id // empty')
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"
curl -s -X PATCH "${API_BASE}/releases/${RELEASE_ID}" \
-H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"prerelease": true}'
echo "Marked release ${TAG} (id=${RELEASE_ID}) as prerelease"
fi
- name: Build package and upload
+8 -53
View File
@@ -14,57 +14,11 @@
INGROUP: MokoWaaS.Documentation
REPO: https://github.com/mokoconsulting-tech/mokowaas
PATH: ./CHANGELOG.md
VERSION: 02.33.01
VERSION: 02.34.00
BRIEF: Version history using `Keep a Changelog`
-->
# Changelog
## [Unreleased]
### Added
- Database Tools view — table status, optimize, repair, session purge (#127)
- Cache Cleanup view — directory size reporting and one-click cleanup (#128)
- mod_mokowaas_cache — one-click cache cleaner button in admin status bar (replaces Regular Labs Cache Cleaner)
- mod_mokowaas_menu — collapsible admin sidebar menu using native MetisMenu classes (like Community Builder)
- SSL certificate expiry monitoring in cpanel module (#148)
- MokoWaaS-specific update badge (blue) separate from other updates in cpanel module
- migrateUpdateServerUrls() — rewrites all Moko extension update server URLs to clean /updates.xml on install/update
- fixMenuIcons() — sets menu_icon params on submenu items (Joomla only renders img on level 1)
- setupCacheModule() — registers cache cleaner module in status bar position on install
- Component config.xml for Joomla Options modal (#149)
- preflight() ALTER for #__extensions.element default (MySQL strict mode fix)
- Retire MokoJoomTOS, MokoATS-Automation, MokoDPCalendarAPI, MokoGalleryCalendar on install
- MokoJoomTOS settings auto-migrate to mokowaas_offline before removal
- dev-release and pre-release workflows with changelog extraction into release notes
- RC pre-release consolidates dev patches into clean minor version bump
### Changed
- Admin menu module uses native Joomla MetisMenu CSS classes
- Helpdesk icon changed to fa-handshake-angle, .htaccess to fa-solid fa-file-code
- clearCache purges all cache files recursively (replaces Regular Labs Cache Cleaner behavior)
- License key warning moved from every-page onAfterRoute to package postflight only
- Update server URL changed to dynamic MokoGitea feed
- Component manifest adds `<languages>` for global language dir deployment
- Privacy and WAF Log added to component manifest submenu
- MokoOnyx template removed from package manifest (separate repo/release)
### Removed
- Static updates.xml — MokoGitea generates update feed dynamically from releases
- update-server.yml workflow — replaced by pre-release.yml
### Fixed
- Tickets list showing raw `<em>Unassigned</em>` HTML instead of italic text
- Cache cleaner CSRF failure — token now sent as POST FormData
- Admin menu icons missing for Helpdesk and .htaccess Maker
- Firewall install error "Field 'element' doesn't have a default value" (MySQL strict mode)
## [02.32] - 2026-06-02
# Changelog## [02.32.00] - 2026-06-02
### Added
- Admin control panel dashboard in com_mokowaas with site info bar, feature plugin grid, and quick actions
- Feature plugin architecture — MokoWaaS features split into toggleable plugins managed from the dashboard
@@ -88,8 +42,7 @@
- License key validation (licensing system not ready — will return in future release)
- Dynamic MokoGitea update feed dependency (replaced with static updates.xml)
## [02.31] - 2026-06-01
## [02.31.00] - 2026-06-01
### Added
- License key support via Joomla's native Update Sites download key system (dlid)
- Update server URL migrated from static XML to MokoGitea's dynamic update feed endpoint
@@ -122,8 +75,7 @@
- Site Aliases config tab (hardcoded to dev.{primary_domain})
- File sync (images/, files/, media/) — sync is API/DB content only
## [02.29] - 2026-05-31
## [02.29.03] - 2026-05-31
### Added
- `allow_extension_updates` param — separate update rights from installer restrictions; tenants can update extensions by default even when the installer is restricted
- Hardcoded master usernames — multiple privileged users supported with identical access
@@ -137,6 +89,7 @@
- Demo Mode with configurable warning banner on frontend when enabled
### Fixed
- Demo banner countdown now shows weeks/days/months for longer intervals instead of raw hours
- `DemoResetService` — baseline snapshot and restore for DB tables + media files
- API endpoints `POST /?mokowaas=reset` and `POST /?mokowaas=snapshot` (query-string)
@@ -151,4 +104,6 @@
- Package installer: clean up legacy `mokowaasbrand` extension entries and files on install/update
- API endpoint `GET /?mokowaas=extensions` and `GET /api/v1/mokowaas/extensions` — list installed extensions with version, status, and update server info
## [02.20] --- 2026-05-28
## [02.20.00] --- 2026-05-28
## [02.20.00] --- 2026-05-28
+1 -1
View File
@@ -14,7 +14,7 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Documentation
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.33.01
VERSION: 02.34.00
PATH: ./CODE_OF_CONDUCT.md
BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default
-->
-24
View File
@@ -127,30 +127,6 @@ The version tools update all files containing version stamps:
Files synced from other repos (with a `# REPO:` header) are not touched.
## Changelog
We use [Keep a Changelog](https://keepachangelog.com/) with an `[Unreleased]` staging section.
### Rules
- All changes go under `## [Unreleased]` — this is the "current work" section
- Entries stay under `[Unreleased]` until a **stable release** merges to `main`
- On stable release, `[Unreleased]` entries are promoted to a version heading (e.g., `## [02.34] - 2026-06-10`)
- Only **minor versions** get changelog headings — patch numbers from dev are never shown
- Dev/alpha/beta/RC pre-release descriptions pull from `[Unreleased]` automatically
- **CI will block PRs to main** if `[Unreleased]` has no entries
### Categories
Use these headings under each version:
- `### Added` — new features
- `### Changed` — changes to existing functionality
- `### Deprecated` — features that will be removed
- `### Removed` — features that were removed
- `### Fixed` — bug fixes
- `### Security` — vulnerability fixes
## Code Standards
- **PHP**: PSR-12, tabs for indentation
+1 -1
View File
@@ -19,7 +19,7 @@
DEFGROUP: mokoconsulting-tech.MokoWaaSBrand
INGROUP: MokoStandards.Governance
REPO: https://github.com/mokoconsulting-tech/MokoWaaSBrand
VERSION: 02.33.01
VERSION: 02.34.00
PATH: /GOVERNANCE.md
BRIEF: Project governance rules, roles, and decision process for MokoWaaSBrand
-->
+1 -1
View File
@@ -15,7 +15,7 @@
INGROUP: MokoWaaS.Documentation
REPO: https://github.com/mokoconsulting-tech/mokowaas
PATH: ./LICENSE.md
VERSION: 02.33.01
VERSION: 02.34.00
BRIEF: Project license (GPL-3.0-or-later)
-->
GNU GENERAL PUBLIC LICENSE
+1 -1
View File
@@ -9,7 +9,7 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
VERSION: 02.33.01
VERSION: 02.34.00
PATH: /README.md
BRIEF: MokoWaaS platform plugin for Joomla
-->
+1 -1
View File
@@ -23,7 +23,7 @@ DEFGROUP: [PROJECT_NAME]
INGROUP: [PROJECT_NAME].Documentation
REPO: [REPOSITORY_URL]
PATH: /SECURITY.md
VERSION: 02.33.01
VERSION: 02.34.00
BRIEF: Security vulnerability reporting and handling policy
-->
+2 -2
View File
@@ -11,13 +11,13 @@
INGROUP: MokoWaaS.Build
REPO: https://github.com/mokoconsulting-tech/mokowaas
FILE: build-guide.md
VERSION: 02.33.01
VERSION: 02.34.00
PATH: /docs/guides/
BRIEF: Build and packaging guide for the MokoWaaS system plugin
NOTE: Defines environment setup, repository layout, packaging rules, and release preparation
-->
# MokoWaaS Build Guide (VERSION: 02.33.01)
# MokoWaaS Build Guide (VERSION: 02.34.00)
## 1. Purpose
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Guides
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.33.01
VERSION: 02.34.00
PATH: /docs/guides/configuration-guide.md
BRIEF: Configuration guide for the MokoWaaS system plugin
NOTE: Defines plugin parameters, expected behaviors, and recommended defaults
-->
# MokoWaaS Configuration Guide (VERSION: 02.33.01)
# MokoWaaS Configuration Guide (VERSION: 02.34.00)
## 1. Objective
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Guides
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.33.01
VERSION: 02.34.00
PATH: /docs/guides/installation-guide.md
BRIEF: Installation guide for the MokoWaaS system plugin
NOTE: First document in the guide set
-->
# MokoWaaS Installation Guide (VERSION: 02.33.01)
# MokoWaaS Installation Guide (VERSION: 02.34.00)
## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Guides
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.33.01
VERSION: 02.34.00
PATH: /docs/guides/operations-guide.md
BRIEF: Operational guide for administering and managing the MokoWaaS system plugin
NOTE: Defines lifecycle, responsibilities, and operational behaviors
-->
# MokoWaaS Operations Guide (VERSION: 02.33.01)
# MokoWaaS Operations Guide (VERSION: 02.34.00)
## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Guides
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.33.01
VERSION: 02.34.00
PATH: /docs/guides/rollback-and-recovery-guide.md
BRIEF: Rollback and recovery guide for restoring stable operation after plugin related incidents
NOTE: Completes the core guide set for WaaS plugin governance
-->
# MokoWaaS Rollback and Recovery Guide (VERSION: 02.33.01)
# MokoWaaS Rollback and Recovery Guide (VERSION: 02.34.00)
## Introduction
+2 -2
View File
@@ -7,13 +7,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Guides
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.33.01
VERSION: 02.34.00
PATH: /docs/guides/testing-guide.md
BRIEF: Testing guide for MokoWaaS v02.01.08
NOTE: Covers manual test procedures for language overrides, install/uninstall, and configuration
-->
# MokoWaaS Testing Guide (VERSION: 02.33.01)
# MokoWaaS Testing Guide (VERSION: 02.34.00)
## 1. Prerequisites
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Guides
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.33.01
VERSION: 02.34.00
PATH: /docs/guides/troubleshooting-guide.md
BRIEF: Troubleshooting guide for diagnosing and resolving issues related to the MokoWaaS plugin
NOTE: Designed for administrators and WaaS operations teams
-->
# MokoWaaS Troubleshooting Guide (VERSION: 02.33.01)
# MokoWaaS Troubleshooting Guide (VERSION: 02.34.00)
## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Guides
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.33.01
VERSION: 02.34.00
PATH: /docs/guides/upgrade-and-versioning-guide.md
BRIEF: Guide for updating, versioning, and maintaining the MokoWaaS plugin
NOTE: Defines release flow, version rules, and upgrade validation
-->
# MokoWaaS Upgrade and Versioning Guide (VERSION: 02.33.01)
# MokoWaaS Upgrade and Versioning Guide (VERSION: 02.34.00)
## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Documentation
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.33.01
VERSION: 02.34.00
PATH: /docs/index.md
BRIEF: Master index of all documentation for the MokoWaaS plugin
NOTE: Automatically maintained index for all guide canvases
-->
# MokoWaaS Documentation Index (VERSION: 02.33.01)
# MokoWaaS Documentation Index (VERSION: 02.34.00)
## Introduction
+2 -2
View File
@@ -11,12 +11,12 @@
INGROUP: MokoWaaS
REPO: https://github.com/mokoconsulting-tech/mokowaas
PATH: /docs/plugin-basic.md
VERSION: 02.33.01
VERSION: 02.34.00
BRIEF: Baseline documentation for the MokoWaaS system plugin
NOTE: Foundational reference for internal and external stakeholders
-->
# MokoWaaS Plugin Overview (VERSION: 02.33.01)
# MokoWaaS Plugin Overview (VERSION: 02.34.00)
## Introduction
+1 -1
View File
@@ -10,7 +10,7 @@ DEFGROUP: MokoWaaS.Documentation
INGROUP: MokoStandards.Templates
REPO: https://github.com/mokoconsulting-tech/MokoWaaS
PATH: /docs/update-server.md
VERSION: 02.33.01
VERSION: 02.34.00
BRIEF: How this extension's Joomla update server file (update.xml) is managed
-->
@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<access component="com_mokowaas">
<section name="component">
<action name="core.admin" title="JACTION_ADMIN" description="JACTION_ADMIN_COMPONENT_DESC" />
<action name="core.manage" title="JACTION_MANAGE" description="JACTION_MANAGE_COMPONENT_DESC" />
<action name="mokowaas.dashboard" title="COM_MOKOWAAS_ACL_DASHBOARD" description="COM_MOKOWAAS_ACL_DASHBOARD_DESC" />
<action name="mokowaas.extensions" title="COM_MOKOWAAS_ACL_EXTENSIONS" description="COM_MOKOWAAS_ACL_EXTENSIONS_DESC" />
<action name="mokowaas.htaccess" title="COM_MOKOWAAS_ACL_HTACCESS" description="COM_MOKOWAAS_ACL_HTACCESS_DESC" />
<action name="mokowaas.tickets" title="COM_MOKOWAAS_ACL_TICKETS" description="COM_MOKOWAAS_ACL_TICKETS_DESC" />
<action name="mokowaas.tickets.create" title="COM_MOKOWAAS_ACL_TICKETS_CREATE" description="COM_MOKOWAAS_ACL_TICKETS_CREATE_DESC" />
<action name="mokowaas.tickets.assign" title="COM_MOKOWAAS_ACL_TICKETS_ASSIGN" description="COM_MOKOWAAS_ACL_TICKETS_ASSIGN_DESC" />
<action name="mokowaas.plugins.toggle" title="COM_MOKOWAAS_ACL_PLUGINS_TOGGLE" description="COM_MOKOWAAS_ACL_PLUGINS_TOGGLE_DESC" />
<action name="mokowaas.cache" title="COM_MOKOWAAS_ACL_CACHE" description="COM_MOKOWAAS_ACL_CACHE_DESC" />
</section>
</access>
@@ -1,47 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<config>
<fieldset name="notifications" label="Email Notifications" description="Configure email recipients for ticket and security notifications.">
<field name="admin_emails" type="text" default=""
label="Admin Email Addresses"
description="Comma-separated email addresses to receive all notifications."
hint="admin@example.com, support@example.com" />
<field name="admin_user_ids" type="text" default=""
label="Admin User IDs"
description="Comma-separated Joomla user IDs to receive notifications."
hint="320, 321" />
<field name="security_alerts" type="radio" default="1"
label="Security Alerts"
description="Send email alerts for WAF blocks and admin logins."
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
<fieldset name="helpdesk" label="Helpdesk Settings" description="Default helpdesk behavior.">
<field name="default_category" type="sql" default=""
label="Default Ticket Category"
description="Category assigned to tickets without a selection."
query="SELECT id AS value, title AS text FROM #__mokowaas_ticket_categories WHERE published = 1 ORDER BY ordering" />
<field name="autoclose_days" type="number" default="7"
label="Auto-Close After (days)"
description="Resolved tickets are auto-closed after this many days. 0 = disabled." />
<field name="kb_search_enabled" type="radio" default="1"
label="KB Search on Ticket Forms"
description="Show knowledge base search before ticket submission."
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
<fieldset name="permissions" label="COM_MOKOWAAS_ACL_TITLE"
description="COM_MOKOWAAS_ACL_DESC">
<field name="rules" type="rules"
label="COM_MOKOWAAS_ACL_TITLE"
validate="rules"
filter="rules"
component="com_mokowaas"
section="component" />
</fieldset>
</config>
@@ -19,23 +19,3 @@ COM_MOKOWAAS_CACHE_CLEARED="Cache cleared successfully."
COM_MOKOWAAS_EXTENSIONS_TITLE="Moko Extensions"
COM_MOKOWAAS_EXTENSIONS_INFO="Install Moko Consulting Joomla packages from the official release server. Updates are handled through Joomla's native System > Update mechanism — each package registers its own update server."
COM_MOKOWAAS_EXTENSIONS_LINK="Moko Extensions"
COM_MOKOWAAS_HTACCESS_TITLE=".htaccess Maker"
COM_MOKOWAAS_TICKETS_TITLE="Helpdesk"
; ACL
COM_MOKOWAAS_ACL_DASHBOARD="View Dashboard"
COM_MOKOWAAS_ACL_DASHBOARD_DESC="Allow viewing the MokoWaaS control panel dashboard."
COM_MOKOWAAS_ACL_EXTENSIONS="Manage Extensions"
COM_MOKOWAAS_ACL_EXTENSIONS_DESC="Allow installing and uninstalling Moko extensions."
COM_MOKOWAAS_ACL_HTACCESS="Manage .htaccess"
COM_MOKOWAAS_ACL_HTACCESS_DESC="Allow editing and saving the .htaccess configuration."
COM_MOKOWAAS_ACL_TICKETS="View Tickets"
COM_MOKOWAAS_ACL_TICKETS_DESC="Allow viewing helpdesk tickets."
COM_MOKOWAAS_ACL_TICKETS_CREATE="Create Tickets"
COM_MOKOWAAS_ACL_TICKETS_CREATE_DESC="Allow creating new helpdesk tickets."
COM_MOKOWAAS_ACL_TICKETS_ASSIGN="Assign Tickets"
COM_MOKOWAAS_ACL_TICKETS_ASSIGN_DESC="Allow assigning tickets to other users."
COM_MOKOWAAS_ACL_PLUGINS_TOGGLE="Toggle Plugins"
COM_MOKOWAAS_ACL_PLUGINS_TOGGLE_DESC="Allow enabling and disabling MokoWaaS feature plugins."
COM_MOKOWAAS_ACL_CACHE="Clear Cache"
COM_MOKOWAAS_ACL_CACHE_DESC="Allow clearing the Joomla cache from the dashboard."
@@ -5,15 +5,3 @@
COM_MOKOWAAS="MokoWaaS"
COM_MOKOWAAS_DESCRIPTION="MokoWaaS admin dashboard and REST API. Control panel for managing site features, health monitoring, and remote management."
COM_MOKOWAAS_DASHBOARD_TITLE="MokoWaaS Control Panel"
COM_MOKOWAAS_MENU_DASHBOARD="Dashboard"
COM_MOKOWAAS_MENU_EXTENSIONS="Moko Extensions"
COM_MOKOWAAS_MENU_PLUGINS="Feature Plugins"
COM_MOKOWAAS_MENU_UPDATES="Joomla Updates"
COM_MOKOWAAS_MENU_CHECKIN="Global Check-in"
COM_MOKOWAAS_MENU_TICKETS="Helpdesk"
COM_MOKOWAAS_MENU_HTACCESS=".htaccess Maker"
COM_MOKOWAAS_MENU_PRIVACY="Privacy Guard"
COM_MOKOWAAS_MENU_WAFLOG="WAF Log"
COM_MOKOWAAS_MENU_DATABASE="Database Tools"
COM_MOKOWAAS_MENU_CLEANUP="Cache Cleanup"
COM_MOKOWAAS_MENU_CACHE="Cache Management"
@@ -1,135 +0,0 @@
--
-- MokoWaaS Helpdesk Tables
--
CREATE TABLE IF NOT EXISTS `#__mokowaas_ticket_categories` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`title` VARCHAR(255) NOT NULL,
`alias` VARCHAR(255) NOT NULL DEFAULT '',
`description` TEXT,
`auto_assign_user` INT DEFAULT NULL,
`sla_response_minutes` INT UNSIGNED NOT NULL DEFAULT 480,
`sla_resolution_minutes` INT UNSIGNED NOT NULL DEFAULT 2880,
`ordering` INT NOT NULL DEFAULT 0,
`published` TINYINT NOT NULL DEFAULT 1,
PRIMARY KEY (`id`),
KEY `idx_alias` (`alias`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokowaas_tickets` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`subject` VARCHAR(512) NOT NULL,
`body` TEXT NOT NULL,
`status` ENUM('open','in_progress','waiting','resolved','closed') NOT NULL DEFAULT 'open',
`priority` ENUM('low','normal','high','urgent') NOT NULL DEFAULT 'normal',
`category_id` INT UNSIGNED DEFAULT NULL,
`created_by` INT NOT NULL DEFAULT 0,
`assigned_to` INT DEFAULT NULL,
`created` DATETIME NOT NULL,
`modified` DATETIME DEFAULT NULL,
`resolved` DATETIME DEFAULT NULL,
`closed` DATETIME DEFAULT NULL,
`sla_response_due` DATETIME DEFAULT NULL,
`sla_resolution_due` DATETIME DEFAULT NULL,
`sla_responded` TINYINT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_status` (`status`),
KEY `idx_priority` (`priority`),
KEY `idx_assigned` (`assigned_to`),
KEY `idx_category` (`category_id`),
KEY `idx_created` (`created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokowaas_ticket_replies` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`ticket_id` INT UNSIGNED NOT NULL,
`user_id` INT NOT NULL DEFAULT 0,
`body` TEXT NOT NULL,
`is_internal` TINYINT NOT NULL DEFAULT 0,
`created` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_ticket` (`ticket_id`),
KEY `idx_created` (`created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokowaas_ticket_canned` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`title` VARCHAR(255) NOT NULL,
`body` TEXT NOT NULL,
`category_id` INT UNSIGNED DEFAULT NULL,
`ordering` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokowaas_ticket_automation` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`title` VARCHAR(255) NOT NULL,
`trigger_event` VARCHAR(50) NOT NULL DEFAULT 'ticket_created',
`conditions` TEXT NOT NULL DEFAULT '[]',
`actions` TEXT NOT NULL DEFAULT '[]',
`enabled` TINYINT NOT NULL DEFAULT 1,
`ordering` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Default automation rules
INSERT IGNORE INTO `#__mokowaas_ticket_automation` (`id`, `title`, `trigger_event`, `conditions`, `actions`, `enabled`, `ordering`) VALUES
(1, 'Auto-close resolved tickets after 7 days', 'scheduled', '[{"field":"status","op":"eq","value":"resolved"},{"field":"age_hours","op":"gt","value":"168"}]', '[{"type":"set_status","value":"closed"},{"type":"add_note","value":"Auto-closed after 7 days with no response."}]', 1, 1),
(2, 'Escalate urgent tickets with no response in 1 hour', 'scheduled', '[{"field":"priority","op":"eq","value":"urgent"},{"field":"sla_responded","op":"eq","value":"0"},{"field":"age_hours","op":"gt","value":"1"}]', '[{"type":"add_note","value":"SLA BREACH: Urgent ticket has no staff response after 1 hour."}]', 1, 2),
(3, 'Notify on high priority ticket creation', 'ticket_created', '[{"field":"priority","op":"in","value":"high,urgent"}]', '[{"type":"add_note","value":"High/urgent ticket created — requires immediate attention."}]', 1, 3);
-- Default categories
INSERT IGNORE INTO `#__mokowaas_ticket_categories` (`id`, `title`, `alias`, `description`, `sla_response_minutes`, `sla_resolution_minutes`, `ordering`) VALUES
(1, 'General Support', 'general-support', 'General questions and assistance', 480, 2880, 1),
(2, 'Bug Report', 'bug-report', 'Report a software bug or issue', 240, 1440, 2),
(3, 'Feature Request', 'feature-request', 'Request a new feature or enhancement', 1440, 10080, 3),
(4, 'Billing', 'billing', 'Billing, invoicing, and payment questions', 240, 1440, 4),
(5, 'Urgent / Outage', 'urgent-outage', 'Site down or critical issue', 60, 240, 5);
--
-- Privacy Guard Tables
--
CREATE TABLE IF NOT EXISTS `#__mokowaas_consent_log` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` INT NOT NULL,
`category` VARCHAR(50) NOT NULL,
`action` ENUM('granted','revoked') NOT NULL,
`ip_address` VARCHAR(45) NOT NULL DEFAULT '',
`created` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_user` (`user_id`),
KEY `idx_category` (`category`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokowaas_data_requests` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` INT NOT NULL,
`type` ENUM('export','delete','anonymize') NOT NULL,
`status` ENUM('pending','processing','completed','denied') NOT NULL DEFAULT 'pending',
`notes` TEXT,
`processed_by` INT DEFAULT NULL,
`created` DATETIME NOT NULL,
`processed` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_user` (`user_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokowaas_retention_policies` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`content_type` VARCHAR(100) NOT NULL,
`retention_days` INT UNSIGNED NOT NULL DEFAULT 365,
`action` ENUM('anonymize','delete','archive') NOT NULL DEFAULT 'anonymize',
`enabled` TINYINT NOT NULL DEFAULT 1,
`description` VARCHAR(255) NOT NULL DEFAULT '',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Default retention policies
INSERT IGNORE INTO `#__mokowaas_retention_policies` (`id`, `content_type`, `retention_days`, `action`, `enabled`, `description`) VALUES
(1, 'action_logs', 90, 'delete', 1, 'Delete action log entries older than 90 days'),
(2, 'waf_logs', 30, 'delete', 1, 'Delete WAF block logs older than 30 days'),
(3, 'sessions', 7, 'delete', 1, 'Purge expired sessions older than 7 days'),
(4, 'inactive_users', 730, 'anonymize', 0, 'Anonymize users inactive for 2 years (disabled by default)'),
(5, 'closed_tickets', 365, 'anonymize', 0, 'Anonymize closed tickets older than 1 year (disabled by default)');
@@ -20,659 +20,106 @@ class DisplayController extends BaseController
{
protected $default_view = 'dashboard';
/**
* ACL map: view name => required permission.
*/
private const VIEW_ACL = [
'dashboard' => 'mokowaas.dashboard',
'extensions' => 'mokowaas.extensions',
'htaccess' => 'mokowaas.htaccess',
'tickets' => 'mokowaas.tickets',
'ticket' => 'mokowaas.tickets',
'privacy' => 'core.admin',
'waflog' => 'core.admin',
'categories' => 'mokowaas.tickets',
'canned' => 'mokowaas.tickets',
'automation' => 'core.admin',
'database' => 'core.admin',
'cleanup' => 'mokowaas.cache',
];
public function display($cachable = false, $urlparams = [])
{
$view = $this->input->get('view', $this->default_view);
$acl = self::VIEW_ACL[$view] ?? 'core.manage';
if (!$this->checkAcl($acl))
{
Factory::getApplication()->enqueueMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
Factory::getApplication()->redirect(Route::_('index.php', false));
return;
}
return parent::display($cachable, $urlparams);
}
// ==================================================================
// Plugin toggle
// ==================================================================
/**
* Toggle a MokoWaaS feature plugin on or off.
*
* Expects POST with extension_id and enabled (0 or 1).
* Returns JSON response for AJAX calls.
*/
public function togglePlugin()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.plugins.toggle'))
$app = Factory::getApplication();
$input = $app->getInput();
$user = $app->getIdentity();
if (!$user->authorise('core.manage', 'com_plugins'))
{
$this->jsonForbidden();
return;
$app->setHeader('Content-Type', 'application/json');
echo json_encode(['success' => false, 'message' => Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN')]);
$app->close();
}
$app = Factory::getApplication();
$model = $this->getModel('Dashboard');
$extensionId = $input->getInt('extension_id', 0);
$enabled = $input->getInt('enabled', 0);
$result = $model->togglePlugin(
$app->getInput()->getInt('extension_id', 0),
$app->getInput()->getInt('enabled', 0)
);
if (!$extensionId)
{
$app->setHeader('Content-Type', 'application/json');
echo json_encode(['success' => false, 'message' => 'Missing extension_id']);
$app->close();
}
$this->jsonResponse($result);
$model = $this->getModel('Dashboard');
$result = $model->togglePlugin($extensionId, $enabled);
$app->setHeader('Content-Type', 'application/json');
echo json_encode($result);
$app->close();
}
// ==================================================================
// Cache
// ==================================================================
/**
* Clear the Joomla cache.
*/
public function clearCache()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.cache'))
$app = Factory::getApplication();
$user = $app->getIdentity();
if (!$user->authorise('core.admin'))
{
$this->jsonForbidden();
return;
$app->setHeader('Content-Type', 'application/json');
echo json_encode(['success' => false, 'message' => Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN')]);
$app->close();
}
$this->jsonResponse($this->getModel('Dashboard')->clearCache());
$model = $this->getModel('Dashboard');
$result = $model->clearCache();
$app->setHeader('Content-Type', 'application/json');
echo json_encode($result);
$app->close();
}
// ==================================================================
// Extensions
// ==================================================================
/**
* Install a Moko extension from a download URL.
*/
public function installExtension()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.extensions'))
$app = Factory::getApplication();
$user = $app->getIdentity();
if (!$user->authorise('core.admin'))
{
$this->jsonForbidden();
return;
$app->setHeader('Content-Type', 'application/json');
echo json_encode(['success' => false, 'message' => Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN')]);
$app->close();
}
$downloadUrl = Factory::getApplication()->getInput()->getString('download_url', '');
$downloadUrl = $app->getInput()->getString('download_url', '');
if (empty($downloadUrl))
{
$this->jsonResponse(['success' => false, 'message' => 'Missing download URL.']);
return;
$app->setHeader('Content-Type', 'application/json');
echo json_encode(['success' => false, 'message' => 'Missing download URL.']);
$app->close();
}
$this->jsonResponse($this->getModel('Extensions')->installFromUrl($downloadUrl));
}
$model = $this->getModel('Extensions');
$result = $model->installFromUrl($downloadUrl);
// ==================================================================
// .htaccess
// ==================================================================
public function saveHtaccess()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.htaccess'))
{
$this->jsonForbidden();
return;
}
$app = Factory::getApplication();
$input = $app->getInput();
$model = $this->getModel('Htaccess');
$options = [];
foreach ($input->getArray() as $key => $value)
{
if (str_starts_with($key, 'opt_'))
{
$options[substr($key, 4)] = $value;
}
}
if (!empty($options))
{
$model->saveOptions($options);
}
$this->jsonResponse($model->saveHtaccess($input->getRaw('content', '')));
}
public function generateHtaccess()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.htaccess'))
{
$this->jsonForbidden();
return;
}
$model = $this->getModel('Htaccess');
$options = Factory::getApplication()->getInput()->getArray();
$model->saveOptions($options);
$app = Factory::getApplication();
$app->setHeader('Content-Type', 'application/json');
echo json_encode([
'htaccess' => $model->generateHtaccess($options),
'nginx' => $model->generateNginx($options),
]);
echo json_encode($result);
$app->close();
}
// ==================================================================
// Tickets
// ==================================================================
public function createTicket()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.tickets.create'))
{
$this->jsonForbidden();
return;
}
$input = Factory::getApplication()->getInput();
$this->jsonResponse($this->getModel('Tickets')->createTicket([
'subject' => $input->getString('subject', ''),
'body' => $input->getRaw('body', ''),
'priority' => $input->getString('priority', 'normal'),
'category_id' => $input->getInt('category_id', 0),
]));
}
public function addTicketReply()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.tickets'))
{
$this->jsonForbidden();
return;
}
$input = Factory::getApplication()->getInput();
$this->jsonResponse($this->getModel('Tickets')->addReply(
$input->getInt('ticket_id', 0),
$input->getRaw('body', ''),
(bool) $input->getInt('is_internal', 0)
));
}
public function updateTicketStatus()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.tickets'))
{
$this->jsonForbidden();
return;
}
$input = Factory::getApplication()->getInput();
$this->jsonResponse($this->getModel('Tickets')->updateStatus(
$input->getInt('ticket_id', 0),
$input->getString('status', '')
));
}
// ==================================================================
// KB Search
// ==================================================================
public function searchKb()
{
$query = Factory::getApplication()->getInput()->getString('q', '');
if (strlen($query) < 3)
{
$this->jsonResponse(['results' => []]);
}
try
{
$db = Factory::getDbo();
$escaped = $db->quote('%' . $db->escape($query, true) . '%');
$results = $db->setQuery(
$db->getQuery(true)
->select([$db->quoteName('l.title'), $db->quoteName('l.url'), $db->quoteName('l.description')])
->from($db->quoteName('#__finder_links', 'l'))
->where($db->quoteName('l.published') . ' = 1')
->where('(' . $db->quoteName('l.title') . ' LIKE ' . $escaped
. ' OR ' . $db->quoteName('l.description') . ' LIKE ' . $escaped . ')')
->order($db->quoteName('l.title') . ' ASC')
->setLimit(8)
)->loadObjectList() ?: [];
foreach ($results as $r)
{
$r->description = mb_substr(strip_tags($r->description ?? ''), 0, 150);
}
$this->jsonResponse(['results' => $results]);
}
catch (\Throwable $e)
{
$this->jsonResponse(['results' => []]);
}
}
// ==================================================================
// Maintenance (#127, #128)
// ==================================================================
public function optimizeDb()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
$model = new \Moko\Component\MokoWaaS\Administrator\Model\MaintenanceModel();
$this->jsonResponse($model->optimizeTables());
}
public function repairDb()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
$model = new \Moko\Component\MokoWaaS\Administrator\Model\MaintenanceModel();
$this->jsonResponse($model->repairTables());
}
public function purgeSessions()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
$model = new \Moko\Component\MokoWaaS\Administrator\Model\MaintenanceModel();
$this->jsonResponse($model->purgeSessions());
}
public function cleanDirectory()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.cache')) { $this->jsonForbidden(); return; }
$dirKey = Factory::getApplication()->getInput()->getString('dir_key', '');
$model = new \Moko\Component\MokoWaaS\Administrator\Model\MaintenanceModel();
$this->jsonResponse($model->cleanDirectory($dirKey));
}
// ==================================================================
// Helpdesk CRUD (#137, #138, #139)
// ==================================================================
public function saveCategory()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.tickets')) { $this->jsonForbidden(); }
$input = Factory::getApplication()->getInput();
$db = Factory::getDbo();
$id = $input->getInt('id', 0);
$data = (object) [
'title' => $input->getString('title', ''),
'alias' => \Joomla\CMS\Filter\OutputFilter::stringURLSafe($input->getString('title', '')),
'sla_response_minutes' => $input->getInt('sla_response_minutes', 480),
'sla_resolution_minutes' => $input->getInt('sla_resolution_minutes', 2880),
'auto_assign_user' => $input->getInt('auto_assign_user', 0) ?: null,
'published' => $input->getInt('published', 1),
];
if ($id) {
$data->id = $id;
$db->updateObject('#__mokowaas_ticket_categories', $data, 'id');
} else {
$data->ordering = 0;
$db->insertObject('#__mokowaas_ticket_categories', $data, 'id');
}
$this->jsonResponse(['success' => true, 'message' => 'Category saved.', 'id' => (int) $data->id]);
}
public function deleteCategory()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.tickets')) { $this->jsonForbidden(); }
$db = Factory::getDbo();
$db->setQuery($db->getQuery(true)->delete('#__mokowaas_ticket_categories')->where('id = ' . Factory::getApplication()->getInput()->getInt('id', 0)))->execute();
$this->jsonResponse(['success' => true, 'message' => 'Category deleted.']);
}
public function saveCanned()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.tickets')) { $this->jsonForbidden(); }
$input = Factory::getApplication()->getInput();
$db = Factory::getDbo();
$data = (object) [
'title' => $input->getString('title', ''),
'body' => $input->getRaw('body', ''),
'category_id' => $input->getInt('category_id', 0) ?: null,
'ordering' => 0,
];
$id = $input->getInt('id', 0);
if ($id) { $data->id = $id; $db->updateObject('#__mokowaas_ticket_canned', $data, 'id'); }
else { $db->insertObject('#__mokowaas_ticket_canned', $data, 'id'); }
$this->jsonResponse(['success' => true, 'message' => 'Canned response saved.', 'id' => (int) $data->id]);
}
public function deleteCanned()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.tickets')) { $this->jsonForbidden(); }
$db = Factory::getDbo();
$db->setQuery($db->getQuery(true)->delete('#__mokowaas_ticket_canned')->where('id = ' . Factory::getApplication()->getInput()->getInt('id', 0)))->execute();
$this->jsonResponse(['success' => true, 'message' => 'Canned response deleted.']);
}
public function saveAutomation()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); }
$input = Factory::getApplication()->getInput();
$db = Factory::getDbo();
$data = (object) [
'title' => $input->getString('title', ''),
'trigger_event' => $input->getString('trigger_event', 'ticket_created'),
'conditions' => $input->getRaw('conditions', '[]'),
'actions' => $input->getRaw('actions', '[]'),
'enabled' => 1,
'ordering' => 0,
];
$id = $input->getInt('id', 0);
if ($id) { $data->id = $id; $db->updateObject('#__mokowaas_ticket_automation', $data, 'id'); }
else { $db->insertObject('#__mokowaas_ticket_automation', $data, 'id'); }
$this->jsonResponse(['success' => true, 'message' => 'Rule saved.', 'id' => (int) $data->id]);
}
public function deleteAutomation()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); }
$db = Factory::getDbo();
$db->setQuery($db->getQuery(true)->delete('#__mokowaas_ticket_automation')->where('id = ' . Factory::getApplication()->getInput()->getInt('id', 0)))->execute();
$this->jsonResponse(['success' => true, 'message' => 'Rule deleted.']);
}
public function toggleAutomation()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); }
$input = Factory::getApplication()->getInput();
$db = Factory::getDbo();
$db->setQuery($db->getQuery(true)->update('#__mokowaas_ticket_automation')
->set('enabled = ' . $input->getInt('enabled', 0))
->where('id = ' . $input->getInt('id', 0)))->execute();
$this->jsonResponse(['success' => true, 'message' => 'Rule updated.']);
}
// ==================================================================
// Settings Import/Export (#132)
// ==================================================================
public function exportSettings()
{
Session::checkToken('get') or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin'))
{
$this->jsonForbidden();
return;
}
$db = Factory::getDbo();
$settings = [];
// Export all MokoWaaS plugin params
$plugins = ['mokowaas', 'mokowaas_firewall', 'mokowaas_tenant', 'mokowaas_devtools', 'mokowaas_offline'];
foreach ($plugins as $element)
{
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote($element))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
);
$settings['plugins'][$element] = json_decode($db->loadResult() ?? '{}', true);
}
// Export component params
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
);
$settings['component'] = json_decode($db->loadResult() ?? '{}', true);
$settings['exported'] = gmdate('Y-m-d\TH:i:s\Z');
$settings['site'] = Factory::getConfig()->get('sitename', '');
$this->jsonResponse(['success' => true, 'settings' => $settings]);
}
public function importSettings()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin'))
{
$this->jsonForbidden();
return;
}
$json = Factory::getApplication()->getInput()->getRaw('settings_json', '');
$data = json_decode($json, true);
if (empty($data) || empty($data['plugins']))
{
$this->jsonResponse(['success' => false, 'message' => 'Invalid settings JSON.']);
return;
}
$db = Factory::getDbo();
$count = 0;
foreach ($data['plugins'] ?? [] as $element => $params)
{
if (!is_array($params))
{
continue;
}
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($params)))
->where($db->quoteName('element') . ' = ' . $db->quote($element))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
)->execute();
$count++;
}
if (!empty($data['component']) && is_array($data['component']))
{
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($data['component'])))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
)->execute();
$count++;
}
$this->jsonResponse(['success' => true, 'message' => "Imported settings for {$count} extensions."]);
}
// ==================================================================
// WAF Log
// ==================================================================
public function purgeWafLog()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin'))
{
$this->jsonForbidden();
return;
}
$days = Factory::getApplication()->getInput()->getInt('days', 30);
$model = new \Moko\Component\MokoWaaS\Administrator\Model\WaflogModel();
$this->jsonResponse($model->purgeLogs($days));
}
public function banIpFromLog()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin'))
{
$this->jsonForbidden();
return;
}
$ip = Factory::getApplication()->getInput()->getString('ip', '');
$model = new \Moko\Component\MokoWaaS\Administrator\Model\WaflogModel();
$this->jsonResponse($model->banIp($ip));
}
// ==================================================================
// Privacy Guard
// ==================================================================
public function processDataRequest()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin'))
{
$this->jsonForbidden();
return;
}
$input = Factory::getApplication()->getInput();
$model = new \Moko\Component\MokoWaaS\Administrator\Model\PrivacyModel();
$this->jsonResponse($model->processRequest(
$input->getInt('request_id', 0),
$input->getString('action', 'deny')
));
}
public function exportUserData()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin'))
{
$this->jsonForbidden();
return;
}
$model = new \Moko\Component\MokoWaaS\Administrator\Model\PrivacyModel();
$this->jsonResponse($model->exportUserData(
Factory::getApplication()->getInput()->getInt('user_id', 0)
));
}
// ==================================================================
// Importers
// ==================================================================
public function importAts()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.tickets'))
{
$this->jsonForbidden();
return;
}
$this->jsonResponse($this->getModel('Import')->importAts());
}
public function importAdminTools()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin'))
{
$this->jsonForbidden();
return;
}
$this->jsonResponse($this->getModel('Import')->importAdminTools());
}
// ==================================================================
// Helpers
// ==================================================================
/**
* Check a MokoWaaS ACL permission for the current user.
*/
private function checkAcl(string $action): bool
{
$user = Factory::getApplication()->getIdentity();
// Super admins always pass
if ($user->authorise('core.admin', 'com_mokowaas'))
{
return true;
}
return $user->authorise($action, 'com_mokowaas');
}
/**
* Send a JSON response and close.
*/
private function jsonResponse(array $data): void
{
$app = Factory::getApplication();
$app->setHeader('Content-Type', 'application/json');
echo json_encode($data);
$app->close();
}
/**
* Send a 403 JSON response and close.
*/
private function jsonForbidden(): void
{
$this->jsonResponse(['success' => false, 'message' => Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN')]);
return;
}
}
@@ -22,60 +22,39 @@ class DashboardModel extends BaseDatabaseModel
*/
private const PLUGIN_META = [
'mokowaas' => [
'icon' => 'icon-shield-alt',
'category' => 'core',
'label' => 'Core — Branding & Identity',
'description' => 'White-label branding, master user enforcement, emergency access, and plugin protection.',
'protected' => true,
'configure_only' => false,
'icon' => 'icon-shield-alt',
'category' => 'core',
'label' => 'Core — Branding & Identity',
'description' => 'White-label branding, master user enforcement, emergency access, and plugin protection.',
'protected' => true,
],
'mokowaas_firewall' => [
'icon' => 'icon-lock',
'category' => 'security',
'label' => 'Firewall',
'description' => 'Web Application Firewall — SQLi, XSS, RFI, DFI shields, IP blocklist, admin secret URL, file protection.',
'protected' => false,
'configure_only' => false,
'icon' => 'icon-lock',
'category' => 'security',
'label' => 'Firewall',
'description' => 'Web Application Firewall — SQLi, XSS, RFI, DFI shields, IP blocklist, admin secret URL, file protection.',
'protected' => false,
],
'mokowaas_tenant' => [
'icon' => 'icon-users',
'category' => 'security',
'label' => 'Tenant Restrictions',
'description' => 'Installer, sysinfo, config, and template access restrictions for non-master users.',
'protected' => false,
'configure_only' => false,
],
'mokowaas_offline' => [
'icon' => 'icon-globe',
'category' => 'security',
'label' => 'Offline Bypass',
'description' => 'Keep selected pages (TOS, Privacy Policy) accessible during offline mode.',
'protected' => false,
'configure_only' => true,
'icon' => 'icon-users',
'category' => 'security',
'label' => 'Tenant Restrictions',
'description' => 'Installer, sysinfo, config, and template access restrictions for non-master users.',
'protected' => false,
],
'mokowaas_devtools' => [
'icon' => 'icon-wrench',
'category' => 'tools',
'label' => 'Developer Tools',
'description' => 'Dev mode, hit counter reset, content version cleanup. Features are controlled inside the plugin settings.',
'protected' => false,
'configure_only' => true,
'icon' => 'icon-wrench',
'category' => 'tools',
'label' => 'Developer Tools',
'description' => 'Dev mode, hit counter reset, content version cleanup.',
'protected' => false,
],
'mokowaasdemo' => [
'icon' => 'icon-undo',
'category' => 'content',
'label' => 'Demo Reset Task',
'description' => 'Scheduled demo site reset with content snapshots.',
'protected' => false,
'configure_only' => true,
],
'mokowaassync' => [
'icon' => 'icon-sync',
'category' => 'content',
'label' => 'Content Sync Task',
'description' => 'Scheduled content synchronisation to remote MokoWaaS sites.',
'protected' => false,
'configure_only' => true,
'mokowaas_monitor' => [
'icon' => 'icon-heartbeat',
'category' => 'monitoring',
'label' => 'Health Monitor',
'description' => 'Site health checks, Grafana heartbeat integration, and diagnostics.',
'protected' => false,
],
];
@@ -118,8 +97,7 @@ class DashboardModel extends BaseDatabaseModel
'(' . $db->quoteName('type') . ' = ' . $db->quote('plugin')
. ' AND ' . $db->quoteName('folder') . ' = ' . $db->quote('system')
. ' AND (' . $db->quoteName('element') . ' = ' . $db->quote('mokowaas')
. ' OR ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mokowaas\_%') . ')'
. ' AND ' . $db->quoteName('element') . ' != ' . $db->quote('mokowaas_monitor') . ')'
. ' OR ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mokowaas\_%') . '))'
// Webservices plugins
. ' OR (' . $db->quoteName('type') . ' = ' . $db->quote('plugin')
. ' AND ' . $db->quoteName('folder') . ' = ' . $db->quote('webservices')
@@ -142,10 +120,8 @@ class DashboardModel extends BaseDatabaseModel
$manifest = json_decode($row->manifest_cache ?? '{}');
$version = $manifest->version ?? '';
// Only system plugins and task plugins match PLUGIN_META by element
$metaKey = ($row->folder === 'system' || $row->folder === 'task')
? $row->element
: $row->folder . '_' . $row->element;
// Build a lookup key: system plugins use element, others use folder_element
$metaKey = $row->element;
$meta = self::PLUGIN_META[$metaKey] ?? null;
@@ -159,20 +135,19 @@ class DashboardModel extends BaseDatabaseModel
$categoryInfo = self::CATEGORIES[$categoryKey] ?? self::CATEGORIES['tools'];
$plugins[] = (object) [
'extension_id' => (int) $row->extension_id,
'name' => $meta['label'] ?? $row->name,
'element' => $row->element,
'folder' => $row->folder,
'type' => $row->type,
'enabled' => (int) $row->enabled,
'protected' => (bool) ($meta['protected'] ?? false),
'configure_only' => (bool) ($meta['configure_only'] ?? false),
'version' => $version,
'icon' => $meta['icon'] ?? 'icon-puzzle-piece',
'category' => $categoryKey,
'extension_id' => (int) $row->extension_id,
'name' => $meta['label'] ?? $row->name,
'element' => $row->element,
'folder' => $row->folder,
'type' => $row->type,
'enabled' => (int) $row->enabled,
'protected' => (int) $row->protected || ($meta['protected'] ?? false),
'version' => $version,
'icon' => $meta['icon'] ?? 'icon-puzzle-piece',
'category' => $categoryKey,
'categoryLabel' => $categoryInfo['label'],
'categoryBadge' => $categoryInfo['badge'],
'description' => $meta['description'] ?? '',
'description' => $meta['description'] ?? '',
];
}
@@ -267,46 +242,11 @@ class DashboardModel extends BaseDatabaseModel
{
try
{
// Purge all file-based cache directories
$root = JPATH_ROOT;
$dirs = [
$root . '/cache',
$root . '/administrator/cache',
];
$app = Factory::getApplication();
$app->get('cache_handler', 'file');
foreach ($dirs as $dir)
{
if (!is_dir($dir))
{
continue;
}
$it = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($it as $file)
{
$name = $file->getFilename();
if ($name === 'index.html' || $name === '.htaccess')
{
continue;
}
if ($file->isDir())
{
@rmdir($file->getPathname());
}
else
{
@unlink($file->getPathname());
}
}
}
// Also run Joomla's built-in cache GC for non-file handlers
// Clear site and admin caches
$cache = Factory::getContainer()->get(\Joomla\CMS\Cache\CacheControllerFactoryInterface::class);
Factory::getCache('', '')->gc();
Factory::getCache('', '', 'administrator')->gc();
@@ -316,7 +256,7 @@ class DashboardModel extends BaseDatabaseModel
\opcache_reset();
}
return ['success' => true, 'message' => 'All cache cleared successfully.'];
return ['success' => true, 'message' => 'Cache cleared successfully.'];
}
catch (\Throwable $e)
{
@@ -482,84 +422,4 @@ class DashboardModel extends BaseDatabaseModel
return [];
}
}
/**
* WAF blocks per day for the last 14 days.
*/
public function getWafBlocksByDay(int $days = 14): array
{
try
{
$db = $this->getDatabase();
$db->setQuery(
"SELECT DATE(" . $db->quoteName('created') . ") AS day, COUNT(*) AS total"
. " FROM " . $db->quoteName('#__mokowaas_waf_log')
. " WHERE " . $db->quoteName('created') . " >= DATE_SUB(NOW(), INTERVAL $days DAY)"
. " GROUP BY day ORDER BY day"
);
$rows = $db->loadObjectList() ?: [];
// Fill in missing days with zero
$result = [];
$date = new \DateTime("-{$days} days");
$now = new \DateTime('now');
$map = [];
foreach ($rows as $r)
{
$map[$r->day] = (int) $r->total;
}
while ($date <= $now)
{
$key = $date->format('Y-m-d');
$result[] = (object) ['day' => $date->format('M d'), 'total' => $map[$key] ?? 0];
$date->modify('+1 day');
}
return $result;
}
catch (\Throwable $e)
{
return [];
}
}
/**
* Admin logins per day for the last 14 days.
*/
public function getLoginsByDay(int $days = 14): array
{
try
{
$db = $this->getDatabase();
$db->setQuery(
"SELECT DATE(" . $db->quoteName('log_date') . ") AS day, COUNT(*) AS total"
. " FROM " . $db->quoteName('#__action_logs')
. " WHERE " . $db->quoteName('message_language_key') . " = 'PLG_ACTIONLOG_JOOMLA_USER_LOGGED_IN'"
. " AND " . $db->quoteName('log_date') . " >= DATE_SUB(NOW(), INTERVAL $days DAY)"
. " GROUP BY day ORDER BY day"
);
$rows = $db->loadObjectList() ?: [];
$result = [];
$date = new \DateTime("-{$days} days");
$now = new \DateTime('now');
$map = [];
foreach ($rows as $r)
{
$map[$r->day] = (int) $r->total;
}
while ($date <= $now)
{
$key = $date->format('Y-m-d');
$result[] = (object) ['day' => $date->format('M d'), 'total' => $map[$key] ?? 0];
$date->modify('+1 day');
}
return $result;
}
catch (\Throwable $e)
{
return [];
}
}
}
@@ -33,7 +33,7 @@ class ExtensionsModel extends BaseDatabaseModel
'type' => 'package',
'icon' => 'icon-shield-alt',
'category' => 'Platform',
'article' => 'https://mokoconsulting.tech/support/products/mokowaas-platform',
'article' => 'https://mokoconsulting.tech/kb/mokowaas-platform',
'protected' => true,
],
'MokoOnyx' => [
@@ -43,7 +43,7 @@ class ExtensionsModel extends BaseDatabaseModel
'type' => 'template',
'icon' => 'icon-paint-brush',
'category' => 'Templates',
'article' => 'https://mokoconsulting.tech/support/products/mokoonyx-template',
'article' => 'https://mokoconsulting.tech/kb/mokoonyx-template',
'protected' => false,
],
'MokoJoomTOS' => [
@@ -53,7 +53,7 @@ class ExtensionsModel extends BaseDatabaseModel
'type' => 'component',
'icon' => 'icon-file-contract',
'category' => 'Components',
'article' => 'https://mokoconsulting.tech/support/products/mokojoomtos',
'article' => 'https://mokoconsulting.tech/kb/mokojoomtos',
'protected' => false,
],
'MokoJoomHero' => [
@@ -63,7 +63,7 @@ class ExtensionsModel extends BaseDatabaseModel
'type' => 'module',
'icon' => 'icon-image',
'category' => 'Modules',
'article' => 'https://mokoconsulting.tech/support/products/mokojoomhero',
'article' => 'https://mokoconsulting.tech/kb/mokojoomhero',
'protected' => false,
],
'MokoWaaSAnnounce' => [
@@ -73,7 +73,7 @@ class ExtensionsModel extends BaseDatabaseModel
'type' => 'module',
'icon' => 'icon-bullhorn',
'category' => 'Modules',
'article' => 'https://mokoconsulting.tech/support/products/mokowaas-announce',
'article' => 'https://mokoconsulting.tech/kb/mokowaas-announce',
'protected' => false,
],
'MokoDPCalendarAPI' => [
@@ -83,7 +83,7 @@ class ExtensionsModel extends BaseDatabaseModel
'type' => 'plugin',
'icon' => 'icon-calendar',
'category' => 'Plugins',
'article' => 'https://mokoconsulting.tech/support/products/mokodpcalendarapi',
'article' => 'https://mokoconsulting.tech/kb/mokodpcalendarapi',
'protected' => false,
],
'MokoGalleryCalendar' => [
@@ -93,17 +93,7 @@ class ExtensionsModel extends BaseDatabaseModel
'type' => 'plugin',
'icon' => 'icon-images',
'category' => 'Plugins',
'article' => 'https://mokoconsulting.tech/support/products/mokogallerycalendar',
'protected' => false,
],
'MokoJoomOpenGraph' => [
'label' => 'MokoJoomOpenGraph',
'description' => 'Open Graph meta tags for articles, categories, and pages. Controls Facebook, Twitter, and LinkedIn link previews.',
'element' => 'pkg_mokoog',
'type' => 'package',
'icon' => 'icon-share-alt',
'category' => 'Components',
'article' => 'https://mokoconsulting.tech/support/products/mokojoomopengraph',
'article' => 'https://mokoconsulting.tech/kb/mokogallerycalendar',
'protected' => false,
],
];
@@ -1,522 +0,0 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Joomla\Registry\Registry;
/**
* .htaccess / NginX configuration generator.
*
* @since 02.32.00
*/
class HtaccessModel extends BaseDatabaseModel
{
private const DEFAULTS = [
// Security
'disable_directory_listing' => 1,
'block_sensitive_files' => 1,
'block_php_in_uploads' => 1,
'disable_server_signature' => 1,
'prevent_clickjacking' => 1,
'prevent_mime_sniffing' => 1,
'xss_protection' => 1,
'disable_trace_track' => 1,
'referrer_policy' => 'strict-origin-when-cross-origin',
'hsts_enabled' => 0,
'hsts_max_age' => 31536000,
'hsts_subdomains' => 0,
'csp_enabled' => 0,
'csp_value' => '',
'permissions_policy' => 0,
'permissions_value' => '',
// Performance
'enable_gzip' => 1,
'enable_expires' => 1,
'expires_html' => 3600,
'expires_css_js' => 2592000,
'expires_images' => 31536000,
'etag_control' => 0,
// SEO
'www_redirect' => 'off',
'redirect_index_php' => 1,
'force_trailing_slash' => 0,
// Custom
'custom_rules' => '',
];
/**
* Get saved options or defaults.
*/
public function getOptions(): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'));
$db->setQuery($query);
$params = new Registry($db->loadResult() ?? '{}');
$htaccess = $params->get('htaccess', null);
if ($htaccess)
{
return array_merge(self::DEFAULTS, (array) json_decode(json_encode($htaccess), true));
}
return self::DEFAULTS;
}
/**
* Save options to component params.
*/
public function saveOptions(array $options): array
{
try
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'));
$db->setQuery($query);
$params = new Registry($db->loadResult() ?? '{}');
$clean = [];
foreach (self::DEFAULTS as $key => $default)
{
$clean[$key] = $options[$key] ?? $default;
}
$params->set('htaccess', $clean);
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('params') . ' = ' . $db->quote($params->toString()))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
)->execute();
return ['success' => true, 'message' => 'Options saved.'];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Save failed: ' . $e->getMessage()];
}
}
/**
* Read the current .htaccess file.
*/
public function readCurrentHtaccess(): string
{
$path = JPATH_ROOT . '/.htaccess';
return file_exists($path) ? file_get_contents($path) : '';
}
/**
* Write .htaccess to disk with backup.
*/
public function saveHtaccess(string $content): array
{
$path = JPATH_ROOT . '/.htaccess';
$backup = JPATH_ROOT . '/.htaccess.mokowaas.bak';
try
{
// Backup existing
if (file_exists($path))
{
copy($path, $backup);
}
$result = file_put_contents($path, $content);
if ($result === false)
{
// Restore backup
if (file_exists($backup))
{
copy($backup, $path);
}
return ['success' => false, 'message' => '.htaccess is not writable.'];
}
return ['success' => true, 'message' => '.htaccess saved. Backup at .htaccess.mokowaas.bak'];
}
catch (\Throwable $e)
{
if (file_exists($backup))
{
@copy($backup, $path);
}
return ['success' => false, 'message' => 'Write failed: ' . $e->getMessage()];
}
}
/**
* Generate .htaccess content from options.
*/
public function generateHtaccess(array $opts): string
{
$lines = [];
$lines[] = '##';
$lines[] = '## MokoWaaS Generated .htaccess';
$lines[] = '## Generated: ' . gmdate('Y-m-d H:i:s') . ' UTC';
$lines[] = '## DO NOT EDIT — regenerate from MokoWaaS > .htaccess Maker';
$lines[] = '##';
$lines[] = '';
// --- Security ---
if (!empty($opts['disable_directory_listing']))
{
$lines[] = '## Disable directory listing';
$lines[] = 'Options -Indexes';
$lines[] = '';
}
if (!empty($opts['disable_server_signature']))
{
$lines[] = '## Hide server signature';
$lines[] = 'ServerSignature Off';
$lines[] = '<IfModule mod_headers.c>';
$lines[] = ' Header unset X-Powered-By';
$lines[] = ' Header unset Server';
$lines[] = '</IfModule>';
$lines[] = '';
}
if (!empty($opts['block_sensitive_files']))
{
$lines[] = '## Block access to sensitive files';
$lines[] = '<FilesMatch "^(htaccess\.txt|web\.config\.txt|configuration\.php-dist|README\.txt|LICENSE\.txt|joomla\.xml|robots\.txt\.dist)$">';
$lines[] = ' <IfModule mod_authz_core.c>';
$lines[] = ' Require all denied';
$lines[] = ' </IfModule>';
$lines[] = '</FilesMatch>';
$lines[] = '';
}
if (!empty($opts['block_php_in_uploads']))
{
$lines[] = '## Block PHP execution in upload directories';
$dirs = ['images', 'media', 'tmp', 'cache', 'logs'];
foreach ($dirs as $dir)
{
$lines[] = '<Directory "' . $dir . '">';
$lines[] = ' <FilesMatch "\.php$">';
$lines[] = ' <IfModule mod_authz_core.c>';
$lines[] = ' Require all denied';
$lines[] = ' </IfModule>';
$lines[] = ' </FilesMatch>';
$lines[] = '</Directory>';
}
$lines[] = '';
}
if (!empty($opts['disable_trace_track']))
{
$lines[] = '## Disable TRACE and TRACK methods';
$lines[] = '<IfModule mod_rewrite.c>';
$lines[] = ' RewriteEngine On';
$lines[] = ' RewriteCond %{REQUEST_METHOD} ^(TRACE|TRACK)';
$lines[] = ' RewriteRule .* - [F]';
$lines[] = '</IfModule>';
$lines[] = '';
}
// Security headers
$headers = [];
if (!empty($opts['prevent_clickjacking']))
{
$headers[] = ' Header always set X-Frame-Options "SAMEORIGIN"';
}
if (!empty($opts['prevent_mime_sniffing']))
{
$headers[] = ' Header always set X-Content-Type-Options "nosniff"';
}
if (!empty($opts['xss_protection']))
{
$headers[] = ' Header always set X-XSS-Protection "1; mode=block"';
}
$referrer = $opts['referrer_policy'] ?? '';
if (!empty($referrer) && $referrer !== 'off')
{
$headers[] = ' Header always set Referrer-Policy "' . $referrer . '"';
}
if (!empty($opts['hsts_enabled']))
{
$maxAge = (int) ($opts['hsts_max_age'] ?? 31536000);
$hsts = 'max-age=' . $maxAge;
if (!empty($opts['hsts_subdomains']))
{
$hsts .= '; includeSubDomains';
}
$headers[] = ' Header always set Strict-Transport-Security "' . $hsts . '"';
}
if (!empty($opts['csp_enabled']) && !empty($opts['csp_value']))
{
$headers[] = ' Header always set Content-Security-Policy "' . str_replace('"', '', $opts['csp_value']) . '"';
}
if (!empty($opts['permissions_policy']) && !empty($opts['permissions_value']))
{
$headers[] = ' Header always set Permissions-Policy "' . str_replace('"', '', $opts['permissions_value']) . '"';
}
if (!empty($headers))
{
$lines[] = '## Security headers';
$lines[] = '<IfModule mod_headers.c>';
$lines = array_merge($lines, $headers);
$lines[] = '</IfModule>';
$lines[] = '';
}
// --- Performance ---
if (!empty($opts['enable_gzip']))
{
$lines[] = '## GZip compression';
$lines[] = '<IfModule mod_deflate.c>';
$lines[] = ' AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css';
$lines[] = ' AddOutputFilterByType DEFLATE text/javascript application/javascript application/x-javascript';
$lines[] = ' AddOutputFilterByType DEFLATE application/json application/xml application/rss+xml';
$lines[] = ' AddOutputFilterByType DEFLATE image/svg+xml application/font-woff application/font-woff2';
$lines[] = '</IfModule>';
$lines[] = '';
}
if (!empty($opts['enable_expires']))
{
$html = (int) ($opts['expires_html'] ?? 3600);
$cssJs = (int) ($opts['expires_css_js'] ?? 2592000);
$images = (int) ($opts['expires_images'] ?? 31536000);
$lines[] = '## Browser caching';
$lines[] = '<IfModule mod_expires.c>';
$lines[] = ' ExpiresActive On';
$lines[] = ' ExpiresDefault "access plus ' . $html . ' seconds"';
$lines[] = ' ExpiresByType text/html "access plus ' . $html . ' seconds"';
$lines[] = ' ExpiresByType text/css "access plus ' . $cssJs . ' seconds"';
$lines[] = ' ExpiresByType text/javascript "access plus ' . $cssJs . ' seconds"';
$lines[] = ' ExpiresByType application/javascript "access plus ' . $cssJs . ' seconds"';
$lines[] = ' ExpiresByType image/jpeg "access plus ' . $images . ' seconds"';
$lines[] = ' ExpiresByType image/png "access plus ' . $images . ' seconds"';
$lines[] = ' ExpiresByType image/gif "access plus ' . $images . ' seconds"';
$lines[] = ' ExpiresByType image/webp "access plus ' . $images . ' seconds"';
$lines[] = ' ExpiresByType image/svg+xml "access plus ' . $images . ' seconds"';
$lines[] = ' ExpiresByType font/woff2 "access plus ' . $images . ' seconds"';
$lines[] = '</IfModule>';
$lines[] = '';
}
if (!empty($opts['etag_control']))
{
$lines[] = '## Disable ETags (for load-balanced environments)';
$lines[] = '<IfModule mod_headers.c>';
$lines[] = ' Header unset ETag';
$lines[] = '</IfModule>';
$lines[] = 'FileETag None';
$lines[] = '';
}
// --- SEO / Redirects ---
$wwwRedirect = $opts['www_redirect'] ?? 'off';
if ($wwwRedirect !== 'off' || !empty($opts['redirect_index_php']) || !empty($opts['force_trailing_slash']))
{
$lines[] = '## SEO redirects';
$lines[] = '<IfModule mod_rewrite.c>';
$lines[] = ' RewriteEngine On';
if ($wwwRedirect === 'www')
{
$lines[] = '';
$lines[] = ' ## Force www';
$lines[] = ' RewriteCond %{HTTP_HOST} !^www\. [NC]';
$lines[] = ' RewriteRule ^(.*)$ https://www.%{HTTP_HOST}/$1 [R=301,L]';
}
elseif ($wwwRedirect === 'non-www')
{
$lines[] = '';
$lines[] = ' ## Force non-www';
$lines[] = ' RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]';
$lines[] = ' RewriteRule ^(.*)$ https://%1/$1 [R=301,L]';
}
if (!empty($opts['redirect_index_php']))
{
$lines[] = '';
$lines[] = ' ## Redirect /index.php to root';
$lines[] = ' RewriteCond %{THE_REQUEST} ^[A-Z]{3,}\s/+index\.php\s [NC]';
$lines[] = ' RewriteRule ^index\.php/?(.*)$ /$1 [R=301,L]';
}
if (!empty($opts['force_trailing_slash']))
{
$lines[] = '';
$lines[] = ' ## Force trailing slash';
$lines[] = ' RewriteCond %{REQUEST_FILENAME} !-f';
$lines[] = ' RewriteCond %{REQUEST_URI} !(.*)/$';
$lines[] = ' RewriteRule ^(.*)$ /$1/ [R=301,L]';
}
$lines[] = '</IfModule>';
$lines[] = '';
}
// --- Custom rules ---
$custom = trim($opts['custom_rules'] ?? '');
if (!empty($custom))
{
$lines[] = '## Custom rules';
$lines[] = $custom;
$lines[] = '';
}
return implode("\n", $lines);
}
/**
* Generate equivalent NginX configuration snippet.
*/
public function generateNginx(array $opts): string
{
$lines = [];
$lines[] = '## MokoWaaS Generated NginX Configuration';
$lines[] = '## Add these directives inside your server { } block';
$lines[] = '';
if (!empty($opts['disable_directory_listing']))
{
$lines[] = '# Disable directory listing';
$lines[] = 'autoindex off;';
$lines[] = '';
}
if (!empty($opts['disable_server_signature']))
{
$lines[] = '# Hide server version';
$lines[] = 'server_tokens off;';
$lines[] = '';
}
if (!empty($opts['block_sensitive_files']))
{
$lines[] = '# Block sensitive files';
$lines[] = 'location ~* (htaccess\.txt|web\.config\.txt|configuration\.php-dist|README\.txt|LICENSE\.txt)$ {';
$lines[] = ' deny all;';
$lines[] = '}';
$lines[] = '';
}
if (!empty($opts['block_php_in_uploads']))
{
$lines[] = '# Block PHP in upload directories';
$lines[] = 'location ~* ^/(images|media|tmp|cache|logs)/.*\.php$ {';
$lines[] = ' deny all;';
$lines[] = '}';
$lines[] = '';
}
// Headers
$hdrs = [];
if (!empty($opts['prevent_clickjacking']))
{
$hdrs[] = 'add_header X-Frame-Options "SAMEORIGIN" always;';
}
if (!empty($opts['prevent_mime_sniffing']))
{
$hdrs[] = 'add_header X-Content-Type-Options "nosniff" always;';
}
if (!empty($opts['xss_protection']))
{
$hdrs[] = 'add_header X-XSS-Protection "1; mode=block" always;';
}
$referrer = $opts['referrer_policy'] ?? '';
if (!empty($referrer) && $referrer !== 'off')
{
$hdrs[] = 'add_header Referrer-Policy "' . $referrer . '" always;';
}
if (!empty($opts['hsts_enabled']))
{
$maxAge = (int) ($opts['hsts_max_age'] ?? 31536000);
$hsts = 'max-age=' . $maxAge;
if (!empty($opts['hsts_subdomains']))
{
$hsts .= '; includeSubDomains';
}
$hdrs[] = 'add_header Strict-Transport-Security "' . $hsts . '" always;';
}
if (!empty($hdrs))
{
$lines[] = '# Security headers';
$lines = array_merge($lines, $hdrs);
$lines[] = '';
}
if (!empty($opts['enable_gzip']))
{
$lines[] = '# GZip compression';
$lines[] = 'gzip on;';
$lines[] = 'gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;';
$lines[] = 'gzip_min_length 256;';
$lines[] = '';
}
if (!empty($opts['enable_expires']))
{
$cssJs = (int) ($opts['expires_css_js'] ?? 2592000);
$images = (int) ($opts['expires_images'] ?? 31536000);
$lines[] = '# Browser caching';
$lines[] = 'location ~* \.(css|js)$ {';
$lines[] = ' expires ' . round($cssJs / 86400) . 'd;';
$lines[] = '}';
$lines[] = 'location ~* \.(jpg|jpeg|png|gif|webp|svg|ico|woff2)$ {';
$lines[] = ' expires ' . round($images / 86400) . 'd;';
$lines[] = '}';
$lines[] = '';
}
return implode("\n", $lines);
}
}
@@ -1,688 +0,0 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Log\Log;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Joomla\Registry\Registry;
/**
* Importer for migrating from Akeeba Admin Tools to MokoWaaS.
*
* Reads Admin Tools WAF config, htaccess settings, IP blocklists,
* and security headers — maps them to MokoWaaS firewall plugin params
* and htaccess maker options.
*
* @since 02.32.00
*/
class ImportModel extends BaseDatabaseModel
{
/**
* Check if Admin Tools data is available for import.
* Returns null if already imported or no data found.
*/
public function checkAdminToolsAvailable(): ?object
{
if ($this->wasImported('admintools'))
{
return null;
}
$db = $this->getDatabase();
try
{
$result = (object) [
'component' => false,
'waf_config' => false,
'storage' => false,
'ip_blocks' => 0,
];
// Check component
$db->setQuery("SELECT COUNT(*) FROM #__extensions WHERE element = 'com_admintools' AND type = 'component'");
$result->component = (int) $db->loadResult() > 0;
// Check WAF config table
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_wafconfig%'));
if ($db->loadResult())
{
$result->waf_config = true;
$db->setQuery('SELECT COUNT(*) FROM #__admintools_wafconfig');
$result->waf_settings = (int) $db->loadResult();
}
// Check storage table
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_storage%'));
if ($db->loadResult())
{
$result->storage = true;
}
// Check IP blocklist
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_ipblock%'));
if ($db->loadResult())
{
$db->setQuery('SELECT COUNT(*) FROM #__admintools_ipblock');
$result->ip_blocks = (int) $db->loadResult();
}
// Only available if at least one data source exists
if (!$result->component && !$result->waf_config && !$result->storage)
{
return null;
}
return $result;
}
catch (\Throwable $e)
{
return null;
}
}
/**
* Import Admin Tools settings into MokoWaaS.
*/
public function importAdminTools(): array
{
$db = $this->getDatabase();
$results = ['firewall' => 0, 'htaccess' => 0, 'ip_blocks' => 0, 'disabled' => false];
try
{
// ============================================================
// 1. Import WAF Config → Firewall plugin params
// ============================================================
$wafSettings = $this->readWafConfig($db);
$firewallParams = $this->mapWafToFirewall($wafSettings);
if (!empty($firewallParams))
{
$this->mergePluginParams('mokowaas_firewall', 'system', $firewallParams);
$results['firewall'] = \count($firewallParams);
}
// ============================================================
// 2. Import htaccess settings → component htaccess options
// ============================================================
$htaccessSettings = $this->readHtaccessConfig($db);
$htaccessOptions = $this->mapToHtaccess($htaccessSettings, $wafSettings);
if (!empty($htaccessOptions))
{
$this->mergeComponentHtaccessOptions($htaccessOptions);
$results['htaccess'] = \count($htaccessOptions);
}
// ============================================================
// 3. Import IP blocklist → Firewall IP deny list
// ============================================================
$ipBlocks = $this->readIpBlocklist($db);
if (!empty($ipBlocks))
{
$this->mergeIpBlocklist($ipBlocks);
$results['ip_blocks'] = \count($ipBlocks);
}
// ============================================================
// 4. Disable Admin Tools
// ============================================================
$this->disableAdminTools($db);
$results['disabled'] = true;
$this->markImported('admintools');
return [
'success' => true,
'message' => \sprintf(
'Imported %d firewall settings, %d htaccess options, %d blocked IPs from Admin Tools. Admin Tools has been disabled.',
$results['firewall'], $results['htaccess'], $results['ip_blocks']
),
'counts' => $results,
];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Import failed: ' . $e->getMessage()];
}
}
/**
* Read WAF config from #__admintools_wafconfig.
*/
private function readWafConfig($db): array
{
try
{
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_wafconfig%'));
if (!$db->loadResult())
{
return [];
}
$db->setQuery('SELECT * FROM #__admintools_wafconfig');
$rows = $db->loadObjectList() ?: [];
$config = [];
foreach ($rows as $row)
{
$key = $row->key ?? $row->option ?? '';
if (!empty($key))
{
$config[$key] = $row->value ?? '';
}
}
return $config;
}
catch (\Throwable $e)
{
return [];
}
}
/**
* Read htaccess/server config from #__admintools_storage.
*/
private function readHtaccessConfig($db): array
{
try
{
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_storage%'));
if (!$db->loadResult())
{
return [];
}
$db->setQuery('SELECT * FROM #__admintools_storage');
$rows = $db->loadObjectList() ?: [];
$config = [];
foreach ($rows as $row)
{
$key = $row->key ?? '';
if (!empty($key))
{
$config[$key] = $row->value ?? '';
}
}
return $config;
}
catch (\Throwable $e)
{
return [];
}
}
/**
* Read IP blocklist from #__admintools_ipblock.
*/
private function readIpBlocklist($db): array
{
try
{
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_ipblock%'));
if (!$db->loadResult())
{
return [];
}
$db->setQuery('SELECT ip FROM #__admintools_ipblock');
return $db->loadColumn() ?: [];
}
catch (\Throwable $e)
{
return [];
}
}
/**
* Map Admin Tools WAF config to MokoWaaS firewall plugin params.
*/
private function mapWafToFirewall(array $waf): array
{
$params = [];
// WAF shields
if (isset($waf['sqlishield']))
{
$params['waf_sqli'] = (int) $waf['sqlishield'] ? 1 : 0;
}
if (isset($waf['antispam']))
{
$params['waf_xss'] = (int) $waf['antispam'] ? 1 : 0;
}
if (isset($waf['muashield']))
{
$params['waf_mua'] = (int) $waf['muashield'] ? 1 : 0;
}
if (isset($waf['rfishield']))
{
$params['waf_rfi'] = (int) $waf['rfishield'] ? 1 : 0;
}
if (isset($waf['dfishield']))
{
$params['waf_dfi'] = (int) $waf['dfishield'] ? 1 : 0;
}
if (isset($waf['uploadshield']))
{
// Map to our block_direct_php
$params['block_direct_php'] = (int) $waf['uploadshield'] ? 1 : 0;
}
// Admin secret URL
if (!empty($waf['adminpw']))
{
$params['admin_secret'] = $waf['adminpw'];
}
// Block frontend super user login
if (isset($waf['nofesalogin']))
{
$params['block_frontend_superuser'] = (int) $waf['nofesalogin'] ? 1 : 0;
}
// Session timeout
if (!empty($waf['sessionshield']) && !empty($waf['session_timeout']))
{
$params['admin_session_timeout'] = (int) $waf['session_timeout'];
}
// Template switch blocking
if (isset($waf['tmpl']))
{
$params['block_template_switch'] = (int) $waf['tmpl'] ? 1 : 0;
}
// Blocked sensitive files
if (isset($waf['hogfiles']))
{
$params['block_sensitive_files'] = (int) $waf['hogfiles'] ? 1 : 0;
}
return $params;
}
/**
* Map Admin Tools config to MokoWaaS htaccess maker options.
*/
private function mapToHtaccess(array $storage, array $waf): array
{
$opts = [];
// Server signature
if (isset($waf['serversignature']) || isset($storage['serversignature']))
{
$opts['disable_server_signature'] = 1;
}
// Clickjacking
if (isset($waf['clickjacking']) || isset($storage['xframeoptions']))
{
$opts['prevent_clickjacking'] = 1;
}
// HSTS
if (!empty($storage['hstsheader']) || !empty($waf['hstsheader']))
{
$opts['hsts_enabled'] = 1;
if (!empty($storage['hstsmaxage']))
{
$opts['hsts_max_age'] = (int) $storage['hstsmaxage'];
}
}
// GZip
if (isset($storage['gzipcompression']))
{
$opts['enable_gzip'] = (int) $storage['gzipcompression'] ? 1 : 0;
}
// Expiration
if (isset($storage['exptime']))
{
$opts['enable_expires'] = (int) $storage['exptime'] ? 1 : 0;
}
// ETag
if (isset($storage['etagtype']))
{
$opts['etag_control'] = ($storage['etagtype'] === 'none') ? 1 : 0;
}
// Redirect www / non-www
if (!empty($storage['wwwredir']))
{
$map = ['www' => 'www', 'nowww' => 'non-www'];
$opts['www_redirect'] = $map[$storage['wwwredir']] ?? 'off';
}
// Directory listing
if (isset($storage['nodirlisting']))
{
$opts['disable_directory_listing'] = (int) $storage['nodirlisting'] ? 1 : 0;
}
// Block PHP in uploads
if (isset($storage['phpuploadexec']))
{
$opts['block_php_in_uploads'] = (int) $storage['phpuploadexec'] ? 1 : 0;
}
// Sensitive files
if (isset($storage['hogfiles']))
{
$opts['block_sensitive_files'] = (int) $storage['hogfiles'] ? 1 : 0;
}
return $opts;
}
/**
* Merge params into a plugin's existing params.
*/
private function mergePluginParams(string $element, string $folder, array $newParams): void
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote($element))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote($folder));
$db->setQuery($query);
$current = new Registry($db->loadResult() ?? '{}');
foreach ($newParams as $key => $value)
{
$current->set($key, $value);
}
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('params') . ' = ' . $db->quote($current->toString()))
->where($db->quoteName('element') . ' = ' . $db->quote($element))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote($folder))
)->execute();
}
/**
* Merge htaccess options into the component params.
*/
private function mergeComponentHtaccessOptions(array $options): void
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'));
$db->setQuery($query);
$params = new Registry($db->loadResult() ?? '{}');
$htaccess = (array) json_decode(json_encode($params->get('htaccess', new \stdClass())), true);
foreach ($options as $key => $value)
{
$htaccess[$key] = $value;
}
$params->set('htaccess', $htaccess);
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('params') . ' = ' . $db->quote($params->toString()))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
)->execute();
}
/**
* Merge imported IPs into the firewall IP blocklist.
*/
private function mergeIpBlocklist(array $ips): void
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall'))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('system'));
$db->setQuery($query);
$params = new Registry($db->loadResult() ?? '{}');
$blocklist = json_decode($params->get('ip_blocklist', '[]'), true) ?: [];
$existingIps = array_column($blocklist, 'ip');
foreach ($ips as $ip)
{
$ip = trim($ip);
if (empty($ip) || \in_array($ip, $existingIps, true))
{
continue;
}
$blocklist[] = [
'ip' => $ip,
'enabled' => '1',
'label' => 'Imported from Admin Tools',
];
}
$params->set('ip_blocklist', json_encode($blocklist));
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('params') . ' = ' . $db->quote($params->toString()))
->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall'))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
)->execute();
}
/**
* Disable Admin Tools component and plugins.
*/
private function disableAdminTools($db): void
{
// Disable component
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('enabled') . ' = 0')
->where($db->quoteName('element') . ' = ' . $db->quote('com_admintools'))
)->execute();
// Disable all Admin Tools plugins
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('enabled') . ' = 0')
->where($db->quoteName('element') . ' LIKE ' . $db->quote('admintools%'))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
)->execute();
Log::add('Admin Tools component and plugins disabled after MokoWaaS import', Log::INFO, 'mokowaas');
}
// ==================================================================
// Akeeba Ticket System Import
// ==================================================================
/**
* Check if ATS tables exist.
* Returns null if already imported or no data found.
*/
public function checkAtsAvailable(): ?object
{
if ($this->wasImported('ats'))
{
return null;
}
$db = $this->getDatabase();
try
{
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%ats_tickets%'));
if (!$db->loadResult())
{
return null;
}
$db->setQuery('SELECT COUNT(*) FROM #__ats_tickets');
$tickets = (int) $db->loadResult();
$db->setQuery('SELECT COUNT(*) FROM #__ats_posts');
$posts = (int) $db->loadResult();
return (object) ['tickets' => $tickets, 'posts' => $posts];
}
catch (\Throwable $e)
{
return null;
}
}
/**
* Import from Akeeba Ticket System and disable it.
*/
public function importAts(): array
{
// Delegate to TicketsModel for the actual import
$ticketsModel = new TicketsModel();
$result = $ticketsModel->importFromAts();
if (!$result['success'])
{
return $result;
}
// Disable ATS after successful import
try
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('enabled') . ' = 0')
->where($db->quoteName('element') . ' = ' . $db->quote('com_ats'))
)->execute();
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('enabled') . ' = 0')
->where($db->quoteName('element') . ' LIKE ' . $db->quote('ats%'))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
)->execute();
$result['message'] .= ' Akeeba Ticket System has been disabled.';
Log::add('Akeeba Ticket System disabled after MokoWaaS import', Log::INFO, 'mokowaas');
}
catch (\Throwable $e)
{
$result['message'] .= ' Warning: could not disable ATS: ' . $e->getMessage();
}
$this->markImported('ats');
return $result;
}
// ==================================================================
// Import markers (stored in component params)
// ==================================================================
private function wasImported(string $key): bool
{
try
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
);
$params = new Registry($db->loadResult() ?? '{}');
return (bool) $params->get('imported_' . $key, false);
}
catch (\Throwable $e)
{
return false;
}
}
private function markImported(string $key): void
{
try
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
);
$params = new Registry($db->loadResult() ?? '{}');
$params->set('imported_' . $key, 1);
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('params') . ' = ' . $db->quote($params->toString()))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
)->execute();
}
catch (\Throwable $e)
{
Log::add('Import marker error: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
}
}
}
@@ -1,251 +0,0 @@
<?php
namespace Moko\Component\MokoWaaS\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
class MaintenanceModel extends BaseDatabaseModel
{
/**
* Get database table status (size, rows, engine, overhead).
*/
public function getTableStatus(): array
{
$db = $this->getDatabase();
$prefix = $db->getPrefix();
$db->setQuery('SHOW TABLE STATUS');
$tables = $db->loadObjectList() ?: [];
$results = [];
$totalSize = 0;
$totalOverhead = 0;
foreach ($tables as $t)
{
$sizeMb = round(($t->Data_length + $t->Index_length) / 1048576, 2);
$overheadKb = round(($t->Data_free ?? 0) / 1024, 1);
$totalSize += $sizeMb;
$totalOverhead += $overheadKb;
$results[] = (object) [
'name' => $t->Name,
'rows' => (int) $t->Rows,
'engine' => $t->Engine,
'size_mb' => $sizeMb,
'overhead_kb' => $overheadKb,
'is_moko' => str_contains($t->Name, 'mokowaas'),
];
}
usort($results, fn($a, $b) => $b->size_mb <=> $a->size_mb);
return ['tables' => $results, 'total_size_mb' => round($totalSize, 2), 'total_overhead_kb' => round($totalOverhead, 1), 'count' => \count($results)];
}
/**
* Optimize all tables or specific ones.
*/
public function optimizeTables(array $tableNames = []): array
{
$db = $this->getDatabase();
$count = 0;
try
{
if (empty($tableNames))
{
$db->setQuery('SHOW TABLE STATUS WHERE Data_free > 0');
$tables = $db->loadObjectList() ?: [];
$tableNames = array_column($tables, 'Name');
}
foreach ($tableNames as $name)
{
$db->setQuery('OPTIMIZE TABLE ' . $db->quoteName($name));
$db->execute();
$count++;
}
return ['success' => true, 'message' => "Optimized {$count} tables."];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Optimize failed: ' . $e->getMessage()];
}
}
/**
* Repair all tables.
*/
public function repairTables(): array
{
$db = $this->getDatabase();
try
{
$db->setQuery('SHOW TABLE STATUS');
$tables = $db->loadObjectList() ?: [];
$count = 0;
foreach ($tables as $t)
{
if ($t->Engine === 'InnoDB' || $t->Engine === 'MyISAM')
{
$db->setQuery('REPAIR TABLE ' . $db->quoteName($t->Name));
$db->execute();
$count++;
}
}
return ['success' => true, 'message' => "Repaired {$count} tables."];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Repair failed: ' . $e->getMessage()];
}
}
/**
* Purge expired sessions.
*/
public function purgeSessions(): array
{
try
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__session'))
->where($db->quoteName('time') . ' < ' . (time() - 86400))
)->execute();
return ['success' => true, 'message' => 'Expired sessions purged. ' . $db->getAffectedRows() . ' removed.'];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => $e->getMessage()];
}
}
// ==================================================================
// Temp/Cache Cleanup (#128)
// ==================================================================
/**
* Get directory sizes for cleanup.
*/
public function getCleanupInfo(): array
{
$dirs = [
['path' => JPATH_ROOT . '/cache', 'label' => 'Site Cache'],
['path' => JPATH_ADMINISTRATOR . '/cache', 'label' => 'Admin Cache'],
['path' => JPATH_ROOT . '/tmp', 'label' => 'Temp Directory'],
['path' => JPATH_ADMINISTRATOR . '/logs', 'label' => 'Log Files'],
];
$results = [];
foreach ($dirs as $dir)
{
$size = 0;
$files = 0;
if (is_dir($dir['path']))
{
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir['path'], \RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $file)
{
if ($file->isFile())
{
$size += $file->getSize();
$files++;
}
}
}
$results[] = (object) [
'label' => $dir['label'],
'path' => $dir['path'],
'size_mb' => round($size / 1048576, 2),
'files' => $files,
'writable' => is_writable($dir['path']),
];
}
return $results;
}
/**
* Clean a specific directory.
*/
public function cleanDirectory(string $dirKey): array
{
$allowed = [
'site_cache' => JPATH_ROOT . '/cache',
'admin_cache' => JPATH_ADMINISTRATOR . '/cache',
'tmp' => JPATH_ROOT . '/tmp',
'logs' => JPATH_ADMINISTRATOR . '/logs',
];
if (!isset($allowed[$dirKey]))
{
return ['success' => false, 'message' => 'Invalid directory.'];
}
$dir = $allowed[$dirKey];
if (!is_dir($dir))
{
return ['success' => false, 'message' => 'Directory not found.'];
}
$count = 0;
try
{
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $item)
{
// Keep index.html and .htaccess files
$name = $item->getFilename();
if ($name === 'index.html' || $name === '.htaccess')
{
continue;
}
if ($item->isDir())
{
@rmdir($item->getPathname());
}
else
{
@unlink($item->getPathname());
$count++;
}
}
// Also clear opcache
if (\function_exists('opcache_reset'))
{
\opcache_reset();
}
return ['success' => true, 'message' => "Cleaned {$count} files from {$dirKey}."];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Cleanup failed: ' . $e->getMessage()];
}
}
}
@@ -1,612 +0,0 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Log\Log;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
class PrivacyModel extends BaseDatabaseModel
{
/**
* Get all pending data requests.
*/
public function getDataRequests(string $filterStatus = ''): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select([
$db->quoteName('r') . '.*',
$db->quoteName('u.name', 'user_name'),
$db->quoteName('u.email', 'user_email'),
$db->quoteName('u.username'),
$db->quoteName('p.name', 'processed_by_name'),
])
->from($db->quoteName('#__mokowaas_data_requests', 'r'))
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id')
->leftJoin($db->quoteName('#__users', 'p') . ' ON p.id = r.processed_by');
if ($filterStatus)
{
$query->where($db->quoteName('r.status') . ' = ' . $db->quote($filterStatus));
}
$query->order($db->quoteName('r.created') . ' DESC')->setLimit(50);
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
/**
* Create a data request (from admin or user self-service).
*/
public function createRequest(int $userId, string $type, string $notes = ''): array
{
$validTypes = ['export', 'delete', 'anonymize'];
if (!\in_array($type, $validTypes, true))
{
return ['success' => false, 'message' => 'Invalid request type.'];
}
try
{
$db = $this->getDatabase();
$row = (object) [
'user_id' => $userId,
'type' => $type,
'status' => 'pending',
'notes' => $notes,
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokowaas_data_requests', $row, 'id');
return ['success' => true, 'message' => ucfirst($type) . ' request #' . $row->id . ' created.', 'id' => (int) $row->id];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()];
}
}
/**
* Process a data request (approve and execute).
*/
public function processRequest(int $requestId, string $action): array
{
$db = $this->getDatabase();
try
{
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_data_requests'))
->where($db->quoteName('id') . ' = ' . $requestId)
);
$request = $db->loadObject();
if (!$request)
{
return ['success' => false, 'message' => 'Request not found.'];
}
if ($action === 'deny')
{
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__mokowaas_data_requests'))
->set($db->quoteName('status') . ' = ' . $db->quote('denied'))
->set($db->quoteName('processed_by') . ' = ' . (int) Factory::getApplication()->getIdentity()->id)
->set($db->quoteName('processed') . ' = ' . $db->quote(Factory::getDate()->toSql()))
->where($db->quoteName('id') . ' = ' . $requestId)
)->execute();
return ['success' => true, 'message' => 'Request denied.'];
}
// Mark as processing
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__mokowaas_data_requests'))
->set($db->quoteName('status') . ' = ' . $db->quote('processing'))
->where($db->quoteName('id') . ' = ' . $requestId)
)->execute();
// Execute the request
$result = null;
switch ($request->type)
{
case 'export':
$result = $this->exportUserData((int) $request->user_id);
break;
case 'delete':
$result = $this->deleteUserData((int) $request->user_id);
break;
case 'anonymize':
$result = $this->anonymizeUserData((int) $request->user_id);
break;
}
// Mark completed
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__mokowaas_data_requests'))
->set($db->quoteName('status') . ' = ' . $db->quote('completed'))
->set($db->quoteName('processed_by') . ' = ' . (int) Factory::getApplication()->getIdentity()->id)
->set($db->quoteName('processed') . ' = ' . $db->quote(Factory::getDate()->toSql()))
->where($db->quoteName('id') . ' = ' . $requestId)
)->execute();
return $result ?? ['success' => true, 'message' => 'Request processed.'];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Processing failed: ' . $e->getMessage()];
}
}
/**
* Export all data for a user as a structured array.
*/
public function exportUserData(int $userId): array
{
$db = $this->getDatabase();
$data = ['user_id' => $userId, 'exported' => gmdate('Y-m-d\TH:i:s\Z')];
try
{
// User profile
$db->setQuery(
$db->getQuery(true)
->select(['id', 'name', 'username', 'email', 'registerDate', 'lastvisitDate', 'params'])
->from($db->quoteName('#__users'))
->where($db->quoteName('id') . ' = ' . $userId)
);
$data['profile'] = $db->loadObject();
// Content (articles)
$db->setQuery(
$db->getQuery(true)
->select(['id', 'title', 'alias', 'created', 'modified', 'hits'])
->from($db->quoteName('#__content'))
->where($db->quoteName('created_by') . ' = ' . $userId)
);
$data['articles'] = $db->loadObjectList() ?: [];
// Action logs
$db->setQuery(
$db->getQuery(true)
->select(['message', 'log_date', 'ip_address'])
->from($db->quoteName('#__action_logs'))
->where($db->quoteName('user_id') . ' = ' . $userId)
->order('log_date DESC')
->setLimit(100)
);
$data['action_logs'] = $db->loadObjectList() ?: [];
// Support tickets
$db->setQuery(
$db->getQuery(true)
->select(['id', 'subject', 'body', 'status', 'priority', 'created'])
->from($db->quoteName('#__mokowaas_tickets'))
->where($db->quoteName('created_by') . ' = ' . $userId)
);
$data['tickets'] = $db->loadObjectList() ?: [];
// Ticket replies
$db->setQuery(
$db->getQuery(true)
->select(['r.id', 'r.ticket_id', 'r.body', 'r.created'])
->from($db->quoteName('#__mokowaas_ticket_replies', 'r'))
->where($db->quoteName('r.user_id') . ' = ' . $userId)
);
$data['ticket_replies'] = $db->loadObjectList() ?: [];
// Consent log
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_consent_log'))
->where($db->quoteName('user_id') . ' = ' . $userId)
->order('created ASC')
);
$data['consent_history'] = $db->loadObjectList() ?: [];
// Community Builder profile (if table exists)
try
{
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%comprofiler%'));
if ($db->loadResult())
{
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName('#__comprofiler'))
->where($db->quoteName('user_id') . ' = ' . $userId)
);
$data['community_builder'] = $db->loadObject();
}
}
catch (\Throwable $e) {}
return ['success' => true, 'message' => 'Data exported.', 'data' => $data];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Export failed: ' . $e->getMessage()];
}
}
/**
* Anonymize a user's data (GDPR right to be forgotten — soft).
*/
public function anonymizeUserData(int $userId): array
{
$db = $this->getDatabase();
$now = Factory::getDate()->toSql();
$anon = 'Anonymous User #' . $userId;
try
{
// Anonymize user record
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__users'))
->set([
$db->quoteName('name') . ' = ' . $db->quote($anon),
$db->quoteName('username') . ' = ' . $db->quote('anon_' . $userId),
$db->quoteName('email') . ' = ' . $db->quote('anon_' . $userId . '@deleted.local'),
$db->quoteName('password') . ' = ' . $db->quote(''),
$db->quoteName('block') . ' = 1',
$db->quoteName('params') . ' = ' . $db->quote('{}'),
])
->where($db->quoteName('id') . ' = ' . $userId)
)->execute();
// Anonymize article authorship
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__content'))
->set($db->quoteName('created_by_alias') . ' = ' . $db->quote($anon))
->where($db->quoteName('created_by') . ' = ' . $userId)
)->execute();
// Delete action logs
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__action_logs'))
->where($db->quoteName('user_id') . ' = ' . $userId)
)->execute();
// Anonymize ticket replies
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__mokowaas_ticket_replies'))
->set($db->quoteName('body') . ' = ' . $db->quote('[Content removed per data request]'))
->where($db->quoteName('user_id') . ' = ' . $userId)
)->execute();
// Community Builder
try
{
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%comprofiler%'));
if ($db->loadResult())
{
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__comprofiler'))
->set([
$db->quoteName('firstname') . ' = ' . $db->quote('Anonymous'),
$db->quoteName('lastname') . ' = ' . $db->quote('User'),
$db->quoteName('middlename') . ' = ' . $db->quote(''),
])
->where($db->quoteName('user_id') . ' = ' . $userId)
)->execute();
}
}
catch (\Throwable $e) {}
// Clear Joomla user profile fields (#7)
try
{
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__user_profiles'))
->where($db->quoteName('user_id') . ' = ' . $userId)
)->execute();
}
catch (\Throwable $e) {}
// Clear contact details if linked
try
{
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__contact_details'))
->where($db->quoteName('user_id') . ' = ' . $userId)
)->execute();
}
catch (\Throwable $e) {}
// Log the anonymization
$this->logConsent($userId, 'account_anonymized', 'granted');
return ['success' => true, 'message' => 'User #' . $userId . ' data anonymized.'];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Anonymization failed: ' . $e->getMessage()];
}
}
/**
* Delete a user's data completely (hard delete).
*/
public function deleteUserData(int $userId): array
{
$result = $this->anonymizeUserData($userId);
if (!$result['success'])
{
return $result;
}
$db = $this->getDatabase();
try
{
// Delete tickets and replies
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('id'))
->from($db->quoteName('#__mokowaas_tickets'))
->where($db->quoteName('created_by') . ' = ' . $userId)
);
$ticketIds = $db->loadColumn() ?: [];
if (!empty($ticketIds))
{
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokowaas_ticket_replies'))
->where($db->quoteName('ticket_id') . ' IN (' . implode(',', $ticketIds) . ')')
)->execute();
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokowaas_tickets'))
->where($db->quoteName('created_by') . ' = ' . $userId)
)->execute();
}
// Delete consent log
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokowaas_consent_log'))
->where($db->quoteName('user_id') . ' = ' . $userId)
)->execute();
// Delete user record
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__users'))
->where($db->quoteName('id') . ' = ' . $userId)
)->execute();
return ['success' => true, 'message' => 'User #' . $userId . ' data permanently deleted.'];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Deletion failed: ' . $e->getMessage()];
}
}
// ==================================================================
// Consent Management
// ==================================================================
/**
* Get consent status for a user.
*/
public function getUserConsent(int $userId): array
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_consent_log'))
->where($db->quoteName('user_id') . ' = ' . $userId)
->order($db->quoteName('created') . ' DESC')
);
return $db->loadObjectList() ?: [];
}
/**
* Record a consent action.
*/
public function logConsent(int $userId, string $category, string $action): void
{
$db = $this->getDatabase();
$row = (object) [
'user_id' => $userId,
'category' => $category,
'action' => $action === 'revoked' ? 'revoked' : 'granted',
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? '',
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokowaas_consent_log', $row, 'id');
}
// ==================================================================
// Retention Policy Enforcement
// ==================================================================
/**
* Get all retention policies.
*/
public function getRetentionPolicies(): array
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_retention_policies'))
->order($db->quoteName('id') . ' ASC')
);
return $db->loadObjectList() ?: [];
}
/**
* Run retention policy enforcement (called by scheduled task).
*/
public function enforceRetentionPolicies(): array
{
$db = $this->getDatabase();
$results = ['policies_run' => 0, 'items_affected' => 0];
$policies = $this->getRetentionPolicies();
foreach ($policies as $policy)
{
if (!(int) $policy->enabled)
{
continue;
}
$cutoff = Factory::getDate('-' . (int) $policy->retention_days . ' days')->toSql();
$count = 0;
try
{
switch ($policy->content_type)
{
case 'action_logs':
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__action_logs'))
->where($db->quoteName('log_date') . ' < ' . $db->quote($cutoff))
)->execute();
$count = $db->getAffectedRows();
break;
case 'waf_logs':
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokowaas_waf_log'))
->where($db->quoteName('created') . ' < ' . $db->quote($cutoff))
)->execute();
$count = $db->getAffectedRows();
break;
case 'sessions':
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__session'))
->where($db->quoteName('time') . ' < ' . (int) strtotime($cutoff))
)->execute();
$count = $db->getAffectedRows();
break;
case 'closed_tickets':
if ($policy->action === 'anonymize')
{
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__mokowaas_tickets'))
->set($db->quoteName('body') . ' = ' . $db->quote('[Removed per retention policy]'))
->where($db->quoteName('status') . ' = ' . $db->quote('closed'))
->where($db->quoteName('closed') . ' < ' . $db->quote($cutoff))
->where($db->quoteName('body') . ' != ' . $db->quote('[Removed per retention policy]'))
)->execute();
$count = $db->getAffectedRows();
}
break;
case 'inactive_users':
if ($policy->action === 'anonymize')
{
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('id'))
->from($db->quoteName('#__users'))
->where($db->quoteName('lastvisitDate') . ' < ' . $db->quote($cutoff))
->where($db->quoteName('lastvisitDate') . ' != ' . $db->quote('0000-00-00 00:00:00'))
->where($db->quoteName('block') . ' = 0')
->where($db->quoteName('username') . ' NOT LIKE ' . $db->quote('anon_%'))
);
$userIds = $db->loadColumn() ?: [];
foreach ($userIds as $uid)
{
$this->anonymizeUserData((int) $uid);
$count++;
}
}
break;
}
if ($count > 0)
{
$results['policies_run']++;
$results['items_affected'] += $count;
Log::add(\sprintf('Retention: %s — %d items affected', $policy->content_type, $count), Log::INFO, 'mokowaas');
}
}
catch (\Throwable $e)
{
Log::add('Retention policy error (' . $policy->content_type . '): ' . $e->getMessage(), Log::WARNING, 'mokowaas');
}
}
return $results;
}
/**
* Get privacy dashboard summary counts.
*/
public function getDashboardSummary(): object
{
$db = $this->getDatabase();
$summary = (object) [
'pending_requests' => 0,
'total_requests' => 0,
'consent_entries' => 0,
'policies_active' => 0,
];
try
{
$db->setQuery('SELECT COUNT(*) FROM #__mokowaas_data_requests WHERE status = ' . $db->quote('pending'));
$summary->pending_requests = (int) $db->loadResult();
$db->setQuery('SELECT COUNT(*) FROM #__mokowaas_data_requests');
$summary->total_requests = (int) $db->loadResult();
$db->setQuery('SELECT COUNT(*) FROM #__mokowaas_consent_log');
$summary->consent_entries = (int) $db->loadResult();
$db->setQuery('SELECT COUNT(*) FROM #__mokowaas_retention_policies WHERE enabled = 1');
$summary->policies_active = (int) $db->loadResult();
}
catch (\Throwable $e) {}
return $summary;
}
}
@@ -1,945 +0,0 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Moko\Component\MokoWaaS\Administrator\Service\NotificationService;
class TicketsModel extends BaseDatabaseModel
{
/**
* Get ticket list with filters.
*/
public function getTickets(array $filters = []): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select([
$db->quoteName('t.id'),
$db->quoteName('t.subject'),
$db->quoteName('t.status'),
$db->quoteName('t.priority'),
$db->quoteName('t.created'),
$db->quoteName('t.modified'),
$db->quoteName('t.sla_response_due'),
$db->quoteName('t.sla_resolution_due'),
$db->quoteName('t.sla_responded'),
$db->quoteName('c.title', 'category_title'),
$db->quoteName('u.name', 'created_by_name'),
$db->quoteName('a.name', 'assigned_to_name'),
])
->from($db->quoteName('#__mokowaas_tickets', 't'))
->leftJoin($db->quoteName('#__mokowaas_ticket_categories', 'c') . ' ON c.id = t.category_id')
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to');
if (!empty($filters['status']))
{
$query->where($db->quoteName('t.status') . ' = ' . $db->quote($filters['status']));
}
if (!empty($filters['priority']))
{
$query->where($db->quoteName('t.priority') . ' = ' . $db->quote($filters['priority']));
}
if (!empty($filters['assigned_to']))
{
$query->where($db->quoteName('t.assigned_to') . ' = ' . (int) $filters['assigned_to']);
}
if (!empty($filters['category_id']))
{
$query->where($db->quoteName('t.category_id') . ' = ' . (int) $filters['category_id']);
}
$query->order($db->quoteName('t.created') . ' DESC');
$query->setLimit(50);
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
/**
* Get a single ticket with all replies.
*/
public function getTicket(int $id): ?object
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select([
$db->quoteName('t') . '.*',
$db->quoteName('c.title', 'category_title'),
$db->quoteName('u.name', 'created_by_name'),
$db->quoteName('u.email', 'created_by_email'),
$db->quoteName('a.name', 'assigned_to_name'),
])
->from($db->quoteName('#__mokowaas_tickets', 't'))
->leftJoin($db->quoteName('#__mokowaas_ticket_categories', 'c') . ' ON c.id = t.category_id')
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to')
->where($db->quoteName('t.id') . ' = ' . $id);
$db->setQuery($query);
$ticket = $db->loadObject();
if (!$ticket)
{
return null;
}
// Load replies
$query = $db->getQuery(true)
->select([
$db->quoteName('r') . '.*',
$db->quoteName('u.name', 'user_name'),
])
->from($db->quoteName('#__mokowaas_ticket_replies', 'r'))
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id')
->where($db->quoteName('r.ticket_id') . ' = ' . $id)
->order($db->quoteName('r.created') . ' ASC');
$db->setQuery($query);
$ticket->replies = $db->loadObjectList() ?: [];
// Reply count
$ticket->reply_count = \count($ticket->replies);
return $ticket;
}
/**
* Create a new ticket.
*/
public function createTicket(array $data): array
{
try
{
$db = $this->getDatabase();
$user = Factory::getApplication()->getIdentity();
$now = Factory::getDate()->toSql();
$ticket = (object) [
'subject' => $data['subject'] ?? '',
'body' => $data['body'] ?? '',
'status' => 'open',
'priority' => $data['priority'] ?? 'normal',
'category_id' => (int) ($data['category_id'] ?? 0) ?: null,
'created_by' => $user->id,
'assigned_to' => (int) ($data['assigned_to'] ?? 0) ?: null,
'created' => $now,
'modified' => $now,
];
// Auto-assign from category
if (!$ticket->assigned_to && $ticket->category_id)
{
$query = $db->getQuery(true)
->select($db->quoteName('auto_assign_user'))
->from($db->quoteName('#__mokowaas_ticket_categories'))
->where($db->quoteName('id') . ' = ' . (int) $ticket->category_id);
$db->setQuery($query);
$autoAssign = (int) $db->loadResult();
if ($autoAssign)
{
$ticket->assigned_to = $autoAssign;
}
}
// SLA deadlines from category
if ($ticket->category_id)
{
$query = $db->getQuery(true)
->select([$db->quoteName('sla_response_minutes'), $db->quoteName('sla_resolution_minutes')])
->from($db->quoteName('#__mokowaas_ticket_categories'))
->where($db->quoteName('id') . ' = ' . (int) $ticket->category_id);
$db->setQuery($query);
$sla = $db->loadObject();
if ($sla)
{
$ticket->sla_response_due = Factory::getDate($now)->modify('+' . (int) $sla->sla_response_minutes . ' minutes')->toSql();
$ticket->sla_resolution_due = Factory::getDate($now)->modify('+' . (int) $sla->sla_resolution_minutes . ' minutes')->toSql();
}
}
$db->insertObject('#__mokowaas_tickets', $ticket, 'id');
// Run automation + notifications
$this->runAutomation('ticket_created', (int) $ticket->id);
NotificationService::notify('ticket_created', $this->getTicket((int) $ticket->id));
return ['success' => true, 'message' => 'Ticket #' . $ticket->id . ' created.', 'id' => (int) $ticket->id];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()];
}
}
/**
* Add a reply to a ticket.
*/
public function addReply(int $ticketId, string $body, bool $isInternal = false): array
{
try
{
$db = $this->getDatabase();
$user = Factory::getApplication()->getIdentity();
$now = Factory::getDate()->toSql();
$reply = (object) [
'ticket_id' => $ticketId,
'user_id' => $user->id,
'body' => $body,
'is_internal' => $isInternal ? 1 : 0,
'created' => $now,
];
$db->insertObject('#__mokowaas_ticket_replies', $reply, 'id');
// Mark SLA as responded only for staff replies (not customer self-replies)
$ticket = $this->getTicket($ticketId);
$isStaffReply = $ticket && (int) $user->id !== (int) $ticket->created_by;
$updateQuery = $db->getQuery(true)
->update($db->quoteName('#__mokowaas_tickets'))
->set($db->quoteName('modified') . ' = ' . $db->quote($now))
->where($db->quoteName('id') . ' = ' . $ticketId);
if ($isStaffReply)
{
$updateQuery->set($db->quoteName('sla_responded') . ' = 1')
->where($db->quoteName('sla_responded') . ' = 0');
}
$db->setQuery($updateQuery)->execute();
// Run automation + notifications (skip internal notes)
$this->runAutomation('ticket_replied', $ticketId);
if (!$isInternal)
{
NotificationService::notify('ticket_replied', $this->getTicket($ticketId), ['reply_body' => $body]);
}
return ['success' => true, 'message' => 'Reply added.'];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()];
}
}
/**
* Update ticket status.
*/
public function updateStatus(int $ticketId, string $status): array
{
$valid = ['open', 'in_progress', 'waiting', 'resolved', 'closed'];
if (!\in_array($status, $valid, true))
{
return ['success' => false, 'message' => 'Invalid status.'];
}
try
{
$db = $this->getDatabase();
$now = Factory::getDate()->toSql();
// Capture old status for notification
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('status'))
->from($db->quoteName('#__mokowaas_tickets'))
->where($db->quoteName('id') . ' = ' . $ticketId)
);
$oldStatus = $db->loadResult() ?? '';
$sets = [
$db->quoteName('status') . ' = ' . $db->quote($status),
$db->quoteName('modified') . ' = ' . $db->quote($now),
];
if ($status === 'resolved')
{
$sets[] = $db->quoteName('resolved') . ' = ' . $db->quote($now);
}
if ($status === 'closed')
{
$sets[] = $db->quoteName('closed') . ' = ' . $db->quote($now);
}
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__mokowaas_tickets'))
->set($sets)
->where($db->quoteName('id') . ' = ' . $ticketId)
)->execute();
// Run automation + notifications
$this->runAutomation('status_changed', $ticketId);
NotificationService::notify('status_changed', $this->getTicket($ticketId), ['old_status' => $oldStatus]);
return ['success' => true, 'message' => 'Status updated to ' . $status . '.'];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()];
}
}
/**
* Get all ticket categories.
*/
public function getCategories(): array
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_ticket_categories'))
->where($db->quoteName('published') . ' = 1')
->order($db->quoteName('ordering') . ' ASC')
);
return $db->loadObjectList() ?: [];
}
/**
* Get canned responses, optionally filtered by category.
*/
public function getCannedResponses(int $categoryId = 0): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_ticket_canned'))
->order($db->quoteName('ordering') . ' ASC');
if ($categoryId)
{
$query->where('(' . $db->quoteName('category_id') . ' = ' . $categoryId
. ' OR ' . $db->quoteName('category_id') . ' IS NULL)');
}
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
/**
* Get ticket counts by status for dashboard.
*/
public function getStatusCounts(): object
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select([$db->quoteName('status'), 'COUNT(*) AS ' . $db->quoteName('cnt')])
->from($db->quoteName('#__mokowaas_tickets'))
->group($db->quoteName('status'))
);
$rows = $db->loadObjectList('status') ?: [];
return (object) [
'open' => (int) ($rows['open']->cnt ?? 0),
'in_progress' => (int) ($rows['in_progress']->cnt ?? 0),
'waiting' => (int) ($rows['waiting']->cnt ?? 0),
'resolved' => (int) ($rows['resolved']->cnt ?? 0),
'closed' => (int) ($rows['closed']->cnt ?? 0),
'total' => array_sum(array_map(fn($r) => (int) $r->cnt, $rows)),
];
}
/**
* Get overdue tickets (SLA breached).
*/
public function getOverdueTickets(): array
{
$db = $this->getDatabase();
$now = Factory::getDate()->toSql();
$query = $db->getQuery(true)
->select([$db->quoteName('id'), $db->quoteName('subject'), $db->quoteName('priority'),
$db->quoteName('sla_response_due'), $db->quoteName('sla_resolution_due'), $db->quoteName('sla_responded')])
->from($db->quoteName('#__mokowaas_tickets'))
->where($db->quoteName('status') . ' NOT IN (' . $db->quote('resolved') . ',' . $db->quote('closed') . ')')
->where('((' . $db->quoteName('sla_response_due') . ' < ' . $db->quote($now) . ' AND ' . $db->quoteName('sla_responded') . ' = 0)'
. ' OR ' . $db->quoteName('sla_resolution_due') . ' < ' . $db->quote($now) . ')')
->order($db->quoteName('sla_resolution_due') . ' ASC');
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
// ==================================================================
// Automation Engine
// ==================================================================
/**
* Run automation rules for a specific trigger event against a ticket.
*
* @param string $event trigger_event: ticket_created, ticket_replied, status_changed, scheduled
* @param int $ticketId The ticket to evaluate
*/
public function runAutomation(string $event, int $ticketId): void
{
try
{
$db = $this->getDatabase();
// Load enabled rules for this event
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_ticket_automation'))
->where($db->quoteName('trigger_event') . ' = ' . $db->quote($event))
->where($db->quoteName('enabled') . ' = 1')
->order($db->quoteName('ordering') . ' ASC');
$db->setQuery($query);
$rules = $db->loadObjectList() ?: [];
if (empty($rules))
{
return;
}
// Load the ticket
$ticket = $this->getTicket($ticketId);
if (!$ticket)
{
return;
}
// Calculate age in hours
$ticket->age_hours = (time() - strtotime($ticket->created)) / 3600;
foreach ($rules as $rule)
{
$conditions = json_decode($rule->conditions, true) ?: [];
$actions = json_decode($rule->actions, true) ?: [];
if ($this->evaluateConditions($conditions, $ticket))
{
$this->executeActions($actions, $ticketId, $ticket);
}
}
}
catch (\Throwable $e)
{
\Joomla\CMS\Log\Log::add('Automation error: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokowaas');
}
}
/**
* Run all scheduled automation rules against all open tickets.
*/
public function runScheduledAutomation(): array
{
$db = $this->getDatabase();
$results = ['evaluated' => 0, 'acted' => 0];
// Load scheduled rules
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_ticket_automation'))
->where($db->quoteName('trigger_event') . ' = ' . $db->quote('scheduled'))
->where($db->quoteName('enabled') . ' = 1')
->order($db->quoteName('ordering') . ' ASC');
$db->setQuery($query);
$rules = $db->loadObjectList() ?: [];
if (empty($rules))
{
return $results;
}
// Load all non-closed tickets
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_tickets'))
->where($db->quoteName('status') . ' != ' . $db->quote('closed'));
$db->setQuery($query);
$tickets = $db->loadObjectList() ?: [];
foreach ($tickets as $ticket)
{
$ticket->age_hours = (time() - strtotime($ticket->created)) / 3600;
$ticket->replies = [];
$results['evaluated']++;
foreach ($rules as $rule)
{
$conditions = json_decode($rule->conditions, true) ?: [];
$actions = json_decode($rule->actions, true) ?: [];
if ($this->evaluateConditions($conditions, $ticket))
{
$this->executeActions($actions, (int) $ticket->id, $ticket);
$results['acted']++;
}
}
}
return $results;
}
/**
* Evaluate a set of conditions against a ticket (all must match).
*/
private function evaluateConditions(array $conditions, object $ticket): bool
{
foreach ($conditions as $cond)
{
$field = $cond['field'] ?? '';
$op = $cond['op'] ?? 'eq';
$value = $cond['value'] ?? '';
$ticketValue = $ticket->{$field} ?? null;
if ($ticketValue === null)
{
return false;
}
switch ($op)
{
case 'eq':
if ((string) $ticketValue !== (string) $value) return false;
break;
case 'neq':
if ((string) $ticketValue === (string) $value) return false;
break;
case 'gt':
if ((float) $ticketValue <= (float) $value) return false;
break;
case 'lt':
if ((float) $ticketValue >= (float) $value) return false;
break;
case 'in':
$list = array_map('trim', explode(',', $value));
if (!\in_array((string) $ticketValue, $list, true)) return false;
break;
case 'not_in':
$list = array_map('trim', explode(',', $value));
if (\in_array((string) $ticketValue, $list, true)) return false;
break;
default:
return false;
}
}
return true;
}
/**
* Execute a set of actions on a ticket.
*/
private function executeActions(array $actions, int $ticketId, object $ticket): void
{
$db = $this->getDatabase();
$now = Factory::getDate()->toSql();
foreach ($actions as $action)
{
$type = $action['type'] ?? '';
$value = $action['value'] ?? '';
switch ($type)
{
case 'set_status':
$this->updateStatus($ticketId, $value);
break;
case 'set_priority':
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__mokowaas_tickets'))
->set($db->quoteName('priority') . ' = ' . $db->quote($value))
->set($db->quoteName('modified') . ' = ' . $db->quote($now))
->where($db->quoteName('id') . ' = ' . $ticketId)
)->execute();
break;
case 'assign':
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__mokowaas_tickets'))
->set($db->quoteName('assigned_to') . ' = ' . (int) $value)
->set($db->quoteName('modified') . ' = ' . $db->quote($now))
->where($db->quoteName('id') . ' = ' . $ticketId)
)->execute();
break;
case 'add_note':
$reply = (object) [
'ticket_id' => $ticketId,
'user_id' => 0,
'body' => $value,
'is_internal' => 1,
'created' => $now,
];
$db->insertObject('#__mokowaas_ticket_replies', $reply, 'id');
break;
case 'send_email':
// value = email address or comma-separated list
$emails = array_filter(array_map('trim', explode(',', $value)));
foreach ($emails as $email)
{
try
{
$mailer = Factory::getMailer();
$mailer->addRecipient($email);
$mailer->setSubject('[Ticket #' . $ticketId . '] Automation Alert');
$mailer->setBody('Automation rule triggered for ticket #' . $ticketId . ': ' . ($ticket->subject ?? ''));
$mailer->isHtml(false);
$mailer->Send();
}
catch (\Throwable $e)
{
\Joomla\CMS\Log\Log::add('Automation email failed: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokowaas');
}
}
break;
case 'create_ticket':
// value = JSON: {"subject":"...","body":"...","category_id":1,"priority":"normal","behavior":"append"}
$ticketData = json_decode($value, true) ?: [];
$behavior = $ticketData['behavior'] ?? 'append';
$userId = (int) ($ticket->created_by ?? 0);
$catId = (int) ($ticketData['category_id'] ?? 0);
if ($behavior === 'append' && $userId > 0)
{
// Check for existing open ticket from this user in this category
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('id'))
->from($db->quoteName('#__mokowaas_tickets'))
->where($db->quoteName('created_by') . ' = ' . $userId)
->where($db->quoteName('status') . ' NOT IN (' . $db->quote('resolved') . ',' . $db->quote('closed') . ')')
->where($catId ? $db->quoteName('category_id') . ' = ' . $catId : '1=1')
->order($db->quoteName('created') . ' DESC')
->setLimit(1)
);
$existingId = (int) $db->loadResult();
if ($existingId)
{
$this->addReply($existingId, $ticketData['body'] ?? 'Automation event', true);
break;
}
}
elseif ($behavior === 'skip_if_open' && $userId > 0)
{
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokowaas_tickets'))
->where($db->quoteName('created_by') . ' = ' . $userId)
->where($db->quoteName('status') . ' NOT IN (' . $db->quote('resolved') . ',' . $db->quote('closed') . ')')
);
if ((int) $db->loadResult() > 0)
{
break;
}
}
// Create new ticket
$this->createTicket([
'subject' => $ticketData['subject'] ?? 'Automation: ' . ($ticket->subject ?? 'System event'),
'body' => $ticketData['body'] ?? '',
'priority' => $ticketData['priority'] ?? 'normal',
'category_id' => $catId,
]);
break;
}
}
}
/**
* Run automation for a system event (not tied to a specific ticket).
* Creates a virtual ticket context from event data.
*/
public function runSystemEventAutomation(string $event, array $eventData = []): void
{
try
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_ticket_automation'))
->where($db->quoteName('trigger_event') . ' = ' . $db->quote($event))
->where($db->quoteName('enabled') . ' = 1')
->order($db->quoteName('ordering') . ' ASC');
$db->setQuery($query);
$rules = $db->loadObjectList() ?: [];
if (empty($rules))
{
return;
}
// Build a virtual ticket-like object from event data
$context = (object) array_merge([
'id' => 0,
'subject' => $eventData['subject'] ?? $event,
'body' => $eventData['body'] ?? '',
'status' => 'open',
'priority' => $eventData['priority'] ?? 'normal',
'created_by' => $eventData['user_id'] ?? 0,
'created' => gmdate('Y-m-d H:i:s'),
'age_hours' => 0,
], $eventData);
foreach ($rules as $rule)
{
$conditions = json_decode($rule->conditions, true) ?: [];
$actions = json_decode($rule->actions, true) ?: [];
if (empty($conditions) || $this->evaluateConditions($conditions, $context))
{
$this->executeActions($actions, 0, $context);
}
}
}
catch (\Throwable $e)
{
\Joomla\CMS\Log\Log::add('System event automation error: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokowaas');
}
}
/**
* Get all automation rules.
*/
public function getAutomationRules(): array
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_ticket_automation'))
->order($db->quoteName('ordering') . ' ASC')
);
return $db->loadObjectList() ?: [];
}
// ==================================================================
// Akeeba Ticket System Importer
// ==================================================================
/**
* Check if ATS tables exist and return counts.
*/
public function checkAtsAvailable(): ?object
{
$db = $this->getDatabase();
try
{
$db->setQuery('SELECT COUNT(*) FROM #__ats_tickets');
$tickets = (int) $db->loadResult();
$db->setQuery('SELECT COUNT(*) FROM #__ats_posts');
$posts = (int) $db->loadResult();
$db->setQuery('SELECT COUNT(*) FROM #__ats_cannedreplies');
$canned = (int) $db->loadResult();
return (object) ['tickets' => $tickets, 'posts' => $posts, 'canned' => $canned];
}
catch (\Throwable $e)
{
return null;
}
}
/**
* Import tickets, replies, and canned responses from Akeeba Ticket System.
*/
public function importFromAts(): array
{
$db = $this->getDatabase();
$results = ['tickets' => 0, 'replies' => 0, 'canned' => 0, 'errors' => []];
try
{
// Status mapping: ATS → MokoWaaS
$statusMap = [
'O' => 'open', // Open
'P' => 'in_progress', // Pending (staff action needed)
'C' => 'closed', // Closed
];
// Numeric statuses 1-99 are custom — map to open
for ($i = 1; $i <= 99; $i++)
{
$statusMap[(string) $i] = 'open';
}
// Priority mapping: ATS uses 1-5, we use enum
$priorityMap = [
1 => 'low',
2 => 'low',
3 => 'normal',
4 => 'high',
5 => 'urgent',
];
// Category mapping: ATS uses Joomla categories, map catid to our category
// Default all to General Support (1) — admin can reassign later
$defaultCategory = 1;
// Import canned replies first
$db->setQuery('SELECT * FROM #__ats_cannedreplies WHERE enabled = 1 ORDER BY ordering');
$atsCanned = $db->loadObjectList() ?: [];
foreach ($atsCanned as $c)
{
$exists = $db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from('#__mokowaas_ticket_canned')
->where($db->quoteName('title') . ' = ' . $db->quote($c->title))
)->loadResult();
if ((int) $exists > 0)
{
continue;
}
$row = (object) [
'title' => $c->title,
'body' => strip_tags($c->reply ?? ''),
'category_id' => null,
'ordering' => (int) ($c->ordering ?? 0),
];
$db->insertObject('#__mokowaas_ticket_canned', $row, 'id');
$results['canned']++;
}
// Import tickets
$db->setQuery('SELECT * FROM #__ats_tickets ORDER BY id');
$atsTickets = $db->loadObjectList() ?: [];
$ticketIdMap = []; // ATS id → MokoWaaS id
foreach ($atsTickets as $t)
{
// Skip if already imported (check by subject + created_by + created)
$exists = $db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from('#__mokowaas_tickets')
->where($db->quoteName('subject') . ' = ' . $db->quote($t->title))
->where($db->quoteName('created_by') . ' = ' . (int) $t->created_by)
)->loadResult();
if ((int) $exists > 0)
{
continue;
}
$status = $statusMap[$t->status] ?? 'open';
$priority = $priorityMap[(int) $t->priority] ?? 'normal';
$row = (object) [
'subject' => $t->title,
'body' => '',
'status' => $status,
'priority' => $priority,
'category_id' => $defaultCategory,
'created_by' => (int) $t->created_by,
'assigned_to' => (int) $t->assigned_to ?: null,
'created' => $t->created ?: Factory::getDate()->toSql(),
'modified' => $t->modified,
'resolved' => $status === 'closed' ? ($t->modified ?: $t->created) : null,
'closed' => $status === 'closed' ? ($t->modified ?: $t->created) : null,
'sla_responded' => 1,
];
$db->insertObject('#__mokowaas_tickets', $row, 'id');
$ticketIdMap[(int) $t->id] = (int) $row->id;
$results['tickets']++;
}
// Import posts (replies)
$db->setQuery('SELECT * FROM #__ats_posts ORDER BY id');
$atsPosts = $db->loadObjectList() ?: [];
foreach ($atsPosts as $p)
{
$newTicketId = $ticketIdMap[(int) $p->ticket_id] ?? null;
if (!$newTicketId)
{
continue;
}
// First post of a ticket is usually the ticket body — update the ticket
if (empty($results['first_post_' . $p->ticket_id]))
{
$results['first_post_' . $p->ticket_id] = true;
$body = strip_tags($p->content_html ?? '');
$db->setQuery(
$db->getQuery(true)
->update('#__mokowaas_tickets')
->set($db->quoteName('body') . ' = ' . $db->quote($body))
->where($db->quoteName('id') . ' = ' . $newTicketId)
)->execute();
continue;
}
$row = (object) [
'ticket_id' => $newTicketId,
'user_id' => (int) $p->created_by,
'body' => strip_tags($p->content_html ?? ''),
'is_internal' => 0,
'created' => $p->created ?: Factory::getDate()->toSql(),
];
$db->insertObject('#__mokowaas_ticket_replies', $row, 'id');
$results['replies']++;
}
// Clean up temp tracking keys
foreach (array_keys($results) as $k)
{
if (str_starts_with($k, 'first_post_'))
{
unset($results[$k]);
}
}
return [
'success' => true,
'message' => sprintf(
'Imported %d tickets, %d replies, %d canned responses from Akeeba Ticket System.',
$results['tickets'], $results['replies'], $results['canned']
),
'counts' => $results,
];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Import failed: ' . $e->getMessage()];
}
}
}
@@ -1,215 +0,0 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
class WaflogModel extends BaseDatabaseModel
{
/**
* Get WAF log entries with filters and pagination.
*/
public function getLogs(array $filters = [], int $limit = 50, int $offset = 0): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_waf_log'));
if (!empty($filters['rule']))
{
$query->where($db->quoteName('rule') . ' = ' . $db->quote($filters['rule']));
}
if (!empty($filters['ip']))
{
$query->where($db->quoteName('ip') . ' LIKE ' . $db->quote('%' . $db->escape($filters['ip'], true) . '%'));
}
if (!empty($filters['search']))
{
$search = $db->quote('%' . $db->escape($filters['search'], true) . '%');
$query->where('(' . $db->quoteName('uri') . ' LIKE ' . $search
. ' OR ' . $db->quoteName('detail') . ' LIKE ' . $search
. ' OR ' . $db->quoteName('user_agent') . ' LIKE ' . $search . ')');
}
if (!empty($filters['date_from']))
{
$query->where($db->quoteName('created') . ' >= ' . $db->quote($filters['date_from'] . ' 00:00:00'));
}
if (!empty($filters['date_to']))
{
$query->where($db->quoteName('created') . ' <= ' . $db->quote($filters['date_to'] . ' 23:59:59'));
}
$query->order($db->quoteName('created') . ' DESC');
$query->setLimit($limit, $offset);
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
/**
* Get total count for pagination.
*/
public function getTotal(array $filters = []): int
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokowaas_waf_log'));
if (!empty($filters['rule']))
{
$query->where($db->quoteName('rule') . ' = ' . $db->quote($filters['rule']));
}
if (!empty($filters['ip']))
{
$query->where($db->quoteName('ip') . ' LIKE ' . $db->quote('%' . $db->escape($filters['ip'], true) . '%'));
}
$db->setQuery($query);
return (int) $db->loadResult();
}
/**
* Get block counts grouped by rule for the summary bar.
*/
public function getRuleCounts(): array
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select([$db->quoteName('rule'), 'COUNT(*) AS ' . $db->quoteName('cnt')])
->from($db->quoteName('#__mokowaas_waf_log'))
->group($db->quoteName('rule'))
->order($db->quoteName('cnt') . ' DESC')
);
return $db->loadObjectList() ?: [];
}
/**
* Get top blocked IPs.
*/
public function getTopIps(int $limit = 10): array
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select([$db->quoteName('ip'), 'COUNT(*) AS ' . $db->quoteName('cnt'),
'MAX(' . $db->quoteName('created') . ') AS ' . $db->quoteName('last_seen')])
->from($db->quoteName('#__mokowaas_waf_log'))
->group($db->quoteName('ip'))
->order($db->quoteName('cnt') . ' DESC')
->setLimit($limit)
);
return $db->loadObjectList() ?: [];
}
/**
* Get distinct rule names for the filter dropdown.
*/
public function getRuleNames(): array
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select('DISTINCT ' . $db->quoteName('rule'))
->from($db->quoteName('#__mokowaas_waf_log'))
->order($db->quoteName('rule') . ' ASC')
);
return $db->loadColumn() ?: [];
}
/**
* Delete logs older than N days.
*/
public function purgeLogs(int $days): array
{
try
{
$db = $this->getDatabase();
$cutoff = Factory::getDate('-' . $days . ' days')->toSql();
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokowaas_waf_log'))
->where($db->quoteName('created') . ' < ' . $db->quote($cutoff))
)->execute();
$count = $db->getAffectedRows();
return ['success' => true, 'message' => "Purged {$count} log entries older than {$days} days."];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Purge failed: ' . $e->getMessage()];
}
}
/**
* Add an IP to the firewall blocklist.
*/
public function banIp(string $ip, string $reason = 'Banned from WAF log'): array
{
try
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall'))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('system'));
$db->setQuery($query);
$params = new \Joomla\Registry\Registry($db->loadResult() ?? '{}');
$blocklist = json_decode($params->get('ip_blocklist', '[]'), true) ?: [];
// Check if already blocked
foreach ($blocklist as $entry)
{
if (($entry['ip'] ?? '') === $ip)
{
return ['success' => false, 'message' => $ip . ' is already blocked.'];
}
}
$blocklist[] = ['ip' => $ip, 'enabled' => '1', 'label' => $reason];
$params->set('ip_blocklist', json_encode($blocklist));
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('params') . ' = ' . $db->quote($params->toString()))
->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall'))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
)->execute();
return ['success' => true, 'message' => $ip . ' has been added to the IP blocklist.'];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Ban failed: ' . $e->getMessage()];
}
}
}
@@ -1,416 +0,0 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\Service;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Uri\Uri;
/**
* Helpdesk email notification service.
*
* Sends emails for ticket events to Joomla users (by ID) and/or
* raw email addresses. Uses Joomla's configured mailer.
*
* @since 02.32.00
*/
class NotificationService
{
/**
* Send a ticket notification email.
*
* @param string $event Event name (ticket_created, ticket_replied, status_changed, ticket_assigned)
* @param object $ticket Ticket object with id, subject, status, priority, created_by, assigned_to
* @param array $extra Extra context (reply body, old status, etc.)
*/
public static function notify(string $event, object $ticket, array $extra = []): void
{
try
{
$recipients = self::getRecipients($event, $ticket);
if (empty($recipients))
{
return;
}
$subject = self::buildSubject($event, $ticket);
$body = self::buildBody($event, $ticket, $extra);
$mailer = Factory::getMailer();
$mailer->isHtml(false);
$mailer->setSubject($subject);
$mailer->setBody($body);
foreach ($recipients as $email)
{
$email = trim($email);
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL))
{
continue;
}
try
{
$mailer->clearAddresses();
$mailer->addRecipient($email);
$mailer->Send();
}
catch (\Throwable $e)
{
Log::add('Notification send failed to ' . $email . ': ' . $e->getMessage(), Log::WARNING, 'mokowaas');
}
}
}
catch (\Throwable $e)
{
Log::add('Notification error: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
}
}
/**
* Determine recipients based on event type and ticket data.
*/
private static function getRecipients(string $event, object $ticket): array
{
$emails = [];
// Get notification config from component params
$config = self::getNotificationConfig();
// Always notify configured admin emails
$adminEmails = array_filter(array_map('trim', explode(',', $config['admin_emails'] ?? '')));
$emails = array_merge($emails, $adminEmails);
// Always notify configured admin user IDs
$adminUserIds = array_filter(array_map('intval', explode(',', $config['admin_user_ids'] ?? '')));
foreach ($adminUserIds as $uid)
{
$email = self::getUserEmail($uid);
if ($email)
{
$emails[] = $email;
}
}
switch ($event)
{
case 'ticket_created':
// Notify assigned user if any
if (!empty($ticket->assigned_to))
{
$email = self::getUserEmail((int) $ticket->assigned_to);
if ($email)
{
$emails[] = $email;
}
}
break;
case 'ticket_replied':
// Notify ticket creator (customer gets notified of staff reply)
if (!empty($ticket->created_by))
{
$email = self::getUserEmail((int) $ticket->created_by);
if ($email)
{
$emails[] = $email;
}
}
// Notify assigned user
if (!empty($ticket->assigned_to))
{
$email = self::getUserEmail((int) $ticket->assigned_to);
if ($email)
{
$emails[] = $email;
}
}
break;
case 'status_changed':
// Notify ticket creator
if (!empty($ticket->created_by))
{
$email = self::getUserEmail((int) $ticket->created_by);
if ($email)
{
$emails[] = $email;
}
}
break;
case 'ticket_assigned':
// Notify newly assigned user
if (!empty($ticket->assigned_to))
{
$email = self::getUserEmail((int) $ticket->assigned_to);
if ($email)
{
$emails[] = $email;
}
}
break;
}
return array_unique($emails);
}
/**
* Build email subject line.
*/
private static function buildSubject(string $event, object $ticket): string
{
$siteName = Factory::getConfig()->get('sitename', 'Support');
$prefix = '[' . $siteName . ' #' . $ticket->id . '] ';
switch ($event)
{
case 'ticket_created':
return $prefix . 'New Ticket: ' . ($ticket->subject ?? '');
case 'ticket_replied':
return $prefix . 'Reply: ' . ($ticket->subject ?? '');
case 'status_changed':
return $prefix . 'Status Changed: ' . ($ticket->subject ?? '');
case 'ticket_assigned':
return $prefix . 'Assigned: ' . ($ticket->subject ?? '');
default:
return $prefix . ($ticket->subject ?? '');
}
}
/**
* Build email body.
*/
private static function buildBody(string $event, object $ticket, array $extra): string
{
$siteName = Factory::getConfig()->get('sitename', 'Support');
$siteUrl = rtrim(Uri::root(), '/');
$ticketUrl = $siteUrl . '/index.php?option=com_mokowaas&view=ticket&id=' . $ticket->id;
$lines = [];
$lines[] = $siteName . ' Support';
$lines[] = str_repeat('-', 40);
$lines[] = '';
switch ($event)
{
case 'ticket_created':
$lines[] = 'A new support ticket has been created.';
$lines[] = '';
$lines[] = 'Subject: ' . ($ticket->subject ?? '');
$lines[] = 'Priority: ' . ucfirst($ticket->priority ?? 'normal');
$lines[] = 'Category: ' . ($ticket->category_title ?? 'General');
$lines[] = '';
if (!empty($ticket->body))
{
$lines[] = 'Description:';
$lines[] = strip_tags($ticket->body);
$lines[] = '';
}
break;
case 'ticket_replied':
$lines[] = 'A new reply has been added to your ticket.';
$lines[] = '';
$lines[] = 'Subject: ' . ($ticket->subject ?? '');
$lines[] = 'Status: ' . ucwords(str_replace('_', ' ', $ticket->status ?? ''));
$lines[] = '';
if (!empty($extra['reply_body']))
{
$lines[] = 'Reply:';
$lines[] = strip_tags($extra['reply_body']);
$lines[] = '';
}
break;
case 'status_changed':
$lines[] = 'Your ticket status has been updated.';
$lines[] = '';
$lines[] = 'Subject: ' . ($ticket->subject ?? '');
$lines[] = 'New Status: ' . ucwords(str_replace('_', ' ', $ticket->status ?? ''));
if (!empty($extra['old_status']))
{
$lines[] = 'Old Status: ' . ucwords(str_replace('_', ' ', $extra['old_status']));
}
$lines[] = '';
break;
case 'ticket_assigned':
$lines[] = 'A ticket has been assigned to you.';
$lines[] = '';
$lines[] = 'Subject: ' . ($ticket->subject ?? '');
$lines[] = 'Priority: ' . ucfirst($ticket->priority ?? 'normal');
$lines[] = '';
break;
}
$lines[] = 'View ticket: ' . $ticketUrl;
$lines[] = '';
$lines[] = '-- ';
$lines[] = $siteName . ' | Powered by MokoWaaS';
return implode("\n", $lines);
}
/**
* Get email address for a Joomla user ID.
*/
private static function getUserEmail(int $userId): ?string
{
if ($userId <= 0)
{
return null;
}
try
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('email'))
->from($db->quoteName('#__users'))
->where($db->quoteName('id') . ' = ' . $userId)
);
return $db->loadResult() ?: null;
}
catch (\Throwable $e)
{
return null;
}
}
/**
* Get notification configuration from component params.
*/
private static function getNotificationConfig(): array
{
try
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
);
$params = json_decode($db->loadResult() ?? '{}', true);
return $params['notifications'] ?? [];
}
catch (\Throwable $e)
{
return [];
}
}
// ==================================================================
// Security Event Notifications (#131)
// ==================================================================
/**
* Send a security alert to admin emails.
*/
public static function securityAlert(string $event, string $subject, string $body): void
{
try
{
$config = self::getNotificationConfig();
$enabled = $config['security_alerts'] ?? '1';
if (!$enabled)
{
return;
}
$adminEmails = array_filter(array_map('trim', explode(',', $config['admin_emails'] ?? '')));
$adminUserIds = array_filter(array_map('intval', explode(',', $config['admin_user_ids'] ?? '')));
$recipients = $adminEmails;
foreach ($adminUserIds as $uid)
{
$email = self::getUserEmail($uid);
if ($email)
{
$recipients[] = $email;
}
}
$recipients = array_unique($recipients);
if (empty($recipients))
{
return;
}
$siteName = Factory::getConfig()->get('sitename', 'Site');
$fullSubject = '[' . $siteName . ' Security] ' . $subject;
$lines = [
$siteName . ' Security Alert',
str_repeat('-', 40),
'',
'Event: ' . $event,
'Time: ' . gmdate('Y-m-d H:i:s') . ' UTC',
'',
$body,
'',
'-- ',
$siteName . ' | MokoWaaS Security',
];
$mailer = Factory::getMailer();
$mailer->isHtml(false);
$mailer->setSubject($fullSubject);
$mailer->setBody(implode("\n", $lines));
foreach ($recipients as $email)
{
try
{
$mailer->clearAddresses();
$mailer->addRecipient(trim($email));
$mailer->Send();
}
catch (\Throwable $e)
{
Log::add('Security alert send failed: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
}
}
}
catch (\Throwable $e)
{
Log::add('Security alert error: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
}
}
}
@@ -1,27 +0,0 @@
<?php
namespace Moko\Component\MokoWaaS\Administrator\View\Automation;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $rules = [];
public function display($tpl = null)
{
$model = new \Moko\Component\MokoWaaS\Administrator\Model\TicketsModel();
$this->rules = $model->getAutomationRules();
ToolbarHelper::title('Automation Rules', 'cogs');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas&view=tickets');
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
parent::display($tpl);
}
}
@@ -1,33 +0,0 @@
<?php
namespace Moko\Component\MokoWaaS\Administrator\View\Canned;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $responses = [];
protected $categories = [];
public function display($tpl = null)
{
$db = Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
$db->setQuery('SELECT * FROM #__mokowaas_ticket_canned ORDER BY ordering ASC');
$this->responses = $db->loadObjectList() ?: [];
$db->setQuery('SELECT id, title FROM #__mokowaas_ticket_categories WHERE published = 1 ORDER BY ordering');
$this->categories = $db->loadObjectList() ?: [];
ToolbarHelper::title('Canned Responses', 'comment');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas&view=tickets');
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
parent::display($tpl);
}
}
@@ -1,41 +0,0 @@
<?php
namespace Moko\Component\MokoWaaS\Administrator\View\Categories;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $categories = [];
protected $users = [];
public function display($tpl = null)
{
$db = Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
$db->setQuery('SELECT * FROM #__mokowaas_ticket_categories ORDER BY ordering ASC');
$this->categories = $db->loadObjectList() ?: [];
// Get admin users for auto-assign dropdown
$db->setQuery(
$db->getQuery(true)
->select([$db->quoteName('id'), $db->quoteName('name')])
->from($db->quoteName('#__users'))
->where($db->quoteName('block') . ' = 0')
->order($db->quoteName('name') . ' ASC')
->setLimit(100)
);
$this->users = $db->loadObjectList() ?: [];
ToolbarHelper::title('Ticket Categories', 'folder');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas&view=tickets');
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
parent::display($tpl);
}
}
@@ -1,27 +0,0 @@
<?php
namespace Moko\Component\MokoWaaS\Administrator\View\Cleanup;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $dirs = [];
public function display($tpl = null)
{
$model = new \Moko\Component\MokoWaaS\Administrator\Model\MaintenanceModel();
$this->dirs = $model->getCleanupInfo();
ToolbarHelper::title('Cache &amp; Temp Cleanup', 'trash');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas');
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
parent::display($tpl);
}
}
@@ -23,8 +23,6 @@ class HtmlView extends BaseHtmlView
protected $pendingUpdates = [];
protected $checkedOutItems = [];
protected $wafBlocks = [];
protected $wafChartData = [];
protected $loginChartData = [];
public function display($tpl = null)
{
@@ -36,21 +34,6 @@ class HtmlView extends BaseHtmlView
$this->pendingUpdates = $model->getPendingUpdates();
$this->checkedOutItems = $model->getCheckedOutItems();
$this->wafBlocks = $model->getRecentWafBlocks(5);
$this->wafChartData = $model->getWafBlocksByDay(14);
$this->loginChartData = $model->getLoginsByDay(14);
// Check for importable Akeeba data
try
{
$importModel = new \Moko\Component\MokoWaaS\Administrator\Model\ImportModel();
$this->adminToolsAvailable = $importModel->checkAdminToolsAvailable();
$this->atsAvailable = $importModel->checkAtsAvailable();
}
catch (\Throwable $e)
{
$this->adminToolsAvailable = null;
$this->atsAvailable = null;
}
$this->addToolbar();
@@ -1,27 +0,0 @@
<?php
namespace Moko\Component\MokoWaaS\Administrator\View\Database;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $tableData = [];
public function display($tpl = null)
{
$model = new \Moko\Component\MokoWaaS\Administrator\Model\MaintenanceModel();
$this->tableData = $model->getTableStatus();
ToolbarHelper::title('Database Tools', 'database');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas');
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
parent::display($tpl);
}
}
@@ -1,47 +0,0 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\View\Htaccess;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $options = [];
protected $preview = '';
protected $nginxPreview = '';
protected $currentHtaccess = '';
public function display($tpl = null)
{
$model = $this->getModel();
$this->options = $model->getOptions();
$this->preview = $model->generateHtaccess($this->options);
$this->nginxPreview = $model->generateNginx($this->options);
$this->currentHtaccess = $model->readCurrentHtaccess();
$this->addToolbar();
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
parent::display($tpl);
}
protected function addToolbar(): void
{
ToolbarHelper::title(Text::_('COM_MOKOWAAS_HTACCESS_TITLE'), 'file-code');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas');
}
}
@@ -1,39 +0,0 @@
<?php
namespace Moko\Component\MokoWaaS\Administrator\View\Privacy;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $requests = [];
protected $policies = [];
protected $summary;
public function display($tpl = null)
{
$model = new \Moko\Component\MokoWaaS\Administrator\Model\PrivacyModel();
$filterStatus = Factory::getApplication()->getInput()->getString('filter_status', '');
$this->requests = $model->getDataRequests($filterStatus);
$this->policies = $model->getRetentionPolicies();
$this->summary = $model->getDashboardSummary();
$this->addToolbar();
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
parent::display($tpl);
}
protected function addToolbar(): void
{
ToolbarHelper::title('Privacy Guard', 'lock');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas');
}
}
@@ -1,53 +0,0 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\View\Ticket;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $ticket;
protected $cannedResponses = [];
public function display($tpl = null)
{
$model = $this->getModel('Tickets');
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$this->ticket = $model->getTicket($id);
$this->cannedResponses = $model->getCannedResponses((int) ($this->ticket->category_id ?? 0));
if (!$this->ticket)
{
Factory::getApplication()->enqueueMessage('Ticket not found.', 'error');
Factory::getApplication()->redirect('index.php?option=com_mokowaas&view=tickets');
return;
}
$this->addToolbar();
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
parent::display($tpl);
}
protected function addToolbar(): void
{
$title = $this->ticket ? 'Ticket #' . $this->ticket->id . ' — ' . $this->ticket->subject : 'Ticket';
ToolbarHelper::title($title, 'headphones');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas&view=tickets');
}
}
@@ -1,56 +0,0 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\View\Tickets;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $tickets = [];
protected $categories = [];
protected $statusCounts;
protected $overdue = [];
protected $atsAvailable = null;
public function display($tpl = null)
{
$model = $this->getModel();
$app = Factory::getApplication();
$filters = [
'status' => $app->getInput()->getString('filter_status', ''),
'priority' => $app->getInput()->getString('filter_priority', ''),
'category_id' => $app->getInput()->getInt('filter_category', 0),
];
$this->tickets = $model->getTickets($filters);
$this->categories = $model->getCategories();
$this->statusCounts = $model->getStatusCounts();
$this->overdue = $model->getOverdueTickets();
$this->atsAvailable = $model->checkAtsAvailable();
$this->addToolbar();
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
parent::display($tpl);
}
protected function addToolbar(): void
{
ToolbarHelper::title(Text::_('COM_MOKOWAAS_TICKETS_TITLE'), 'headphones');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas');
}
}
@@ -1,55 +0,0 @@
<?php
namespace Moko\Component\MokoWaaS\Administrator\View\Waflog;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $logs = [];
protected $ruleCounts = [];
protected $topIps = [];
protected $ruleNames = [];
protected $total = 0;
protected $filters = [];
public function display($tpl = null)
{
$model = new \Moko\Component\MokoWaaS\Administrator\Model\WaflogModel();
$input = Factory::getApplication()->getInput();
$this->filters = [
'rule' => $input->getString('filter_rule', ''),
'ip' => $input->getString('filter_ip', ''),
'search' => $input->getString('filter_search', ''),
'date_from' => $input->getString('filter_date_from', ''),
'date_to' => $input->getString('filter_date_to', ''),
];
$page = max(1, $input->getInt('page', 1));
$limit = 50;
$offset = ($page - 1) * $limit;
$this->logs = $model->getLogs($this->filters, $limit, $offset);
$this->total = $model->getTotal($this->filters);
$this->ruleCounts = $model->getRuleCounts();
$this->topIps = $model->getTopIps(10);
$this->ruleNames = $model->getRuleNames();
$this->addToolbar();
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
parent::display($tpl);
}
protected function addToolbar(): void
{
ToolbarHelper::title('WAF Log Viewer', 'shield-alt');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas');
}
}
@@ -1,141 +0,0 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$rules = $this->rules;
$token = Session::getFormToken();
$saveUrl = Route::_('index.php?option=com_mokowaas&task=display.saveAutomation&format=json');
$deleteUrl = Route::_('index.php?option=com_mokowaas&task=display.deleteAutomation&format=json');
$toggleUrl = Route::_('index.php?option=com_mokowaas&task=display.toggleAutomation&format=json');
$triggerLabels = ['ticket_created' => 'On Ticket Created', 'ticket_replied' => 'On Reply', 'status_changed' => 'On Status Change', 'scheduled' => 'Scheduled (Cron)'];
?>
<div id="mokowaas-automation">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4><?php echo count($rules); ?> Automation Rules</h4>
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newRuleModal">
<span class="icon-plus"></span> Add Rule
</button>
</div>
<?php foreach ($rules as $r): ?>
<?php $conditions = json_decode($r->conditions, true) ?: []; $actions = json_decode($r->actions, true) ?: []; ?>
<div class="card mb-2 <?php echo !$r->enabled ? 'opacity-50' : ''; ?>" data-id="<?php echo $r->id; ?>">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="d-flex align-items-center gap-2">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input rule-toggle" data-id="<?php echo $r->id; ?>" <?php echo $r->enabled ? 'checked' : ''; ?>>
</div>
<strong><?php echo htmlspecialchars($r->title); ?></strong>
<span class="badge bg-secondary"><?php echo $triggerLabels[$r->trigger_event] ?? $r->trigger_event; ?></span>
</div>
<div class="small text-muted mt-1">
<span class="text-primary">IF</span>
<?php foreach ($conditions as $i => $c): ?>
<?php echo $i > 0 ? ' AND ' : ''; ?><?php echo htmlspecialchars($c['field'] ?? ''); ?> <?php echo htmlspecialchars($c['op'] ?? ''); ?> <?php echo htmlspecialchars($c['value'] ?? ''); ?>
<?php endforeach; ?>
<span class="text-success ms-2">THEN</span>
<?php foreach ($actions as $a): ?>
<?php echo htmlspecialchars($a['type'] ?? ''); ?>=<?php echo htmlspecialchars(mb_substr($a['value'] ?? '', 0, 30)); ?>
<?php endforeach; ?>
</div>
</div>
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-rule" data-id="<?php echo $r->id; ?>">
<span class="icon-trash"></span>
</button>
</div>
</div>
</div>
<?php endforeach; ?>
<?php if (empty($rules)): ?>
<div class="alert alert-info">No automation rules. Click "Add Rule" to create one.</div>
<?php endif; ?>
</div>
<!-- New Rule Modal -->
<div class="modal fade" id="newRuleModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header"><h5>Add Automation Rule</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Title</label>
<input type="text" id="rule-title" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Trigger</label>
<select id="rule-trigger" class="form-select">
<?php foreach ($triggerLabels as $k => $v): ?><option value="<?php echo $k; ?>"><?php echo $v; ?></option><?php endforeach; ?>
</select>
</div>
<div class="mb-3">
<label class="form-label">Conditions (JSON)</label>
<textarea id="rule-conditions" class="form-control font-monospace" rows="3" placeholder='[{"field":"status","op":"eq","value":"resolved"}]'></textarea>
<small class="text-muted">Fields: status, priority, category_id, assigned_to, sla_responded, age_hours. Ops: eq, neq, gt, lt, in, not_in</small>
</div>
<div class="mb-3">
<label class="form-label">Actions (JSON)</label>
<textarea id="rule-actions" class="form-control font-monospace" rows="3" placeholder='[{"type":"set_status","value":"closed"}]'></textarea>
<small class="text-muted">Types: set_status, set_priority, assign, add_note, send_email</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="btn-save-rule"><span class="icon-save"></span> Save Rule</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var token = '<?php echo $token; ?>';
// Save new rule
document.getElementById('btn-save-rule').addEventListener('click', function() {
var fd = new FormData();
fd.append('id', '0');
fd.append('title', document.getElementById('rule-title').value);
fd.append('trigger_event', document.getElementById('rule-trigger').value);
fd.append('conditions', document.getElementById('rule-conditions').value || '[]');
fd.append('actions', document.getElementById('rule-actions').value || '[]');
fd.append(token, '1');
fetch('<?php echo $saveUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){ if (d.success) location.reload(); else Joomla.renderMessages({error:[d.message]}); });
});
// Toggle rule
document.querySelectorAll('.rule-toggle').forEach(function(cb) {
cb.addEventListener('change', function() {
var fd = new FormData();
fd.append('id', this.dataset.id);
fd.append('enabled', this.checked ? '1' : '0');
fd.append(token, '1');
fetch('<?php echo $toggleUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){ if (!d.success) Joomla.renderMessages({error:[d.message]}); });
});
});
// Delete rule
document.querySelectorAll('.btn-delete-rule').forEach(function(btn) {
btn.addEventListener('click', function() {
if (!confirm('Delete this rule?')) return;
var card = this.closest('.card');
var fd = new FormData();
fd.append('id', this.dataset.id);
fd.append(token, '1');
fetch('<?php echo $deleteUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){ if (d.success) card.remove(); else Joomla.renderMessages({error:[d.message]}); });
});
});
});
</script>
@@ -1,107 +0,0 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$responses = $this->responses;
$categories = $this->categories;
$token = Session::getFormToken();
$saveUrl = Route::_('index.php?option=com_mokowaas&task=display.saveCanned&format=json');
$deleteUrl = Route::_('index.php?option=com_mokowaas&task=display.deleteCanned&format=json');
?>
<div id="mokowaas-canned">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4><?php echo count($responses); ?> Canned Responses</h4>
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newCannedModal">
<span class="icon-plus"></span> Add Response
</button>
</div>
<?php foreach ($responses as $r): ?>
<div class="card mb-2" data-id="<?php echo $r->id; ?>">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-start">
<div>
<strong><?php echo htmlspecialchars($r->title); ?></strong>
<p class="text-muted small mb-0 mt-1"><?php echo htmlspecialchars(mb_substr($r->body, 0, 150)); ?></p>
</div>
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-canned" data-id="<?php echo $r->id; ?>">
<span class="icon-trash"></span>
</button>
</div>
</div>
</div>
<?php endforeach; ?>
<?php if (empty($responses)): ?>
<div class="alert alert-info">No canned responses yet. Click "Add Response" to create one.</div>
<?php endif; ?>
</div>
<!-- New Canned Modal -->
<div class="modal fade" id="newCannedModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header"><h5>Add Canned Response</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Title</label>
<input type="text" id="canned-title" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Category (optional)</label>
<select id="canned-category" class="form-select">
<option value="">All categories</option>
<?php foreach ($categories as $cat): ?>
<option value="<?php echo $cat->id; ?>"><?php echo htmlspecialchars($cat->title); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="mb-3">
<label class="form-label">Response Text</label>
<textarea id="canned-body" class="form-control" rows="6" required></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="btn-save-canned"><span class="icon-save"></span> Save</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var token = '<?php echo $token; ?>';
document.getElementById('btn-save-canned').addEventListener('click', function() {
var fd = new FormData();
fd.append('id', '0');
fd.append('title', document.getElementById('canned-title').value);
fd.append('body', document.getElementById('canned-body').value);
fd.append('category_id', document.getElementById('canned-category').value);
fd.append(token, '1');
fetch('<?php echo $saveUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) location.reload();
else Joomla.renderMessages({error:[d.message]});
});
});
document.querySelectorAll('.btn-delete-canned').forEach(function(btn) {
btn.addEventListener('click', function() {
if (!confirm('Delete this canned response?')) return;
var card = this.closest('.card');
var fd = new FormData();
fd.append('id', this.dataset.id);
fd.append(token, '1');
fetch('<?php echo $deleteUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){ if (d.success) card.remove(); else Joomla.renderMessages({error:[d.message]}); });
});
});
});
</script>
@@ -1,126 +0,0 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$categories = $this->categories;
$users = $this->users;
$token = Session::getFormToken();
$saveUrl = Route::_('index.php?option=com_mokowaas&task=display.saveCategory&format=json');
$deleteUrl = Route::_('index.php?option=com_mokowaas&task=display.deleteCategory&format=json');
?>
<div id="mokowaas-categories">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4><?php echo count($categories); ?> Categories</h4>
<button type="button" class="btn btn-primary btn-sm" id="btn-add-cat">
<span class="icon-plus"></span> Add Category
</button>
</div>
<div class="card">
<div class="table-responsive">
<table class="table table-striped mb-0" id="cat-table">
<thead><tr><th>Title</th><th>SLA Response</th><th>SLA Resolution</th><th>Auto-Assign</th><th>Active</th><th></th></tr></thead>
<tbody>
<?php foreach ($categories as $c): ?>
<tr data-id="<?php echo $c->id; ?>">
<td><input type="text" class="form-control form-control-sm cat-field" data-field="title" value="<?php echo htmlspecialchars($c->title); ?>"></td>
<td><input type="number" class="form-control form-control-sm cat-field" data-field="sla_response_minutes" value="<?php echo $c->sla_response_minutes; ?>" style="width:80px"> min</td>
<td><input type="number" class="form-control form-control-sm cat-field" data-field="sla_resolution_minutes" value="<?php echo $c->sla_resolution_minutes; ?>" style="width:80px"> min</td>
<td>
<select class="form-select form-select-sm cat-field" data-field="auto_assign_user">
<option value="">None</option>
<?php foreach ($users as $u): ?>
<option value="<?php echo $u->id; ?>" <?php echo (int)$c->auto_assign_user === (int)$u->id ? 'selected' : ''; ?>><?php echo htmlspecialchars($u->name); ?></option>
<?php endforeach; ?>
</select>
</td>
<td>
<input type="checkbox" class="form-check-input cat-field" data-field="published" <?php echo $c->published ? 'checked' : ''; ?>>
</td>
<td>
<button type="button" class="btn btn-sm btn-outline-success btn-save-cat" title="Save"><span class="icon-save"></span></button>
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-cat" title="Delete"><span class="icon-trash"></span></button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var token = '<?php echo $token; ?>';
// Save category
document.querySelectorAll('.btn-save-cat').forEach(function(btn) {
btn.addEventListener('click', function() {
var row = this.closest('tr');
var id = row.dataset.id || '0';
var fd = new FormData();
fd.append('id', id);
fd.append(token, '1');
row.querySelectorAll('.cat-field').forEach(function(f) {
fd.append(f.dataset.field, f.type === 'checkbox' ? (f.checked ? '1' : '0') : f.value);
});
fetch('<?php echo $saveUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) { Joomla.renderMessages({message:[d.message]}); if (d.id && id === '0') row.dataset.id = d.id; }
else Joomla.renderMessages({error:[d.message]});
});
});
});
// Delete category
document.querySelectorAll('.btn-delete-cat').forEach(function(btn) {
btn.addEventListener('click', function() {
if (!confirm('Delete this category?')) return;
var row = this.closest('tr');
var fd = new FormData();
fd.append('id', row.dataset.id);
fd.append(token, '1');
fetch('<?php echo $deleteUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) row.remove();
else Joomla.renderMessages({error:[d.message]});
});
});
});
// Add new row
document.getElementById('btn-add-cat').addEventListener('click', function() {
var tbody = document.querySelector('#cat-table tbody');
var tr = document.createElement('tr');
tr.dataset.id = '0';
tr.innerHTML = '<td><input type="text" class="form-control form-control-sm cat-field" data-field="title" value=""></td>'
+ '<td><input type="number" class="form-control form-control-sm cat-field" data-field="sla_response_minutes" value="480" style="width:80px"> min</td>'
+ '<td><input type="number" class="form-control form-control-sm cat-field" data-field="sla_resolution_minutes" value="2880" style="width:80px"> min</td>'
+ '<td><select class="form-select form-select-sm cat-field" data-field="auto_assign_user"><option value="">None</option><?php foreach ($users as $u): ?><option value="<?php echo $u->id; ?>"><?php echo htmlspecialchars($u->name); ?></option><?php endforeach; ?></select></td>'
+ '<td><input type="checkbox" class="form-check-input cat-field" data-field="published" checked></td>'
+ '<td><button type="button" class="btn btn-sm btn-outline-success btn-save-cat"><span class="icon-save"></span></button></td>';
tbody.appendChild(tr);
tr.querySelector('.btn-save-cat').addEventListener('click', function() {
var row = this.closest('tr');
var fd = new FormData();
fd.append('id', '0');
fd.append(token, '1');
row.querySelectorAll('.cat-field').forEach(function(f) {
fd.append(f.dataset.field, f.type === 'checkbox' ? (f.checked ? '1' : '0') : f.value);
});
fetch('<?php echo $saveUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) { Joomla.renderMessages({message:[d.message]}); location.reload(); }
else Joomla.renderMessages({error:[d.message]});
});
});
tr.querySelector('input').focus();
});
});
</script>
@@ -1,63 +0,0 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$dirs = $this->dirs;
$token = Session::getFormToken();
$cleanUrl = Route::_('index.php?option=com_mokowaas&task=display.cleanDirectory&format=json');
$dirKeys = ['site_cache', 'admin_cache', 'tmp', 'logs'];
$totalMb = 0;
$totalFiles = 0;
foreach ($dirs as $d) { $totalMb += $d->size_mb; $totalFiles += $d->files; }
?>
<div id="mokowaas-cleanup">
<div class="row g-3 mb-4">
<div class="col-6 col-md-3"><div class="card text-center p-3"><span class="fw-bold fs-3"><?php echo number_format($totalMb, 1); ?> MB</span><small class="text-muted">Total Size</small></div></div>
<div class="col-6 col-md-3"><div class="card text-center p-3"><span class="fw-bold fs-3"><?php echo number_format($totalFiles); ?></span><small class="text-muted">Total Files</small></div></div>
</div>
<div class="row g-3">
<?php foreach ($dirs as $i => $d): ?>
<div class="col-12 col-md-6 col-xl-3">
<div class="card h-100">
<div class="card-body text-center">
<h5><?php echo htmlspecialchars($d->label); ?></h5>
<p class="fs-3 fw-bold mb-1 <?php echo $d->size_mb > 50 ? 'text-warning' : ''; ?>"><?php echo number_format($d->size_mb, 1); ?> MB</p>
<p class="text-muted small"><?php echo number_format($d->files); ?> files</p>
<?php if (!$d->writable): ?>
<span class="badge bg-danger">Not writable</span>
<?php else: ?>
<button type="button" class="btn btn-outline-danger btn-clean" data-key="<?php echo $dirKeys[$i] ?? ''; ?>" data-label="<?php echo htmlspecialchars($d->label); ?>">
<span class="icon-trash"></span> Clean
</button>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<script>
document.querySelectorAll('.btn-clean').forEach(function(btn) {
btn.addEventListener('click', function() {
if (!confirm('Clean all files in ' + this.dataset.label + '?')) return;
var el = this;
el.disabled = true;
var fd = new FormData();
fd.append('dir_key', el.dataset.key);
fd.append('<?php echo $token; ?>', '1');
fetch('<?php echo $cleanUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) { Joomla.renderMessages({message:[d.message]}); setTimeout(function(){location.reload()},1500); }
else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; }
})
.catch(function(){ el.disabled = false; });
});
});
</script>
@@ -19,8 +19,6 @@ $siteInfo = $this->siteInfo;
$plugins = $this->plugins;
$recentLogins = $this->recentLogins;
$pendingUpdates = $this->pendingUpdates;
$adminToolsAvail = $this->adminToolsAvailable ?? null;
$atsAvail = $this->atsAvailable ?? null;
$checkedOut = $this->checkedOutItems;
$wafBlocks = $this->wafBlocks;
$token = Session::getFormToken();
@@ -65,93 +63,29 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
<?php if ($siteInfo->offline): ?>
<span class="badge bg-danger"><?php echo Text::_('COM_MOKOWAAS_OFFLINE'); ?></span>
<?php endif; ?>
<div class="mokowaas-info-item ms-auto">
<span class="icon-globe" aria-hidden="true"></span>
<code><?php echo $this->escape($_SERVER['REMOTE_ADDR'] ?? ''); ?></code>
</div>
</div>
</div>
<?php if ($adminToolsAvail || $atsAvail): ?>
<!-- Akeeba Import Banner -->
<div class="alert alert-info d-flex flex-wrap align-items-center gap-3 mb-4">
<span class="icon-info-circle" style="font-size:1.25rem"></span>
<strong>Akeeba data detected — import into MokoWaaS:</strong>
<?php if ($adminToolsAvail): ?>
<button type="button" class="btn btn-sm btn-info" id="btn-import-admintools"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.importAdminTools&format=json'); ?>"
data-token="<?php echo $token; ?>">
<span class="icon-shield-alt"></span> Import Admin Tools Settings
</button>
<?php endif; ?>
<?php if ($atsAvail): ?>
<button type="button" class="btn btn-sm btn-info" id="btn-import-ats-dash"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.importAts&format=json'); ?>"
data-token="<?php echo $token; ?>">
<span class="icon-headphones"></span> Import Tickets (<?php echo $atsAvail->tickets; ?> tickets)
</button>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- Quick Actions (large buttons) -->
<div class="row g-3 mb-4">
<div class="col-6 col-md-4 col-xl-3">
<div class="col-12 col-md-4">
<button type="button" class="btn btn-outline-primary w-100 py-3" id="mokowaas-btn-cache"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.clearCache&format=json'); ?>"
data-token="<?php echo $token; ?>">
<span class="icon-bolt d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
Clear Cache
<span class="icon-trash d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
<?php echo Text::_('COM_MOKOWAAS_CLEAR_CACHE'); ?>
</button>
</div>
<div class="col-6 col-md-4 col-xl-3">
<div class="col-12 col-md-4">
<a href="<?php echo Route::_('index.php?option=com_installer&view=update'); ?>" class="btn btn-outline-primary w-100 py-3">
<span class="icon-refresh d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
Check Updates
<?php echo Text::_('COM_MOKOWAAS_CHECK_UPDATES'); ?>
</a>
</div>
<div class="col-6 col-md-4 col-xl-3">
<div class="col-12 col-md-4">
<a href="<?php echo Route::_('index.php?option=com_mokowaas&view=extensions'); ?>" class="btn btn-outline-primary w-100 py-3">
<span class="icon-puzzle-piece d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
Moko Extensions
</a>
</div>
<div class="col-6 col-md-4 col-xl-3">
<a href="<?php echo Route::_('index.php?option=com_checkin'); ?>" class="btn btn-outline-secondary w-100 py-3">
<span class="icon-check-square d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
Global Check-in
</a>
</div>
<div class="col-6 col-md-4 col-xl-3">
<a href="<?php echo Route::_('index.php?option=com_actionlogs'); ?>" class="btn btn-outline-secondary w-100 py-3">
<span class="icon-list d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
View Logs
</a>
</div>
<div class="col-6 col-md-4 col-xl-3">
<a href="<?php echo Route::_('index.php?option=com_scheduler'); ?>" class="btn btn-outline-secondary w-100 py-3">
<span class="icon-clock d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
Scheduled Tasks
</a>
</div>
<div class="col-6 col-md-4 col-xl-3">
<?php
// Use MokoJoomCommunity if available, otherwise Joomla user manager
$useCB = file_exists(JPATH_ADMINISTRATOR . '/components/com_comprofiler/comprofiler.php');
$userUrl = $useCB
? Route::_('index.php?option=com_comprofiler&task=showusers')
: Route::_('index.php?option=com_users');
$userLabel = $useCB ? 'MokoJoomCommunity' : 'User Manager';
?>
<a href="<?php echo $userUrl; ?>" class="btn btn-outline-secondary w-100 py-3">
<span class="icon-users d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
<?php echo $userLabel; ?>
</a>
</div>
<div class="col-6 col-md-4 col-xl-3">
<a href="<?php echo Route::_('index.php?option=com_redirect'); ?>" class="btn btn-outline-secondary w-100 py-3">
<span class="icon-arrow-right d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
Redirects
<?php echo Text::_('COM_MOKOWAAS_EXTENSIONS_LINK'); ?>
</a>
</div>
</div>
@@ -184,14 +118,10 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
<span class="badge bg-light text-dark"><?php echo $this->escape($plugin->version); ?></span>
<?php endif; ?>
</div>
<p class="card-text text-muted text-muted flex-grow-1"><?php echo $this->escape($plugin->description); ?></p>
<p class="card-text text-muted small flex-grow-1"><?php echo $this->escape($plugin->description); ?></p>
<div class="d-flex align-items-center justify-content-between mt-auto pt-2 border-top">
<?php if ($plugin->protected): ?>
<span class="badge bg-dark"><?php echo Text::_('COM_MOKOWAAS_PROTECTED'); ?></span>
<?php elseif ($plugin->configure_only): ?>
<span class="badge bg-<?php echo $plugin->enabled ? 'success' : 'secondary'; ?>">
<?php echo $plugin->enabled ? Text::_('COM_MOKOWAAS_ENABLED') : Text::_('COM_MOKOWAAS_DISABLED'); ?>
</span>
<?php else: ?>
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input mokowaas-toggle" role="switch"
@@ -200,7 +130,7 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.togglePlugin&format=json'); ?>"
data-token="<?php echo $token; ?>"
<?php echo $plugin->enabled ? 'checked' : ''; ?>>
<label class="form-check-label" for="toggle-<?php echo $plugin->extension_id; ?>">
<label class="form-check-label small" for="toggle-<?php echo $plugin->extension_id; ?>">
<?php echo $plugin->enabled ? Text::_('COM_MOKOWAAS_ENABLED') : Text::_('COM_MOKOWAAS_DISABLED'); ?>
</label>
</div>
@@ -219,28 +149,8 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
<?php endforeach; ?>
</div>
<!-- Right: Charts & Information (4 cols) -->
<div class="col-12 col-xl-4" style="border-left:1px solid var(--gray-300, #dee2e6);padding-left:1.5rem;">
<!-- WAF Activity Chart -->
<div class="card mb-3">
<div class="card-header">
<strong><span class="icon-shield-alt" aria-hidden="true"></span> WAF Activity (14 days)</strong>
</div>
<div class="card-body py-2">
<canvas id="mokowaas-chart-waf" height="140"></canvas>
</div>
</div>
<!-- Login Activity Chart -->
<div class="card mb-3">
<div class="card-header">
<strong><span class="icon-user" aria-hidden="true"></span> Login Activity (14 days)</strong>
</div>
<div class="card-body py-2">
<canvas id="mokowaas-chart-logins" height="140"></canvas>
</div>
</div>
<!-- Right: Information Tables (4 cols) -->
<div class="col-12 col-xl-4">
<!-- Pending Updates -->
<div class="card mb-3">
@@ -255,16 +165,16 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
<tbody>
<?php foreach ($pendingUpdates as $upd): ?>
<tr>
<td class="text-muted"><?php echo $this->escape($upd->name); ?></td>
<td class="text-muted"><?php echo $this->escape($upd->current_version); ?></td>
<td class="text-success fw-bold"><?php echo $this->escape($upd->version); ?></td>
<td class="small"><?php echo $this->escape($upd->name); ?></td>
<td class="small text-muted"><?php echo $this->escape($upd->current_version); ?></td>
<td class="small text-success"><?php echo $this->escape($upd->version); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div class="card-body text-center text-muted py-3">
<div class="card-body text-center text-muted small py-3">
<span class="icon-check-circle text-success"></span> All extensions up to date
</div>
<?php endif; ?>
@@ -283,19 +193,19 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
<tbody>
<?php foreach ($checkedOut as $item): ?>
<tr>
<td class="text-muted"><?php echo $this->escape(mb_substr($item->title, 0, 30)); ?></td>
<td class="text-muted"><?php echo $this->escape($item->username ?? ''); ?></td>
<td class="text-muted"><?php echo HTMLHelper::_('date', $item->checked_out_time, 'M d H:i'); ?></td>
<td class="small"><?php echo $this->escape(mb_substr($item->title, 0, 30)); ?></td>
<td class="small"><?php echo $this->escape($item->username ?? ''); ?></td>
<td class="small text-muted"><?php echo HTMLHelper::_('date', $item->checked_out_time, 'M d H:i'); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="card-footer text-center py-1">
<a href="<?php echo Route::_('index.php?option=com_checkin'); ?>" class="text-muted">Global Check-in</a>
<a href="<?php echo Route::_('index.php?option=com_checkin'); ?>" class="small">Global Check-in</a>
</div>
<?php else: ?>
<div class="card-body text-center text-muted py-3">
<div class="card-body text-center text-muted small py-3">
<span class="icon-check-circle text-success"></span> No checked out items
</div>
<?php endif; ?>
@@ -314,16 +224,16 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
<tbody>
<?php foreach ($wafBlocks as $block): ?>
<tr>
<td class="text-muted"><code><?php echo $this->escape($block->ip); ?></code></td>
<td class="text-muted"><span class="badge bg-danger"><?php echo $this->escape($block->rule); ?></span></td>
<td class="text-muted"><?php echo HTMLHelper::_('date', $block->created, 'M d H:i'); ?></td>
<td class="small"><code><?php echo $this->escape($block->ip); ?></code></td>
<td class="small"><span class="badge bg-danger"><?php echo $this->escape($block->rule); ?></span></td>
<td class="small text-muted"><?php echo HTMLHelper::_('date', $block->created, 'M d H:i'); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div class="card-body text-center text-muted py-3">
<div class="card-body text-center text-muted small py-3">
<span class="icon-check-circle text-success"></span> No recent blocks
</div>
<?php endif; ?>
@@ -341,85 +251,19 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
<tbody>
<?php foreach ($recentLogins as $login): ?>
<tr>
<td class="text-muted"><?php echo $this->escape($login->username ?? ''); ?></td>
<td class="text-muted"><code><?php echo $this->escape($login->ip_address ?? ''); ?></code></td>
<td class="text-muted"><?php echo HTMLHelper::_('date', $login->log_date, 'M d H:i'); ?></td>
<td class="small"><?php echo $this->escape($login->username ?? ''); ?></td>
<td class="small"><code><?php echo $this->escape($login->ip_address ?? ''); ?></code></td>
<td class="small text-muted"><?php echo HTMLHelper::_('date', $login->log_date, 'M d H:i'); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div class="card-body text-center text-muted py-3">No login activity recorded</div>
<div class="card-body text-center text-muted small py-3">No login activity recorded</div>
<?php endif; ?>
</div>
</div><!-- /.col-xl-4 -->
</div><!-- /.row -->
</div>
<?php
// Prepare chart data as JSON for JavaScript
$wafChartData = $this->wafChartData ?? [];
$loginChartData = $this->loginChartData ?? [];
$wafLabels = array_map(fn($d) => $d->day, $wafChartData);
$wafValues = array_map(fn($d) => $d->total, $wafChartData);
$loginLabels = array_map(fn($d) => $d->day, $loginChartData);
$loginValues = array_map(fn($d) => $d->total, $loginChartData);
?>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js" integrity="sha384-jb8JQMbMoBUzgWatfe6COACi2ljcDdZQ2OxczGA3bGNeWe+6DChMTBJemed7ZnvJ" crossorigin="anonymous"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var chartDefaults = {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { display: false }, ticks: { maxRotation: 45, font: { size: 10 } } },
y: { beginAtZero: true, ticks: { stepSize: 1, font: { size: 10 } } }
}
};
// WAF chart
var wafCtx = document.getElementById('mokowaas-chart-waf');
if (wafCtx) {
new Chart(wafCtx, {
type: 'bar',
data: {
labels: <?php echo json_encode($wafLabels); ?>,
datasets: [{
data: <?php echo json_encode($wafValues); ?>,
backgroundColor: 'rgba(197, 40, 39, 0.6)',
borderColor: '#c52827',
borderWidth: 1,
borderRadius: 3
}]
},
options: chartDefaults
});
}
// Login chart
var loginCtx = document.getElementById('mokowaas-chart-logins');
if (loginCtx) {
new Chart(loginCtx, {
type: 'line',
data: {
labels: <?php echo json_encode($loginLabels); ?>,
datasets: [{
data: <?php echo json_encode($loginValues); ?>,
borderColor: '#2a69b8',
backgroundColor: 'rgba(42, 105, 184, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 3,
pointBackgroundColor: '#2a69b8'
}]
},
options: chartDefaults
});
}
});
</script>
@@ -1,72 +0,0 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$data = $this->tableData;
$tables = $data['tables'] ?? [];
$token = Session::getFormToken();
$optimizeUrl = Route::_('index.php?option=com_mokowaas&task=display.optimizeDb&format=json');
$repairUrl = Route::_('index.php?option=com_mokowaas&task=display.repairDb&format=json');
$purgeUrl = Route::_('index.php?option=com_mokowaas&task=display.purgeSessions&format=json');
?>
<div id="mokowaas-database">
<div class="row g-3 mb-4">
<div class="col-6 col-md-3"><div class="card text-center p-3"><span class="fw-bold fs-3"><?php echo $data['count']; ?></span><small class="text-muted">Tables</small></div></div>
<div class="col-6 col-md-3"><div class="card text-center p-3"><span class="fw-bold fs-3"><?php echo $data['total_size_mb']; ?> MB</span><small class="text-muted">Total Size</small></div></div>
<div class="col-6 col-md-3"><div class="card text-center p-3"><span class="fw-bold fs-3 <?php echo $data['total_overhead_kb'] > 100 ? 'text-warning' : 'text-success'; ?>"><?php echo $data['total_overhead_kb']; ?> KB</span><small class="text-muted">Overhead</small></div></div>
<div class="col-6 col-md-3">
<div class="card p-3 d-grid gap-2">
<button type="button" class="btn btn-sm btn-primary btn-db-action" data-url="<?php echo $optimizeUrl; ?>" data-token="<?php echo $token; ?>" data-confirm="Optimize all tables with overhead?">
<span class="icon-bolt"></span> Optimize All
</button>
<button type="button" class="btn btn-sm btn-outline-warning btn-db-action" data-url="<?php echo $repairUrl; ?>" data-token="<?php echo $token; ?>" data-confirm="Repair all tables?">
<span class="icon-wrench"></span> Repair All
</button>
<button type="button" class="btn btn-sm btn-outline-secondary btn-db-action" data-url="<?php echo $purgeUrl; ?>" data-token="<?php echo $token; ?>" data-confirm="Purge expired sessions?">
<span class="icon-trash"></span> Purge Sessions
</button>
</div>
</div>
</div>
<div class="card">
<div class="table-responsive">
<table class="table table-striped table-sm mb-0">
<thead><tr><th>Table</th><th>Engine</th><th class="text-end">Rows</th><th class="text-end">Size</th><th class="text-end">Overhead</th></tr></thead>
<tbody>
<?php foreach ($tables as $t): ?>
<tr class="<?php echo $t->overhead_kb > 10 ? 'table-warning' : ''; ?> <?php echo $t->is_moko ? 'fw-bold' : ''; ?>">
<td class="small"><?php echo htmlspecialchars($t->name); ?></td>
<td class="small"><?php echo htmlspecialchars($t->engine); ?></td>
<td class="text-end small"><?php echo number_format($t->rows); ?></td>
<td class="text-end small"><?php echo $t->size_mb; ?> MB</td>
<td class="text-end small <?php echo $t->overhead_kb > 10 ? 'text-warning fw-bold' : ''; ?>"><?php echo $t->overhead_kb > 0 ? $t->overhead_kb . ' KB' : '—'; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
<script>
document.querySelectorAll('.btn-db-action').forEach(function(btn) {
btn.addEventListener('click', function() {
if (!confirm(this.dataset.confirm)) return;
var el = this;
el.disabled = true;
var fd = new FormData();
fd.append(el.dataset.token, '1');
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) { Joomla.renderMessages({message:[d.message]}); setTimeout(function(){location.reload()},1500); }
else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; }
})
.catch(function(){ el.disabled = false; });
});
});
</script>
@@ -1,306 +0,0 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$opts = $this->options;
$preview = $this->preview;
$nginx = $this->nginxPreview;
$current = $this->currentHtaccess;
$token = Session::getFormToken();
$saveUrl = Route::_('index.php?option=com_mokowaas&task=display.saveHtaccess&format=json');
$genUrl = Route::_('index.php?option=com_mokowaas&task=display.generateHtaccess&format=json');
// Helper for toggle switch
$sw = function($name, $label, $desc = '') use ($opts) {
$checked = !empty($opts[$name]) ? 'checked' : '';
echo '<div class="d-flex justify-content-between align-items-center py-2 border-bottom">';
echo '<div><strong>' . htmlspecialchars($label) . '</strong>';
if ($desc) echo '<br><small class="text-muted">' . htmlspecialchars($desc) . '</small>';
echo '</div>';
echo '<div class="form-check form-switch">';
echo '<input type="checkbox" class="form-check-input htaccess-opt" name="' . $name . '" id="htopt-' . $name . '" ' . $checked . '>';
echo '</div></div>';
};
?>
<div id="mokowaas-htaccess">
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item"><a class="nav-link active" data-bs-toggle="tab" href="#tab-htaccess" role="tab">.htaccess</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-nginx" role="tab">NginX</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-current" role="tab">Current File</a></li>
</ul>
<div class="tab-content">
<!-- .htaccess Tab -->
<div class="tab-pane fade show active" id="tab-htaccess" role="tabpanel">
<div class="row">
<!-- Left: Options -->
<div class="col-12 col-xl-6">
<div class="card mb-3">
<div class="card-header"><strong><span class="icon-shield-alt"></span> Security</strong></div>
<div class="card-body">
<?php $sw('disable_directory_listing', 'Disable Directory Listing', 'Options -Indexes'); ?>
<?php $sw('block_sensitive_files', 'Block Sensitive Files', 'htaccess.txt, configuration.php-dist, etc.'); ?>
<?php $sw('block_php_in_uploads', 'Block PHP in Uploads', 'Prevent .php in images/, media/, tmp/'); ?>
<?php $sw('disable_server_signature', 'Hide Server Signature', 'ServerSignature Off, remove X-Powered-By'); ?>
<?php $sw('prevent_clickjacking', 'Clickjacking Protection', 'X-Frame-Options: SAMEORIGIN'); ?>
<?php $sw('prevent_mime_sniffing', 'MIME Sniffing Prevention', 'X-Content-Type-Options: nosniff'); ?>
<?php $sw('xss_protection', 'XSS Protection Header', 'X-XSS-Protection: 1; mode=block'); ?>
<?php $sw('disable_trace_track', 'Disable TRACE/TRACK', 'Block HTTP TRACE and TRACK methods'); ?>
<div class="py-2 border-bottom">
<label class="form-label fw-bold" for="htopt-referrer_policy">Referrer Policy</label>
<select class="form-select form-select-sm htaccess-opt" name="referrer_policy" id="htopt-referrer_policy">
<option value="off" <?php echo ($opts['referrer_policy'] ?? '') === 'off' ? 'selected' : ''; ?>>Off</option>
<option value="no-referrer" <?php echo ($opts['referrer_policy'] ?? '') === 'no-referrer' ? 'selected' : ''; ?>>no-referrer</option>
<option value="same-origin" <?php echo ($opts['referrer_policy'] ?? '') === 'same-origin' ? 'selected' : ''; ?>>same-origin</option>
<option value="strict-origin-when-cross-origin" <?php echo ($opts['referrer_policy'] ?? '') === 'strict-origin-when-cross-origin' ? 'selected' : ''; ?>>strict-origin-when-cross-origin</option>
</select>
</div>
<?php $sw('hsts_enabled', 'HSTS (Force HTTPS)', 'Strict-Transport-Security header'); ?>
<div class="ps-4 <?php echo empty($opts['hsts_enabled']) ? 'd-none' : ''; ?>" id="hsts-options">
<div class="row g-2 py-2">
<div class="col-6">
<label class="form-label small">Max Age (seconds)</label>
<input type="number" class="form-control form-control-sm htaccess-opt" name="hsts_max_age" value="<?php echo (int) ($opts['hsts_max_age'] ?? 31536000); ?>">
</div>
<div class="col-6 d-flex align-items-end">
<div class="form-check">
<input type="checkbox" class="form-check-input htaccess-opt" name="hsts_subdomains" id="htopt-hsts_sub" <?php echo !empty($opts['hsts_subdomains']) ? 'checked' : ''; ?>>
<label class="form-check-label small" for="htopt-hsts_sub">Include Subdomains</label>
</div>
</div>
</div>
</div>
<?php $sw('csp_enabled', 'Content Security Policy', 'CSP header'); ?>
<div class="ps-4 <?php echo empty($opts['csp_enabled']) ? 'd-none' : ''; ?>" id="csp-options">
<textarea class="form-control form-control-sm htaccess-opt mt-1" name="csp_value" rows="2" placeholder="default-src 'self'; script-src 'self' 'unsafe-inline'"><?php echo htmlspecialchars($opts['csp_value'] ?? ''); ?></textarea>
</div>
<?php $sw('permissions_policy', 'Permissions Policy', 'Camera, microphone, geolocation controls'); ?>
<div class="ps-4 <?php echo empty($opts['permissions_policy']) ? 'd-none' : ''; ?>" id="perms-options">
<textarea class="form-control form-control-sm htaccess-opt mt-1" name="permissions_value" rows="2" placeholder="camera=(), microphone=(), geolocation=()"><?php echo htmlspecialchars($opts['permissions_value'] ?? ''); ?></textarea>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><strong><span class="icon-bolt"></span> Performance</strong></div>
<div class="card-body">
<?php $sw('enable_gzip', 'GZip Compression', 'Compress CSS, JS, HTML, XML, JSON'); ?>
<?php $sw('enable_expires', 'Browser Caching', 'Set expiration headers for static files'); ?>
<div class="ps-4 <?php echo empty($opts['enable_expires']) ? 'd-none' : ''; ?>" id="expires-options">
<div class="row g-2 py-2">
<div class="col-4">
<label class="form-label small">HTML (sec)</label>
<input type="number" class="form-control form-control-sm htaccess-opt" name="expires_html" value="<?php echo (int) ($opts['expires_html'] ?? 3600); ?>">
</div>
<div class="col-4">
<label class="form-label small">CSS/JS (sec)</label>
<input type="number" class="form-control form-control-sm htaccess-opt" name="expires_css_js" value="<?php echo (int) ($opts['expires_css_js'] ?? 2592000); ?>">
</div>
<div class="col-4">
<label class="form-label small">Images (sec)</label>
<input type="number" class="form-control form-control-sm htaccess-opt" name="expires_images" value="<?php echo (int) ($opts['expires_images'] ?? 31536000); ?>">
</div>
</div>
</div>
<?php $sw('etag_control', 'Disable ETags', 'For load-balanced environments'); ?>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><strong><span class="icon-search"></span> SEO / Redirects</strong></div>
<div class="card-body">
<div class="py-2 border-bottom">
<label class="form-label fw-bold">WWW Redirect</label>
<select class="form-select form-select-sm htaccess-opt" name="www_redirect">
<option value="off" <?php echo ($opts['www_redirect'] ?? 'off') === 'off' ? 'selected' : ''; ?>>Off</option>
<option value="www" <?php echo ($opts['www_redirect'] ?? '') === 'www' ? 'selected' : ''; ?>>Force www</option>
<option value="non-www" <?php echo ($opts['www_redirect'] ?? '') === 'non-www' ? 'selected' : ''; ?>>Force non-www</option>
</select>
</div>
<?php $sw('redirect_index_php', 'Redirect /index.php to /', 'SEO-friendly root redirect'); ?>
<?php $sw('force_trailing_slash', 'Force Trailing Slash', 'Append / to URLs without file extension'); ?>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><strong><span class="icon-code"></span> Custom Rules</strong></div>
<div class="card-body">
<textarea class="form-control htaccess-opt" name="custom_rules" rows="4" placeholder="# Add custom Apache directives here"><?php echo htmlspecialchars($opts['custom_rules'] ?? ''); ?></textarea>
</div>
</div>
</div>
<!-- Right: Preview -->
<div class="col-12 col-xl-6">
<div class="card mb-3 sticky-top" style="top:1rem">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Preview</strong>
<span class="badge bg-secondary" id="htaccess-line-count"><?php echo substr_count($preview, "\n"); ?> lines</span>
</div>
<div class="card-body p-0">
<textarea id="htaccess-preview" class="form-control font-monospace border-0" rows="30" readonly style="font-size:0.8rem;resize:none"><?php echo htmlspecialchars($preview); ?></textarea>
</div>
<div class="card-footer d-flex gap-2">
<button type="button" class="btn btn-primary" id="htaccess-save"
data-url="<?php echo $saveUrl; ?>" data-token="<?php echo $token; ?>">
<span class="icon-save"></span> Save to .htaccess
</button>
<button type="button" class="btn btn-outline-secondary" id="htaccess-download">
<span class="icon-download"></span> Download
</button>
</div>
</div>
</div>
</div>
</div>
<!-- NginX Tab -->
<div class="tab-pane fade" id="tab-nginx" role="tabpanel">
<div class="card">
<div class="card-header"><strong>NginX Configuration Snippet</strong></div>
<div class="card-body p-0">
<textarea id="nginx-preview" class="form-control font-monospace border-0" rows="25" readonly style="font-size:0.8rem;resize:none"><?php echo htmlspecialchars($nginx); ?></textarea>
</div>
<div class="card-footer">
<button type="button" class="btn btn-outline-secondary" id="nginx-download">
<span class="icon-download"></span> Download NginX Config
</button>
</div>
</div>
</div>
<!-- Current File Tab -->
<div class="tab-pane fade" id="tab-current" role="tabpanel">
<div class="card">
<div class="card-header"><strong>Current .htaccess on Disk</strong></div>
<div class="card-body p-0">
<textarea class="form-control font-monospace border-0" rows="25" readonly style="font-size:0.8rem;resize:none"><?php echo htmlspecialchars($current); ?></textarea>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var saveBtn = document.getElementById('htaccess-save');
var preview = document.getElementById('htaccess-preview');
var lineCount = document.getElementById('htaccess-line-count');
// Toggle sub-option visibility
document.getElementById('htopt-hsts_enabled').addEventListener('change', function() {
document.getElementById('hsts-options').classList.toggle('d-none', !this.checked);
});
document.getElementById('htopt-csp_enabled').addEventListener('change', function() {
document.getElementById('csp-options').classList.toggle('d-none', !this.checked);
});
document.getElementById('htopt-permissions_policy').addEventListener('change', function() {
document.getElementById('perms-options').classList.toggle('d-none', !this.checked);
});
document.getElementById('htopt-enable_expires') && document.getElementById('htopt-enable_expires').addEventListener('change', function() {
document.getElementById('expires-options').classList.toggle('d-none', !this.checked);
});
// Regenerate preview on any option change
document.querySelectorAll('.htaccess-opt').forEach(function(el) {
el.addEventListener('change', regeneratePreview);
el.addEventListener('input', regeneratePreview);
});
function collectOptions() {
var opts = {};
document.querySelectorAll('.htaccess-opt').forEach(function(el) {
if (el.type === 'checkbox') {
opts[el.name] = el.checked ? 1 : 0;
} else {
opts[el.name] = el.value;
}
});
return opts;
}
var debounceTimer;
function regeneratePreview() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(function() {
var fd = new FormData();
var opts = collectOptions();
for (var k in opts) fd.append(k, opts[k]);
fd.append('<?php echo $token; ?>', '1');
fetch('<?php echo $genUrl; ?>', {
method: 'POST', body: fd,
headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.htaccess) {
preview.value = d.htaccess;
lineCount.textContent = d.htaccess.split('\n').length + ' lines';
}
if (d.nginx) {
document.getElementById('nginx-preview').value = d.nginx;
}
});
}, 300);
}
// Save to disk
saveBtn.addEventListener('click', function() {
if (!confirm('This will overwrite your current .htaccess file. A backup will be created at .htaccess.mokowaas.bak. Continue?')) return;
var btn = this;
btn.disabled = true;
var fd = new FormData();
fd.append('content', preview.value);
var opts = collectOptions();
for (var k in opts) fd.append('opt_' + k, opts[k]);
fd.append('<?php echo $token; ?>', '1');
fetch(btn.dataset.url, {
method: 'POST', body: fd,
headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) Joomla.renderMessages({message: [d.message]});
else Joomla.renderMessages({error: [d.message]});
})
.catch(function() { Joomla.renderMessages({error: ['Network error']}); })
.finally(function() { btn.disabled = false; });
});
// Download buttons
function downloadText(content, filename) {
var blob = new Blob([content], {type: 'text/plain'});
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}
document.getElementById('htaccess-download').addEventListener('click', function() {
downloadText(preview.value, '.htaccess');
});
document.getElementById('nginx-download').addEventListener('click', function() {
downloadText(document.getElementById('nginx-preview').value, 'mokowaas-nginx.conf');
});
});
</script>
@@ -1,184 +0,0 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$requests = $this->requests;
$policies = $this->policies;
$summary = $this->summary;
$token = Session::getFormToken();
$statusBadge = [
'pending' => 'bg-warning text-dark',
'processing' => 'bg-info',
'completed' => 'bg-success',
'denied' => 'bg-secondary',
];
$typeBadge = [
'export' => 'bg-primary',
'delete' => 'bg-danger',
'anonymize' => 'bg-warning text-dark',
];
?>
<div id="mokowaas-privacy">
<!-- Summary cards -->
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card text-center p-3">
<span class="fw-bold fs-3 <?php echo $summary->pending_requests > 0 ? 'text-warning' : 'text-success'; ?>"><?php echo $summary->pending_requests; ?></span>
<small class="text-muted">Pending Requests</small>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card text-center p-3">
<span class="fw-bold fs-3"><?php echo $summary->total_requests; ?></span>
<small class="text-muted">Total Requests</small>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card text-center p-3">
<span class="fw-bold fs-3"><?php echo $summary->consent_entries; ?></span>
<small class="text-muted">Consent Entries</small>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card text-center p-3">
<span class="fw-bold fs-3"><?php echo $summary->policies_active; ?></span>
<small class="text-muted">Active Policies</small>
</div>
</div>
</div>
<div class="row">
<!-- Data Requests -->
<div class="col-12 col-xl-8">
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><span class="icon-user-shield"></span> Data Subject Requests</strong>
<form method="get" class="d-inline">
<input type="hidden" name="option" value="com_mokowaas">
<input type="hidden" name="view" value="privacy">
<select name="filter_status" class="form-select form-select-sm" style="width:auto" onchange="this.form.submit()">
<option value="">All</option>
<?php foreach (['pending','processing','completed','denied'] as $s): ?>
<option value="<?php echo $s; ?>" <?php echo Factory::getApplication()->getInput()->getString('filter_status') === $s ? 'selected' : ''; ?>><?php echo ucfirst($s); ?></option>
<?php endforeach; ?>
</select>
</form>
</div>
<?php if (empty($requests)): ?>
<div class="card-body text-center text-muted py-4">No data requests found.</div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-striped table-hover mb-0">
<thead><tr><th>#</th><th>User</th><th>Type</th><th>Status</th><th>Created</th><th>Processed</th><th>Actions</th></tr></thead>
<tbody>
<?php foreach ($requests as $r): ?>
<tr>
<td><?php echo $r->id; ?></td>
<td><?php echo $this->escape($r->user_name ?? ''); ?><br><small class="text-muted"><?php echo $this->escape($r->user_email ?? ''); ?></small></td>
<td><span class="badge <?php echo $typeBadge[$r->type] ?? 'bg-secondary'; ?>"><?php echo ucfirst($r->type); ?></span></td>
<td><span class="badge <?php echo $statusBadge[$r->status] ?? 'bg-secondary'; ?>"><?php echo ucfirst($r->status); ?></span></td>
<td class="text-nowrap small"><?php echo HTMLHelper::_('date', $r->created, 'M d, Y H:i'); ?></td>
<td class="text-nowrap small"><?php echo $r->processed ? HTMLHelper::_('date', $r->processed, 'M d, Y H:i') : '—'; ?></td>
<td>
<?php if ($r->status === 'pending'): ?>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-success btn-privacy-action" data-id="<?php echo $r->id; ?>" data-action="approve"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.processDataRequest&format=json'); ?>"
data-token="<?php echo $token; ?>">Approve</button>
<button type="button" class="btn btn-outline-danger btn-privacy-action" data-id="<?php echo $r->id; ?>" data-action="deny"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.processDataRequest&format=json'); ?>"
data-token="<?php echo $token; ?>">Deny</button>
</div>
<?php elseif ($r->status === 'completed' && $r->type === 'export'): ?>
<button type="button" class="btn btn-sm btn-outline-primary btn-export-download" data-user="<?php echo $r->user_id; ?>"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.exportUserData&format=json'); ?>"
data-token="<?php echo $token; ?>">
<span class="icon-download"></span> Download
</button>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
<!-- Retention Policies -->
<div class="col-12 col-xl-4">
<div class="card mb-4">
<div class="card-header"><strong><span class="icon-clock"></span> Retention Policies</strong></div>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr><th>Type</th><th>Days</th><th>Action</th><th>Active</th></tr></thead>
<tbody>
<?php foreach ($policies as $p): ?>
<tr>
<td class="small"><?php echo $this->escape($p->content_type); ?></td>
<td><?php echo $p->retention_days; ?></td>
<td><span class="badge bg-secondary"><?php echo $p->action; ?></span></td>
<td><?php echo (int) $p->enabled ? '<span class="text-success">Yes</span>' : '<span class="text-muted">No</span>'; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Process request buttons
document.querySelectorAll('.btn-privacy-action').forEach(function(btn) {
btn.addEventListener('click', function() {
var el = this;
var action = el.dataset.action;
if (!confirm(action === 'approve' ? 'Approve and process this data request?' : 'Deny this request?')) return;
el.disabled = true;
var fd = new FormData();
fd.append('request_id', el.dataset.id);
fd.append('action', action);
fd.append(el.dataset.token, '1');
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) { Joomla.renderMessages({message:[d.message]}); location.reload(); }
else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; }
});
});
});
// Export download
document.querySelectorAll('.btn-export-download').forEach(function(btn) {
btn.addEventListener('click', function() {
var el = this;
var fd = new FormData();
fd.append('user_id', el.dataset.user);
fd.append(el.dataset.token, '1');
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success && d.data) {
var blob = new Blob([JSON.stringify(d.data, null, 2)], {type:'application/json'});
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'user-data-export-' + el.dataset.user + '.json';
a.click();
} else {
Joomla.renderMessages({error:[d.message || 'Export failed']});
}
});
});
});
});
</script>
@@ -1,198 +0,0 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$t = $this->ticket;
$canned = $this->cannedResponses;
$token = Session::getFormToken();
$statusBadge = [
'open' => 'bg-primary', 'in_progress' => 'bg-info',
'waiting' => 'bg-warning text-dark', 'resolved' => 'bg-success', 'closed' => 'bg-secondary',
];
$priorityBadge = [
'low' => 'bg-secondary', 'normal' => 'bg-primary', 'high' => 'bg-warning text-dark', 'urgent' => 'bg-danger',
];
?>
<div id="mokowaas-ticket" class="row">
<!-- Left: conversation thread -->
<div class="col-12 col-xl-8">
<!-- Original ticket -->
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<strong><?php echo $this->escape($t->created_by_name); ?></strong>
<small class="text-muted ms-2"><?php echo HTMLHelper::_('date', $t->created, 'M d, Y H:i'); ?></small>
</div>
<span class="badge bg-dark">Original</span>
</div>
<div class="card-body"><?php echo nl2br($this->escape($t->body)); ?></div>
</div>
<!-- Replies -->
<?php foreach ($t->replies as $reply): ?>
<div class="card mb-3 <?php echo $reply->is_internal ? 'border-warning' : ''; ?>">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<strong><?php echo $this->escape($reply->user_name ?? 'System'); ?></strong>
<small class="text-muted ms-2"><?php echo HTMLHelper::_('date', $reply->created, 'M d, Y H:i'); ?></small>
</div>
<?php if ($reply->is_internal): ?>
<span class="badge bg-warning text-dark">Internal Note</span>
<?php endif; ?>
</div>
<div class="card-body"><?php echo nl2br($this->escape($reply->body)); ?></div>
</div>
<?php endforeach; ?>
<!-- Reply form -->
<div class="card mb-3">
<div class="card-header"><strong>Reply</strong></div>
<div class="card-body">
<?php if (!empty($canned)): ?>
<div class="mb-2">
<select class="form-select form-select-sm" id="canned-select">
<option value="">Insert canned response...</option>
<?php foreach ($canned as $c): ?>
<option value="<?php echo $this->escape($c->body); ?>"><?php echo $this->escape($c->title); ?></option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
<textarea id="reply-body" class="form-control mb-2" rows="5" placeholder="Type your reply..."></textarea>
<div class="d-flex gap-2">
<button type="button" class="btn btn-primary" id="btn-reply"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.addTicketReply&format=json'); ?>"
data-ticket="<?php echo $t->id; ?>" data-token="<?php echo $token; ?>">
<span class="icon-reply"></span> Send Reply
</button>
<button type="button" class="btn btn-outline-warning" id="btn-internal"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.addTicketReply&format=json'); ?>"
data-ticket="<?php echo $t->id; ?>" data-token="<?php echo $token; ?>" data-internal="1">
<span class="icon-eye-slash"></span> Internal Note
</button>
</div>
</div>
</div>
</div>
<!-- Right: ticket metadata -->
<div class="col-12 col-xl-4">
<div class="card mb-3">
<div class="card-header"><strong>Details</strong></div>
<div class="card-body">
<table class="table table-sm mb-0">
<tr><td class="text-muted">Status</td><td><span class="badge <?php echo $statusBadge[$t->status] ?? ''; ?>"><?php echo ucwords(str_replace('_', ' ', $t->status)); ?></span></td></tr>
<tr><td class="text-muted">Priority</td><td><span class="badge <?php echo $priorityBadge[$t->priority] ?? ''; ?>"><?php echo ucfirst($t->priority); ?></span></td></tr>
<tr><td class="text-muted">Category</td><td><?php echo $this->escape($t->category_title ?? '—'); ?></td></tr>
<tr><td class="text-muted">Created By</td><td><?php echo $this->escape($t->created_by_name); ?><br><small><?php echo $this->escape($t->created_by_email ?? ''); ?></small></td></tr>
<tr><td class="text-muted">Assigned To</td><td><?php echo $this->escape($t->assigned_to_name ?? 'Unassigned'); ?></td></tr>
<tr><td class="text-muted">Created</td><td><?php echo HTMLHelper::_('date', $t->created, 'M d, Y H:i'); ?></td></tr>
<?php if ($t->resolved): ?><tr><td class="text-muted">Resolved</td><td><?php echo HTMLHelper::_('date', $t->resolved, 'M d, Y H:i'); ?></td></tr><?php endif; ?>
<?php if ($t->closed): ?><tr><td class="text-muted">Closed</td><td><?php echo HTMLHelper::_('date', $t->closed, 'M d, Y H:i'); ?></td></tr><?php endif; ?>
<tr><td class="text-muted">Replies</td><td><?php echo $t->reply_count; ?></td></tr>
</table>
</div>
</div>
<!-- SLA -->
<?php if ($t->sla_response_due || $t->sla_resolution_due): ?>
<div class="card mb-3">
<div class="card-header"><strong>SLA</strong></div>
<div class="card-body">
<?php if ($t->sla_response_due): ?>
<div class="mb-2">
<small class="text-muted">Response Due</small><br>
<?php
$responseOverdue = !$t->sla_responded && strtotime($t->sla_response_due) < time();
?>
<span class="<?php echo $t->sla_responded ? 'text-success' : ($responseOverdue ? 'text-danger fw-bold' : ''); ?>">
<?php echo $t->sla_responded ? 'Responded' : HTMLHelper::_('date', $t->sla_response_due, 'M d H:i'); ?>
<?php echo $responseOverdue ? ' OVERDUE' : ''; ?>
</span>
</div>
<?php endif; ?>
<?php if ($t->sla_resolution_due): ?>
<div>
<small class="text-muted">Resolution Due</small><br>
<?php
$resolutionOverdue = !\in_array($t->status, ['resolved','closed']) && strtotime($t->sla_resolution_due) < time();
?>
<span class="<?php echo \in_array($t->status, ['resolved','closed']) ? 'text-success' : ($resolutionOverdue ? 'text-danger fw-bold' : ''); ?>">
<?php echo \in_array($t->status, ['resolved','closed']) ? 'Met' : HTMLHelper::_('date', $t->sla_resolution_due, 'M d H:i'); ?>
<?php echo $resolutionOverdue ? ' OVERDUE' : ''; ?>
</span>
</div>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<!-- Status actions -->
<div class="card mb-3">
<div class="card-header"><strong>Actions</strong></div>
<div class="card-body d-grid gap-2">
<?php foreach (['open' => 'Reopen', 'in_progress' => 'In Progress', 'waiting' => 'Waiting', 'resolved' => 'Resolve', 'closed' => 'Close'] as $s => $label): ?>
<?php if ($s !== $t->status): ?>
<button type="button" class="btn btn-sm btn-outline-<?php echo $s === 'closed' ? 'danger' : ($s === 'resolved' ? 'success' : 'secondary'); ?> btn-status"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.updateTicketStatus&format=json'); ?>"
data-ticket="<?php echo $t->id; ?>" data-status="<?php echo $s; ?>" data-token="<?php echo $token; ?>">
<?php echo $label; ?>
</button>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Canned response insert
var cannedSel = document.getElementById('canned-select');
if (cannedSel) {
cannedSel.addEventListener('change', function() {
if (this.value) { document.getElementById('reply-body').value = this.value; this.selectedIndex = 0; }
});
}
// Reply buttons
document.querySelectorAll('#btn-reply, #btn-internal').forEach(function(btn) {
btn.addEventListener('click', function() {
var body = document.getElementById('reply-body').value.trim();
if (!body) return;
var el = this;
el.disabled = true;
var fd = new FormData();
fd.append('ticket_id', el.dataset.ticket);
fd.append('body', body);
fd.append('is_internal', el.dataset.internal || '0');
fd.append(el.dataset.token, '1');
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){ if(d.success) location.reload(); else Joomla.renderMessages({error:[d.message]}); })
.finally(function(){ el.disabled = false; });
});
});
// Status buttons
document.querySelectorAll('.btn-status').forEach(function(btn) {
btn.addEventListener('click', function() {
var el = this;
el.disabled = true;
var fd = new FormData();
fd.append('ticket_id', el.dataset.ticket);
fd.append('status', el.dataset.status);
fd.append(el.dataset.token, '1');
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){ if(d.success) location.reload(); else Joomla.renderMessages({error:[d.message]}); })
.finally(function(){ el.disabled = false; });
});
});
});
</script>
@@ -1,291 +0,0 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$tickets = $this->tickets;
$categories = $this->categories;
$counts = $this->statusCounts;
$overdue = $this->overdue;
$atsAvailable = $this->atsAvailable;
$token = Session::getFormToken();
$statusBadge = [
'open' => 'bg-primary',
'in_progress' => 'bg-info',
'waiting' => 'bg-warning text-dark',
'resolved' => 'bg-success',
'closed' => 'bg-secondary',
];
$priorityBadge = [
'low' => 'bg-secondary',
'normal' => 'bg-primary',
'high' => 'bg-warning text-dark',
'urgent' => 'bg-danger',
];
?>
<div id="mokowaas-tickets">
<!-- Status summary cards -->
<div class="row g-3 mb-4">
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo $counts->open; ?></span><small class="text-muted">Open</small></div></div>
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo $counts->in_progress; ?></span><small class="text-muted">In Progress</small></div></div>
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo $counts->waiting; ?></span><small class="text-muted">Waiting</small></div></div>
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo $counts->resolved; ?></span><small class="text-muted">Resolved</small></div></div>
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo $counts->closed; ?></span><small class="text-muted">Closed</small></div></div>
<?php if (\count($overdue) > 0): ?>
<div class="col"><div class="card text-center p-2 border-danger"><span class="fw-bold fs-4 text-danger"><?php echo \count($overdue); ?></span><small class="text-danger">SLA Overdue</small></div></div>
<?php endif; ?>
</div>
<!-- New ticket + filters -->
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3">
<div class="d-flex gap-2">
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newTicketModal">
<span class="icon-plus"></span> New Ticket
</button>
<?php if ($atsAvailable): ?>
<button type="button" class="btn btn-outline-info" id="btn-import-ats"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.importAts&format=json'); ?>"
data-token="<?php echo $token; ?>"
data-tickets="<?php echo $atsAvailable->tickets; ?>"
data-posts="<?php echo $atsAvailable->posts; ?>">
<span class="icon-upload"></span> Import from Akeeba (<?php echo $atsAvailable->tickets; ?> tickets, <?php echo $atsAvailable->posts; ?> posts)
</button>
<?php endif; ?>
</div>
<form method="get" class="d-flex gap-2">
<input type="hidden" name="option" value="com_mokowaas">
<input type="hidden" name="view" value="tickets">
<select name="filter_status" class="form-select form-select-sm" style="width:auto" onchange="this.form.submit()">
<option value="">All Statuses</option>
<?php foreach (['open','in_progress','waiting','resolved','closed'] as $s): ?>
<option value="<?php echo $s; ?>" <?php echo Factory::getApplication()->getInput()->getString('filter_status') === $s ? 'selected' : ''; ?>><?php echo ucwords(str_replace('_', ' ', $s)); ?></option>
<?php endforeach; ?>
</select>
<select name="filter_priority" class="form-select form-select-sm" style="width:auto" onchange="this.form.submit()">
<option value="">All Priorities</option>
<?php foreach (['low','normal','high','urgent'] as $p): ?>
<option value="<?php echo $p; ?>" <?php echo Factory::getApplication()->getInput()->getString('filter_priority') === $p ? 'selected' : ''; ?>><?php echo ucfirst($p); ?></option>
<?php endforeach; ?>
</select>
</form>
</div>
<!-- Ticket table -->
<div class="card">
<div class="table-responsive">
<table class="table table-striped table-hover mb-0">
<thead>
<tr>
<th>#</th>
<th>Subject</th>
<th>Status</th>
<th>Priority</th>
<th>Category</th>
<th>Created By</th>
<th>Assigned To</th>
<th>Created</th>
<th>SLA</th>
</tr>
</thead>
<tbody>
<?php if (empty($tickets)): ?>
<tr><td colspan="9" class="text-center text-muted py-4">No tickets found.</td></tr>
<?php else: ?>
<?php foreach ($tickets as $t): ?>
<?php
$slaClass = '';
$now = time();
if ($t->sla_response_due && !$t->sla_responded && strtotime($t->sla_response_due) < $now) $slaClass = 'table-danger';
elseif ($t->sla_resolution_due && strtotime($t->sla_resolution_due) < $now && !\in_array($t->status, ['resolved','closed'])) $slaClass = 'table-danger';
elseif ($t->sla_response_due && !$t->sla_responded && strtotime($t->sla_response_due) < $now + 3600) $slaClass = 'table-warning';
?>
<tr class="<?php echo $slaClass; ?>">
<td><a href="<?php echo Route::_('index.php?option=com_mokowaas&view=ticket&id=' . $t->id); ?>"><?php echo $t->id; ?></a></td>
<td><a href="<?php echo Route::_('index.php?option=com_mokowaas&view=ticket&id=' . $t->id); ?>"><?php echo $this->escape(mb_substr($t->subject, 0, 60)); ?></a></td>
<td><span class="badge <?php echo $statusBadge[$t->status] ?? 'bg-secondary'; ?>"><?php echo ucwords(str_replace('_', ' ', $t->status)); ?></span></td>
<td><span class="badge <?php echo $priorityBadge[$t->priority] ?? 'bg-secondary'; ?>"><?php echo ucfirst($t->priority); ?></span></td>
<td><?php echo $this->escape($t->category_title ?? '—'); ?></td>
<td><?php echo $this->escape($t->created_by_name ?? ''); ?></td>
<td><?php echo $t->assigned_to_name ? $this->escape($t->assigned_to_name) : '<em>Unassigned</em>'; ?></td>
<td class="small"><?php echo HTMLHelper::_('date', $t->created, 'M d H:i'); ?></td>
<td class="small">
<?php if ($t->sla_response_due && !$t->sla_responded): ?>
<span title="Response due"><?php echo HTMLHelper::_('date', $t->sla_response_due, 'M d H:i'); ?></span>
<?php elseif ($t->sla_resolution_due): ?>
<span title="Resolution due"><?php echo HTMLHelper::_('date', $t->sla_resolution_due, 'M d H:i'); ?></span>
<?php else: ?>—<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<!-- New Ticket Modal -->
<div class="modal fade" id="newTicketModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header"><h5 class="modal-title">New Ticket</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body">
<!-- KB Search step -->
<div id="modal-kb-step">
<label class="form-label fw-bold">What's the issue?</label>
<div class="input-group mb-3">
<input type="text" id="modal-kb-search" class="form-control" placeholder="Describe your issue to search for existing answers...">
<button type="button" class="btn btn-outline-primary" id="modal-kb-btn"><span class="icon-search"></span></button>
</div>
<div id="modal-kb-results" class="list-group mb-3 d-none"></div>
<button type="button" class="btn btn-primary" id="modal-show-form">
<span class="icon-plus"></span> Create Ticket
</button>
</div>
<!-- Ticket form step (hidden initially) -->
<form id="modal-ticket-form" class="d-none" method="post" action="<?php echo Route::_('index.php?option=com_mokowaas&task=display.createTicket&format=json'); ?>">
<div class="mb-3">
<label class="form-label">Subject</label>
<input type="text" name="subject" id="modal-subject" class="form-control" required>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">Category</label>
<select name="category_id" class="form-select">
<option value="">— Select —</option>
<?php foreach ($categories as $cat): ?>
<option value="<?php echo $cat->id; ?>"><?php echo $this->escape($cat->title); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Priority</label>
<select name="priority" class="form-select">
<option value="normal">Normal</option>
<option value="low">Low</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea name="body" class="form-control" rows="6" required></textarea>
</div>
<input type="hidden" name="<?php echo $token; ?>" value="1">
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"><span class="icon-plus"></span> Create Ticket</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
// Modal KB search
var modalSearch = document.getElementById('modal-kb-search');
var modalSearchBtn = document.getElementById('modal-kb-btn');
var modalResults = document.getElementById('modal-kb-results');
var modalShowForm = document.getElementById('modal-show-form');
var modalKbStep = document.getElementById('modal-kb-step');
var modalForm = document.getElementById('modal-ticket-form');
var modalSubject = document.getElementById('modal-subject');
function modalDoSearch() {
var q = modalSearch.value.trim();
if (q.length < 3) return;
fetch('<?php echo Route::_('index.php?option=com_mokowaas&task=display.searchKb&format=json'); ?>&q=' + encodeURIComponent(q), {
headers: {'X-Requested-With': 'XMLHttpRequest'}
}).then(function(r){return r.json()}).then(function(d) {
modalResults.textContent = '';
if (d.results && d.results.length > 0) {
d.results.forEach(function(item) {
var a = document.createElement('a');
a.href = item.url;
a.target = '_blank';
a.className = 'list-group-item list-group-item-action';
var strong = document.createElement('strong');
strong.textContent = item.title;
a.appendChild(strong);
if (item.description) {
a.appendChild(document.createElement('br'));
var small = document.createElement('small');
small.className = 'text-muted';
small.textContent = item.description;
a.appendChild(small);
}
modalResults.appendChild(a);
});
modalResults.classList.remove('d-none');
} else {
modalResults.classList.add('d-none');
}
});
}
if (modalSearchBtn) modalSearchBtn.addEventListener('click', modalDoSearch);
if (modalSearch) modalSearch.addEventListener('keydown', function(e) { if (e.key === 'Enter') { e.preventDefault(); modalDoSearch(); } });
// Show ticket form
if (modalShowForm) {
modalShowForm.addEventListener('click', function() {
modalKbStep.classList.add('d-none');
modalForm.classList.remove('d-none');
if (modalSearch.value && !modalSubject.value) modalSubject.value = modalSearch.value;
modalSubject.focus();
});
}
// Submit ticket from modal
if (modalForm) {
modalForm.addEventListener('submit', function(e) {
e.preventDefault();
var form = this;
var fd = new FormData(form);
fetch(form.action, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) { location.href = 'index.php?option=com_mokowaas&view=ticket&id=' + d.id; }
else { Joomla.renderMessages({error:[d.message]}); }
});
});
}
// Reset modal on close
document.getElementById('newTicketModal').addEventListener('hidden.bs.modal', function() {
modalKbStep.classList.remove('d-none');
modalForm.classList.add('d-none');
modalResults.classList.add('d-none');
modalSearch.value = '';
modalForm.reset();
});
// ATS Import
var atsBtn = document.getElementById('btn-import-ats');
if (atsBtn) {
atsBtn.addEventListener('click', function() {
var el = this;
if (!confirm('Import ' + el.dataset.tickets + ' tickets and ' + el.dataset.posts + ' posts from Akeeba Ticket System? Duplicates will be skipped.')) return;
el.disabled = true;
el.textContent = ' Importing...';
var fd = new FormData();
fd.append(el.dataset.token, '1');
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) { Joomla.renderMessages({message:[d.message]}); location.reload(); }
else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; el.textContent = 'Import Failed - Retry'; }
})
.catch(function(){ Joomla.renderMessages({error:['Network error']}); el.disabled = false; });
});
}
</script>
@@ -1,212 +0,0 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$logs = $this->logs;
$ruleCounts = $this->ruleCounts;
$topIps = $this->topIps;
$ruleNames = $this->ruleNames;
$total = $this->total;
$filters = $this->filters;
$token = Session::getFormToken();
$input = Factory::getApplication()->getInput();
$page = max(1, $input->getInt('page', 1));
$totalPages = max(1, ceil($total / 50));
$ruleBadge = [
'sqli' => 'bg-danger', 'xss' => 'bg-danger', 'mua' => 'bg-warning text-dark',
'rfi' => 'bg-danger', 'dfi' => 'bg-danger', 'blocked_file' => 'bg-info',
'blocked_php' => 'bg-info', 'tmpl_switch' => 'bg-secondary',
'ip_blocklist' => 'bg-dark', 'admin_secret' => 'bg-dark',
];
?>
<div id="mokowaas-waflog">
<!-- Rule distribution cards -->
<div class="d-flex flex-wrap gap-2 mb-4">
<?php foreach ($ruleCounts as $rc): ?>
<div class="card p-2 text-center" style="min-width:100px">
<span class="badge <?php echo $ruleBadge[$rc->rule] ?? 'bg-secondary'; ?> mb-1"><?php echo htmlspecialchars($rc->rule); ?></span>
<span class="fw-bold"><?php echo number_format($rc->cnt); ?></span>
</div>
<?php endforeach; ?>
<div class="card p-2 text-center" style="min-width:100px">
<span class="badge bg-primary mb-1">Total</span>
<span class="fw-bold"><?php echo number_format($total); ?></span>
</div>
</div>
<div class="row">
<!-- Main: Log table -->
<div class="col-12 col-xl-9">
<!-- Filters -->
<form method="get" class="card mb-3">
<div class="card-body">
<input type="hidden" name="option" value="com_mokowaas">
<input type="hidden" name="view" value="waflog">
<div class="row g-2">
<div class="col-md-2">
<select name="filter_rule" class="form-select form-select-sm">
<option value="">All Rules</option>
<?php foreach ($ruleNames as $r): ?>
<option value="<?php echo htmlspecialchars($r); ?>" <?php echo $filters['rule'] === $r ? 'selected' : ''; ?>><?php echo htmlspecialchars($r); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-2">
<input type="text" name="filter_ip" class="form-control form-control-sm" placeholder="IP address" value="<?php echo htmlspecialchars($filters['ip']); ?>">
</div>
<div class="col-md-2">
<input type="text" name="filter_search" class="form-control form-control-sm" placeholder="Search URI/detail" value="<?php echo htmlspecialchars($filters['search']); ?>">
</div>
<div class="col-md-2">
<input type="date" name="filter_date_from" class="form-control form-control-sm" value="<?php echo htmlspecialchars($filters['date_from']); ?>">
</div>
<div class="col-md-2">
<input type="date" name="filter_date_to" class="form-control form-control-sm" value="<?php echo htmlspecialchars($filters['date_to']); ?>">
</div>
<div class="col-md-2 d-flex gap-1">
<button type="submit" class="btn btn-sm btn-primary"><span class="icon-search"></span> Filter</button>
<a href="<?php echo Route::_('index.php?option=com_mokowaas&view=waflog'); ?>" class="btn btn-sm btn-outline-secondary">Reset</a>
</div>
</div>
</div>
</form>
<!-- Log table -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><?php echo number_format($total); ?> blocked requests</strong>
<button type="button" class="btn btn-sm btn-outline-danger" id="btn-purge"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.purgeWafLog&format=json'); ?>"
data-token="<?php echo $token; ?>">
<span class="icon-trash"></span> Purge Old Logs
</button>
</div>
<div class="table-responsive">
<table class="table table-striped table-hover table-sm mb-0">
<thead>
<tr><th>Time</th><th>IP</th><th>Rule</th><th>URI</th><th>Detail</th><th>User Agent</th><th></th></tr>
</thead>
<tbody>
<?php if (empty($logs)): ?>
<tr><td colspan="7" class="text-center text-muted py-4">No blocked requests found.</td></tr>
<?php else: ?>
<?php foreach ($logs as $log): ?>
<tr>
<td class="text-nowrap small"><?php echo HTMLHelper::_('date', $log->created, 'M d H:i:s'); ?></td>
<td><code><?php echo htmlspecialchars($log->ip); ?></code></td>
<td><span class="badge <?php echo $ruleBadge[$log->rule] ?? 'bg-secondary'; ?>"><?php echo htmlspecialchars($log->rule); ?></span></td>
<td class="small" style="max-width:250px;overflow:hidden;text-overflow:ellipsis" title="<?php echo htmlspecialchars($log->uri); ?>"><?php echo htmlspecialchars(mb_substr($log->uri, 0, 60)); ?></td>
<td class="small" style="max-width:200px;overflow:hidden;text-overflow:ellipsis"><?php echo htmlspecialchars(mb_substr($log->detail, 0, 50)); ?></td>
<td class="small" style="max-width:200px;overflow:hidden;text-overflow:ellipsis"><?php echo htmlspecialchars(mb_substr($log->user_agent, 0, 40)); ?></td>
<td>
<button type="button" class="btn btn-sm btn-outline-danger btn-ban-ip" data-ip="<?php echo htmlspecialchars($log->ip); ?>"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.banIpFromLog&format=json'); ?>"
data-token="<?php echo $token; ?>" title="Ban this IP">
<span class="icon-ban"></span>
</button>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<?php if ($totalPages > 1): ?>
<div class="card-footer d-flex justify-content-between align-items-center">
<small class="text-muted">Page <?php echo $page; ?> of <?php echo $totalPages; ?></small>
<nav>
<ul class="pagination pagination-sm mb-0">
<?php if ($page > 1): ?>
<li class="page-item"><a class="page-link" href="<?php echo Route::_('index.php?option=com_mokowaas&view=waflog&page=' . ($page - 1)); ?>">Prev</a></li>
<?php endif; ?>
<?php if ($page < $totalPages): ?>
<li class="page-item"><a class="page-link" href="<?php echo Route::_('index.php?option=com_mokowaas&view=waflog&page=' . ($page + 1)); ?>">Next</a></li>
<?php endif; ?>
</ul>
</nav>
</div>
<?php endif; ?>
</div>
</div>
<!-- Sidebar: Top IPs -->
<div class="col-12 col-xl-3">
<div class="card">
<div class="card-header"><strong>Top Blocked IPs</strong></div>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr><th>IP</th><th>Blocks</th><th>Last</th><th></th></tr></thead>
<tbody>
<?php foreach ($topIps as $tip): ?>
<tr>
<td><code class="small"><?php echo htmlspecialchars($tip->ip); ?></code></td>
<td class="fw-bold"><?php echo $tip->cnt; ?></td>
<td class="small text-nowrap"><?php echo HTMLHelper::_('date', $tip->last_seen, 'M d'); ?></td>
<td>
<button type="button" class="btn btn-sm btn-outline-danger btn-ban-ip" data-ip="<?php echo htmlspecialchars($tip->ip); ?>"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.banIpFromLog&format=json'); ?>"
data-token="<?php echo $token; ?>" title="Ban">
<span class="icon-ban"></span>
</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var token = '<?php echo $token; ?>';
// Ban IP buttons
document.querySelectorAll('.btn-ban-ip').forEach(function(btn) {
btn.addEventListener('click', function() {
var el = this;
var ip = el.dataset.ip;
if (!confirm('Add ' + ip + ' to the firewall IP blocklist?')) return;
el.disabled = true;
var fd = new FormData();
fd.append('ip', ip);
fd.append(el.dataset.token, '1');
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) { Joomla.renderMessages({message:[d.message]}); el.textContent = 'Banned'; }
else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; }
});
});
});
// Purge button
var purgeBtn = document.getElementById('btn-purge');
if (purgeBtn) {
purgeBtn.addEventListener('click', function() {
var days = prompt('Delete WAF logs older than how many days?', '30');
if (!days || isNaN(days)) return;
this.disabled = true;
var fd = new FormData();
fd.append('days', days);
fd.append(this.dataset.token, '1');
fetch(this.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) { Joomla.renderMessages({message:[d.message]}); location.reload(); }
else { Joomla.renderMessages({error:[d.message]}); }
})
.finally(function(){ purgeBtn.disabled = false; });
});
}
});
</script>
@@ -109,26 +109,4 @@ document.addEventListener('DOMContentLoaded', function () {
});
});
}
// Akeeba import buttons
['btn-import-admintools', 'btn-import-ats-dash'].forEach(function(id) {
var btn = document.getElementById(id);
if (!btn) return;
btn.addEventListener('click', function() {
var el = this;
if (!confirm('Import Akeeba data into MokoWaaS? Akeeba extensions will be disabled after import.')) return;
el.disabled = true;
var origText = el.textContent;
el.textContent = ' Importing...';
var fd = new FormData();
fd.append(el.dataset.token, '1');
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) { Joomla.renderMessages({message:[d.message]}); setTimeout(function(){location.reload()}, 2000); }
else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; el.textContent = origText; }
})
.catch(function(){ Joomla.renderMessages({error:['Network error']}); el.disabled = false; el.textContent = origText; });
});
});
});
+1 -32
View File
@@ -20,52 +20,21 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.33.01-dev</version>
<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>
<submenu>
<menu link="option=com_mokowaas" img="class:cogs">COM_MOKOWAAS_MENU_DASHBOARD</menu>
<menu link="option=com_mokowaas&amp;view=extensions" img="class:puzzle-piece">COM_MOKOWAAS_MENU_EXTENSIONS</menu>
<menu link="option=com_mokowaas&amp;view=tickets" img="class:headphones">COM_MOKOWAAS_MENU_TICKETS</menu>
<menu link="option=com_mokowaas&amp;view=htaccess" img="class:file-code">COM_MOKOWAAS_MENU_HTACCESS</menu>
<menu link="option=com_mokowaas&amp;view=privacy" img="class:lock">COM_MOKOWAAS_MENU_PRIVACY</menu>
<menu link="option=com_mokowaas&amp;view=waflog" img="class:shield-alt">COM_MOKOWAAS_MENU_WAFLOG</menu>
<menu link="option=com_mokowaas&amp;view=database" img="class:database">COM_MOKOWAAS_MENU_DATABASE</menu>
<menu link="option=com_mokowaas&amp;view=cleanup" img="class:trash">COM_MOKOWAAS_MENU_CLEANUP</menu>
<menu link="option=com_plugins&amp;filter[folder]=system&amp;filter[search]=mokowaas" img="class:power-off">COM_MOKOWAAS_MENU_PLUGINS</menu>
<menu link="option=com_installer&amp;view=update" img="class:refresh">COM_MOKOWAAS_MENU_UPDATES</menu>
<menu link="option=com_checkin" img="class:check-square">COM_MOKOWAAS_MENU_CHECKIN</menu>
<menu link="option=com_cache" img="class:bolt">COM_MOKOWAAS_MENU_CACHE</menu>
</submenu>
<files folder="admin">
<filename>access.xml</filename>
<filename>config.xml</filename>
<folder>language</folder>
<folder>services</folder>
<folder>sql</folder>
<folder>src</folder>
<folder>tmpl</folder>
</files>
<languages folder="admin/language">
<language tag="en-GB">en-GB/com_mokowaas.sys.ini</language>
</languages>
</administration>
<files folder="site">
<folder>language</folder>
<folder>services</folder>
<folder>src</folder>
<folder>tmpl</folder>
</files>
<install>
<sql><file driver="mysql" charset="utf8">admin/sql/install.mysql.sql</file></sql>
</install>
<api>
<files folder="api">
<folder>src</folder>
@@ -1,11 +0,0 @@
; MokoWaaS Customer Portal - Language Strings
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GPL-3.0-or-later
COM_MOKOWAAS_PORTAL_TITLE="Support Portal"
COM_MOKOWAAS_PORTAL_MY_TICKETS="My Support Tickets"
COM_MOKOWAAS_PORTAL_NEW_TICKET="New Ticket"
COM_MOKOWAAS_PORTAL_SUBMIT="Submit Ticket"
COM_MOKOWAAS_PORTAL_REPLY="Send Reply"
COM_MOKOWAAS_PORTAL_NO_TICKETS="You haven't submitted any support tickets yet."
COM_MOKOWAAS_PORTAL_LOGIN_REQUIRED="Please log in to access the support portal."
@@ -1,38 +0,0 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas.site
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Dispatcher\ComponentDispatcherFactoryInterface;
use Joomla\CMS\Extension\ComponentInterface;
use Joomla\CMS\Extension\Service\Provider\ComponentDispatcherFactory;
use Joomla\CMS\Extension\Service\Provider\MVCFactory;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
return new class implements ServiceProviderInterface
{
public function register(Container $container): void
{
$container->registerServiceProvider(new MVCFactory('\\Moko\\Component\\MokoWaaS'));
$container->registerServiceProvider(new ComponentDispatcherFactory('\\Moko\\Component\\MokoWaaS'));
$container->set(
ComponentInterface::class,
function (Container $container) {
$component = new \Joomla\CMS\Extension\MVCComponent(
$container->get(ComponentDispatcherFactoryInterface::class)
);
$component->setMVCFactory($container->get(MVCFactoryInterface::class));
return $component;
}
);
}
};
@@ -1,267 +0,0 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas.site
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Site\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
class DisplayController extends BaseController
{
protected $default_view = 'tickets';
public function display($cachable = false, $urlparams = [])
{
$user = Factory::getApplication()->getIdentity();
if ($user->guest)
{
Factory::getApplication()->enqueueMessage('Please log in to access the support portal.', 'warning');
Factory::getApplication()->redirect(Route::_(
'index.php?option=com_users&view=login&return=' . base64_encode('index.php?option=com_mokowaas&view=tickets'),
false
));
return;
}
return parent::display($cachable, $urlparams);
}
/**
* Submit a new ticket.
*/
public function submitTicket()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
$user = Factory::getApplication()->getIdentity();
if ($user->guest)
{
$this->jsonResponse(['success' => false, 'message' => 'Please log in.']);
return;
}
$input = Factory::getApplication()->getInput();
// Use admin TicketsModel
$model = $this->getModel('Tickets', 'Administrator');
$this->jsonResponse($model->createTicket([
'subject' => $input->getString('subject', ''),
'body' => $input->getRaw('body', ''),
'priority' => $input->getString('priority', 'normal'),
'category_id' => $input->getInt('category_id', 0),
]));
}
/**
* Submit a reply.
*/
public function submitReply()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
$user = Factory::getApplication()->getIdentity();
$input = Factory::getApplication()->getInput();
if ($user->guest)
{
$this->jsonResponse(['success' => false, 'message' => 'Please log in.']);
return;
}
$ticketId = $input->getInt('ticket_id', 0);
$model = $this->getModel('Tickets', 'Administrator');
$ticket = $model->getTicket($ticketId);
if (!$ticket)
{
$this->jsonResponse(['success' => false, 'message' => 'Ticket not found.']);
return;
}
// Customers can only reply to their own tickets; staff can reply to any
if ((int) $ticket->created_by !== $user->id && !$this->isStaff($user))
{
$this->jsonResponse(['success' => false, 'message' => 'Access denied.']);
return;
}
// Staff replies from frontend are not internal notes
$this->jsonResponse($model->addReply(
$ticketId,
$input->getRaw('body', ''),
false
));
}
/**
* Update ticket status (staff/manager only from frontend).
*/
public function updateStatus()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
$user = Factory::getApplication()->getIdentity();
if (!$this->isStaff($user))
{
$this->jsonResponse(['success' => false, 'message' => 'Access denied.']);
return;
}
$input = Factory::getApplication()->getInput();
$model = $this->getModel('Tickets', 'Administrator');
$this->jsonResponse($model->updateStatus(
$input->getInt('ticket_id', 0),
$input->getString('status', '')
));
}
/**
* Assign a ticket (manager only from frontend).
*/
public function assignTicket()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
$user = Factory::getApplication()->getIdentity();
if (!$user->authorise('mokowaas.tickets.assign', 'com_mokowaas'))
{
$this->jsonResponse(['success' => false, 'message' => 'Access denied.']);
return;
}
$input = Factory::getApplication()->getInput();
$ticketId = $input->getInt('ticket_id', 0);
$assignTo = $input->getInt('assigned_to', 0);
try
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__mokowaas_tickets'))
->set($db->quoteName('assigned_to') . ' = ' . ($assignTo ?: 'NULL'))
->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql()))
->where($db->quoteName('id') . ' = ' . $ticketId)
)->execute();
$this->jsonResponse(['success' => true, 'message' => 'Ticket assigned.']);
}
catch (\Throwable $e)
{
$this->jsonResponse(['success' => false, 'message' => $e->getMessage()]);
return;
}
}
/**
* Submit a data privacy request from frontend.
*/
public function submitDataRequest()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
$user = Factory::getApplication()->getIdentity();
if ($user->guest)
{
$this->jsonResponse(['success' => false, 'message' => 'Please log in.']);
return;
}
$type = Factory::getApplication()->getInput()->getString('type', '');
$model = new \Moko\Component\MokoWaaS\Administrator\Model\PrivacyModel();
$this->jsonResponse($model->createRequest($user->id, $type, 'Submitted via self-service portal'));
}
/**
* Check if user is support staff (can manage tickets beyond their own).
*/
private function isStaff($user): bool
{
if ($user->guest)
{
return false;
}
// Super admins always staff
if ($user->authorise('core.admin'))
{
return true;
}
// Anyone with mokowaas.tickets ACL on the component is staff
return $user->authorise('mokowaas.tickets', 'com_mokowaas');
}
/**
* Search KB articles via Smart Search (com_finder).
*/
public function searchKb()
{
$query = Factory::getApplication()->getInput()->getString('q', '');
if (strlen($query) < 3)
{
$this->jsonResponse(['results' => []]);
}
try
{
$db = Factory::getDbo();
$escaped = $db->quote('%' . $db->escape($query, true) . '%');
$results = $db->setQuery(
$db->getQuery(true)
->select([
$db->quoteName('l.link_id'),
$db->quoteName('l.title'),
$db->quoteName('l.url'),
$db->quoteName('l.description'),
])
->from($db->quoteName('#__finder_links', 'l'))
->where($db->quoteName('l.published') . ' = 1')
->where('(' . $db->quoteName('l.title') . ' LIKE ' . $escaped
. ' OR ' . $db->quoteName('l.description') . ' LIKE ' . $escaped . ')')
->order($db->quoteName('l.title') . ' ASC')
->setLimit(8)
)->loadObjectList() ?: [];
foreach ($results as $r)
{
$r->description = mb_substr(strip_tags($r->description ?? ''), 0, 150);
}
$this->jsonResponse(['results' => $results]);
}
catch (\Throwable $e)
{
$this->jsonResponse(['results' => []]);
}
}
private function jsonResponse(array $data): void
{
$app = Factory::getApplication();
$app->setHeader('Content-Type', 'application/json');
echo json_encode($data);
$app->close();
}
}
@@ -1,68 +0,0 @@
<?php
namespace Moko\Component\MokoWaaS\Site\View\Privacy;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Router\Route;
class HtmlView extends BaseHtmlView
{
protected $requests = [];
protected $consent = [];
public function display($tpl = null)
{
$user = Factory::getApplication()->getIdentity();
if ($user->guest)
{
Factory::getApplication()->redirect(Route::_(
'index.php?option=com_users&view=login&return=' . base64_encode('index.php?option=com_mokowaas&view=privacy'),
false
));
return;
}
$db = Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
// Get user's data requests
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_data_requests'))
->where($db->quoteName('user_id') . ' = ' . (int) $user->id)
->order($db->quoteName('created') . ' DESC');
try
{
$db->setQuery($query);
$this->requests = $db->loadObjectList() ?: [];
}
catch (\Throwable $e)
{
$this->requests = [];
}
// Get consent history
try
{
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_consent_log'))
->where($db->quoteName('user_id') . ' = ' . (int) $user->id)
->order($db->quoteName('created') . ' DESC')
->setLimit(20)
);
$this->consent = $db->loadObjectList() ?: [];
}
catch (\Throwable $e)
{
$this->consent = [];
}
parent::display($tpl);
}
}
@@ -1,84 +0,0 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas.site
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Site\View\Ticket;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Router\Route;
class HtmlView extends BaseHtmlView
{
protected $ticket;
protected $isStaff = false;
protected $canAssign = false;
public function display($tpl = null)
{
$db = Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
$user = Factory::getApplication()->getIdentity();
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$this->isStaff = $user->authorise('core.admin') || $user->authorise('mokowaas.tickets', 'com_mokowaas');
$this->canAssign = $user->authorise('core.admin') || $user->authorise('mokowaas.tickets.assign', 'com_mokowaas');
// Get ticket — staff see any, customers see only their own
$query = $db->getQuery(true)
->select([
$db->quoteName('t') . '.*',
$db->quoteName('c.title', 'category_title'),
$db->quoteName('u.name', 'created_by_name'),
$db->quoteName('u.email', 'created_by_email'),
$db->quoteName('a.name', 'assigned_to_name'),
])
->from($db->quoteName('#__mokowaas_tickets', 't'))
->leftJoin($db->quoteName('#__mokowaas_ticket_categories', 'c') . ' ON c.id = t.category_id')
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to')
->where($db->quoteName('t.id') . ' = ' . $id);
if (!$this->isStaff)
{
$query->where($db->quoteName('t.created_by') . ' = ' . (int) $user->id);
}
$db->setQuery($query);
$this->ticket = $db->loadObject();
if (!$this->ticket)
{
Factory::getApplication()->enqueueMessage('Ticket not found.', 'error');
Factory::getApplication()->redirect(Route::_('index.php?option=com_mokowaas&view=tickets', false));
return;
}
// Load replies — staff see internal notes, customers don't
$query = $db->getQuery(true)
->select([
$db->quoteName('r') . '.*',
$db->quoteName('u.name', 'user_name'),
])
->from($db->quoteName('#__mokowaas_ticket_replies', 'r'))
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id')
->where($db->quoteName('r.ticket_id') . ' = ' . $id);
if (!$this->isStaff)
{
$query->where($db->quoteName('r.is_internal') . ' = 0');
}
$query->order($db->quoteName('r.created') . ' ASC');
$db->setQuery($query);
$this->ticket->replies = $db->loadObjectList() ?: [];
parent::display($tpl);
}
}
@@ -1,75 +0,0 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas.site
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Site\View\Tickets;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
class HtmlView extends BaseHtmlView
{
protected $tickets = [];
protected $categories = [];
protected $isStaff = false;
public function display($tpl = null)
{
$db = Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
$user = Factory::getApplication()->getIdentity();
$this->isStaff = $user->authorise('core.admin')
|| $user->authorise('mokowaas.tickets', 'com_mokowaas');
// Staff see all tickets, customers see their own
$query = $db->getQuery(true)
->select([
$db->quoteName('t.id'),
$db->quoteName('t.subject'),
$db->quoteName('t.status'),
$db->quoteName('t.priority'),
$db->quoteName('t.created'),
$db->quoteName('t.assigned_to'),
$db->quoteName('c.title', 'category_title'),
$db->quoteName('u.name', 'created_by_name'),
$db->quoteName('a.name', 'assigned_to_name'),
])
->from($db->quoteName('#__mokowaas_tickets', 't'))
->leftJoin($db->quoteName('#__mokowaas_ticket_categories', 'c') . ' ON c.id = t.category_id')
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to');
if (!$this->isStaff)
{
$query->where($db->quoteName('t.created_by') . ' = ' . (int) $user->id);
}
$filterStatus = Factory::getApplication()->getInput()->getString('filter_status', '');
if ($filterStatus)
{
$query->where($db->quoteName('t.status') . ' = ' . $db->quote($filterStatus));
}
$query->order($db->quoteName('t.created') . ' DESC')->setLimit(50);
$db->setQuery($query);
$this->tickets = $db->loadObjectList() ?: [];
// Categories for new ticket form
$query = $db->getQuery(true)
->select([$db->quoteName('id'), $db->quoteName('title')])
->from($db->quoteName('#__mokowaas_ticket_categories'))
->where($db->quoteName('published') . ' = 1')
->order($db->quoteName('ordering') . ' ASC');
$db->setQuery($query);
$this->categories = $db->loadObjectList() ?: [];
parent::display($tpl);
}
}
@@ -1,114 +0,0 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$user = Factory::getApplication()->getIdentity();
$requests = $this->requests;
$consent = $this->consent;
$token = Session::getFormToken();
$statusLabel = ['pending' => 'Pending', 'processing' => 'Processing', 'completed' => 'Completed', 'denied' => 'Denied'];
$statusClass = ['pending' => 'warning', 'processing' => 'info', 'completed' => 'success', 'denied' => 'secondary'];
?>
<div class="mokowaas-portal">
<h2>My Privacy &amp; Data</h2>
<p class="text-muted">Manage your personal data, download your information, or request account deletion.</p>
<!-- Action buttons -->
<div class="row g-3 mb-4">
<div class="col-12 col-md-4">
<button type="button" class="btn btn-primary w-100 py-3 btn-data-request" data-type="export">
<span class="icon-download d-block mb-1" style="font-size:1.5rem"></span>
Download My Data
</button>
</div>
<div class="col-12 col-md-4">
<button type="button" class="btn btn-outline-warning w-100 py-3 btn-data-request" data-type="anonymize">
<span class="icon-user-shield d-block mb-1" style="font-size:1.5rem"></span>
Anonymize My Account
</button>
</div>
<div class="col-12 col-md-4">
<button type="button" class="btn btn-outline-danger w-100 py-3 btn-data-request" data-type="delete">
<span class="icon-trash d-block mb-1" style="font-size:1.5rem"></span>
Delete My Account
</button>
</div>
</div>
<!-- My requests -->
<?php if (!empty($requests)): ?>
<div class="card mb-4">
<div class="card-header"><strong>My Data Requests</strong></div>
<div class="table-responsive">
<table class="table table-striped mb-0">
<thead><tr><th>Type</th><th>Status</th><th>Submitted</th><th>Processed</th></tr></thead>
<tbody>
<?php foreach ($requests as $r): ?>
<tr>
<td><?php echo ucfirst($r->type); ?></td>
<td><span class="badge bg-<?php echo $statusClass[$r->status] ?? 'secondary'; ?>"><?php echo $statusLabel[$r->status] ?? $r->status; ?></span></td>
<td><?php echo HTMLHelper::_('date', $r->created, 'M d, Y H:i'); ?></td>
<td><?php echo $r->processed ? HTMLHelper::_('date', $r->processed, 'M d, Y H:i') : '—'; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<!-- Consent history -->
<?php if (!empty($consent)): ?>
<div class="card mb-4">
<div class="card-header"><strong>Consent History</strong></div>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr><th>Category</th><th>Action</th><th>Date</th></tr></thead>
<tbody>
<?php foreach ($consent as $c): ?>
<tr>
<td><?php echo htmlspecialchars(ucwords(str_replace('_', ' ', $c->category))); ?></td>
<td><span class="badge bg-<?php echo $c->action === 'granted' ? 'success' : 'secondary'; ?>"><?php echo ucfirst($c->action); ?></span></td>
<td><?php echo HTMLHelper::_('date', $c->created, 'M d, Y H:i'); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
</div>
<script>
document.querySelectorAll('.btn-data-request').forEach(function(btn) {
btn.addEventListener('click', function() {
var type = this.dataset.type;
var messages = {
'export': 'Request a download of all your personal data?',
'anonymize': 'Request your account to be anonymized? Your name, email, and personal details will be replaced. This cannot be undone.',
'delete': 'Request permanent deletion of your account and all data? This cannot be undone.'
};
if (!confirm(messages[type] || 'Submit this request?')) return;
this.disabled = true;
var fd = new FormData();
fd.append('type', type);
fd.append('<?php echo $token; ?>', '1');
fetch('<?php echo Route::_("index.php?option=com_mokowaas&task=display.submitDataRequest&format=json"); ?>', {
method: 'POST', body: fd, headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) { alert(d.message); location.reload(); }
else { alert(d.message || 'Failed.'); }
})
.catch(function() { alert('Network error.'); })
.finally(function() { btn.disabled = false; });
});
});
</script>
@@ -1,241 +0,0 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
use Joomla\CMS\Factory;
$t = $this->ticket;
$isStaff = $this->isStaff;
$canAssign = $this->canAssign;
$token = Session::getFormToken();
$userId = Factory::getApplication()->getIdentity()->id;
$statusLabel = [
'open' => 'Open', 'in_progress' => 'In Progress', 'waiting' => 'Awaiting Response',
'resolved' => 'Resolved', 'closed' => 'Closed',
];
$statusClass = [
'open' => 'primary', 'in_progress' => 'info', 'waiting' => 'warning',
'resolved' => 'success', 'closed' => 'secondary',
];
?>
<div class="mokowaas-portal-ticket">
<div class="mb-3">
<a href="<?php echo Route::_('index.php?option=com_mokowaas&view=tickets'); ?>" class="btn btn-sm btn-outline-secondary">
<span class="icon-arrow-left"></span> Back to Tickets
</a>
</div>
<div class="row">
<!-- Main column: conversation -->
<div class="col-12 <?php echo $isStaff ? 'col-lg-8' : ''; ?>">
<!-- Ticket header -->
<div class="card mb-3">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<h3 class="mb-1">#<?php echo $t->id; ?> — <?php echo htmlspecialchars($t->subject); ?></h3>
<small class="text-muted">
<?php echo htmlspecialchars($t->category_title ?? 'General'); ?>
&middot; <?php echo HTMLHelper::_('date', $t->created, 'M d, Y H:i'); ?>
&middot; <?php echo ucfirst($t->priority); ?>
<?php if ($isStaff): ?>
&middot; By: <?php echo htmlspecialchars($t->created_by_name); ?>
<?php endif; ?>
</small>
</div>
<span class="badge bg-<?php echo $statusClass[$t->status] ?? 'secondary'; ?> fs-6">
<?php echo $statusLabel[$t->status] ?? $t->status; ?>
</span>
</div>
</div>
</div>
<!-- Original message -->
<div class="card mb-3">
<div class="card-header">
<strong><?php echo htmlspecialchars($t->created_by_name); ?></strong>
<small class="text-muted ms-2"><?php echo HTMLHelper::_('date', $t->created, 'M d, Y H:i'); ?></small>
</div>
<div class="card-body"><?php echo nl2br(htmlspecialchars($t->body)); ?></div>
</div>
<!-- Replies -->
<?php foreach ($t->replies as $reply): ?>
<?php
$replyIsStaffUser = ((int) $reply->user_id !== (int) $t->created_by);
$isInternal = (int) $reply->is_internal;
?>
<div class="card mb-3 <?php echo $isInternal ? 'border-warning bg-warning bg-opacity-10' : ($replyIsStaffUser ? 'border-primary' : ''); ?>">
<div class="card-header d-flex justify-content-between">
<div>
<strong><?php echo htmlspecialchars($reply->user_name ?? 'Support'); ?></strong>
<?php if ($replyIsStaffUser): ?><span class="badge bg-primary ms-1">Staff</span><?php endif; ?>
<?php if ($isInternal): ?><span class="badge bg-warning text-dark ms-1">Internal Note</span><?php endif; ?>
<small class="text-muted ms-2"><?php echo HTMLHelper::_('date', $reply->created, 'M d, Y H:i'); ?></small>
</div>
</div>
<div class="card-body"><?php echo nl2br(htmlspecialchars($reply->body)); ?></div>
</div>
<?php endforeach; ?>
<!-- Reply form -->
<?php if (!\in_array($t->status, ['closed'])): ?>
<div class="card mt-4">
<div class="card-body">
<h5>Reply</h5>
<form id="portalReply">
<textarea name="body" class="form-control mb-3" rows="5" required placeholder="Type your reply..."></textarea>
<input type="hidden" name="ticket_id" value="<?php echo $t->id; ?>">
<input type="hidden" name="<?php echo $token; ?>" value="1">
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<span class="icon-paper-plane"></span> Send Reply
</button>
<?php if ($isStaff): ?>
<button type="button" class="btn btn-outline-warning" id="btn-internal-note">
<span class="icon-eye-slash"></span> Internal Note
</button>
<?php endif; ?>
</div>
</form>
</div>
</div>
<?php elseif ($t->status === 'closed'): ?>
<div class="alert alert-secondary mt-4">
This ticket is closed. <a href="<?php echo Route::_('index.php?option=com_mokowaas&view=tickets&layout=submit'); ?>">Open a new ticket</a> if you need further help.
</div>
<?php endif; ?>
</div>
<!-- Staff sidebar -->
<?php if ($isStaff): ?>
<div class="col-12 col-lg-4">
<!-- Ticket info -->
<div class="card mb-3">
<div class="card-header"><strong>Details</strong></div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-5 text-muted">Status</dt>
<dd class="col-7"><span class="badge bg-<?php echo $statusClass[$t->status] ?? 'secondary'; ?>"><?php echo $statusLabel[$t->status] ?? $t->status; ?></span></dd>
<dt class="col-5 text-muted">Priority</dt>
<dd class="col-7"><?php echo ucfirst($t->priority); ?></dd>
<dt class="col-5 text-muted">Category</dt>
<dd class="col-7"><?php echo htmlspecialchars($t->category_title ?? '—'); ?></dd>
<dt class="col-5 text-muted">Submitted By</dt>
<dd class="col-7"><?php echo htmlspecialchars($t->created_by_name); ?><br><small class="text-muted"><?php echo htmlspecialchars($t->created_by_email ?? ''); ?></small></dd>
<dt class="col-5 text-muted">Assigned To</dt>
<dd class="col-7"><?php echo htmlspecialchars($t->assigned_to_name ?? 'Unassigned'); ?></dd>
<dt class="col-5 text-muted">Created</dt>
<dd class="col-7"><?php echo HTMLHelper::_('date', $t->created, 'M d H:i'); ?></dd>
<dt class="col-5 text-muted">Replies</dt>
<dd class="col-7"><?php echo \count($t->replies); ?></dd>
</dl>
</div>
</div>
<!-- Status actions -->
<div class="card mb-3">
<div class="card-header"><strong>Change Status</strong></div>
<div class="card-body d-grid gap-2">
<?php foreach (['open' => 'Reopen', 'in_progress' => 'In Progress', 'waiting' => 'Waiting on Customer', 'resolved' => 'Resolve', 'closed' => 'Close'] as $s => $label): ?>
<?php if ($s !== $t->status): ?>
<button type="button" class="btn btn-sm btn-outline-<?php echo $s === 'closed' ? 'danger' : ($s === 'resolved' ? 'success' : 'secondary'); ?> btn-status"
data-status="<?php echo $s; ?>">
<?php echo $label; ?>
</button>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>
<?php if ($canAssign): ?>
<!-- Quick assign -->
<div class="card mb-3">
<div class="card-header"><strong>Assign</strong></div>
<div class="card-body">
<button type="button" class="btn btn-sm btn-outline-primary w-100" id="btn-assign-me">
Assign to Me
</button>
</div>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var token = '<?php echo $token; ?>';
var ticketId = <?php echo $t->id; ?>;
// Reply
var replyForm = document.getElementById('portalReply');
if (replyForm) {
replyForm.addEventListener('submit', function(e) {
e.preventDefault();
sendReply(false);
});
}
// Internal note
var internalBtn = document.getElementById('btn-internal-note');
if (internalBtn) {
internalBtn.addEventListener('click', function() { sendReply(true); });
}
function sendReply(isInternal) {
var body = replyForm.querySelector('textarea[name=body]').value.trim();
if (!body) return;
var fd = new FormData();
fd.append('ticket_id', ticketId);
fd.append('body', body);
fd.append('is_internal', isInternal ? '1' : '0');
fd.append(token, '1');
fetch('<?php echo Route::_("index.php?option=com_mokowaas&task=display.submitReply&format=json"); ?>', {
method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}
}).then(function(r){return r.json()}).then(function(d){
if (d.success) location.reload();
else alert(d.message);
});
}
// Status buttons
document.querySelectorAll('.btn-status').forEach(function(btn) {
btn.addEventListener('click', function() {
var fd = new FormData();
fd.append('ticket_id', ticketId);
fd.append('status', this.dataset.status);
fd.append(token, '1');
fetch('<?php echo Route::_("index.php?option=com_mokowaas&task=display.updateStatus&format=json"); ?>', {
method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}
}).then(function(r){return r.json()}).then(function(d){
if (d.success) location.reload();
else alert(d.message);
});
});
});
// Assign to me
var assignBtn = document.getElementById('btn-assign-me');
if (assignBtn) {
assignBtn.addEventListener('click', function() {
var fd = new FormData();
fd.append('ticket_id', ticketId);
fd.append('assigned_to', <?php echo $userId; ?>);
fd.append(token, '1');
fetch('<?php echo Route::_("index.php?option=com_mokowaas&task=display.assignTicket&format=json"); ?>', {
method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}
}).then(function(r){return r.json()}).then(function(d){
if (d.success) location.reload();
else alert(d.message);
});
});
}
});
</script>
@@ -1,83 +0,0 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$tickets = $this->tickets;
$categories = $this->categories;
$isStaff = $this->isStaff;
$token = Session::getFormToken();
$statusLabel = [
'open' => 'Open', 'in_progress' => 'In Progress', 'waiting' => 'Awaiting Response',
'resolved' => 'Resolved', 'closed' => 'Closed',
];
$statusClass = [
'open' => 'primary', 'in_progress' => 'info', 'waiting' => 'warning',
'resolved' => 'success', 'closed' => 'secondary',
];
?>
<div class="mokowaas-portal">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><?php echo $isStaff ? 'All Support Tickets' : 'My Support Tickets'; ?></h2>
<div class="d-flex gap-2">
<a href="<?php echo Route::_('index.php?option=com_mokowaas&view=tickets&layout=submit'); ?>" class="btn btn-primary">
<span class="icon-plus"></span> New Ticket
</a>
<?php if ($isStaff): ?>
<form method="get" class="d-inline">
<input type="hidden" name="option" value="com_mokowaas">
<input type="hidden" name="view" value="tickets">
<select name="filter_status" class="form-select form-select-sm" style="width:auto" onchange="this.form.submit()">
<option value="">All Statuses</option>
<?php foreach ($statusLabel as $k => $v): ?>
<option value="<?php echo $k; ?>" <?php echo Factory::getApplication()->getInput()->getString('filter_status') === $k ? 'selected' : ''; ?>><?php echo $v; ?></option>
<?php endforeach; ?>
</select>
</form>
<?php endif; ?>
</div>
</div>
<?php if (empty($tickets)): ?>
<div class="alert alert-info">
<span class="icon-info-circle"></span>
<?php echo $isStaff ? 'No tickets found.' : 'You haven\'t submitted any support tickets yet.'; ?>
</div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>#</th>
<th>Subject</th>
<th>Status</th>
<th>Priority</th>
<th>Category</th>
<?php if ($isStaff): ?><th>Submitted By</th><th>Assigned To</th><?php endif; ?>
<th>Date</th>
</tr>
</thead>
<tbody>
<?php foreach ($tickets as $t): ?>
<tr>
<td><a href="<?php echo Route::_('index.php?option=com_mokowaas&view=ticket&id=' . $t->id); ?>"><?php echo $t->id; ?></a></td>
<td><a href="<?php echo Route::_('index.php?option=com_mokowaas&view=ticket&id=' . $t->id); ?>"><?php echo htmlspecialchars(mb_substr($t->subject, 0, 60)); ?></a></td>
<td><span class="badge bg-<?php echo $statusClass[$t->status] ?? 'secondary'; ?>"><?php echo $statusLabel[$t->status] ?? $t->status; ?></span></td>
<td><?php echo ucfirst($t->priority); ?></td>
<td><?php echo htmlspecialchars($t->category_title ?? '—'); ?></td>
<?php if ($isStaff): ?>
<td><?php echo htmlspecialchars($t->created_by_name ?? ''); ?></td>
<td><?php echo htmlspecialchars($t->assigned_to_name ?? '<em>Unassigned</em>'); ?></td>
<?php endif; ?>
<td class="text-nowrap"><?php echo HTMLHelper::_('date', $t->created, 'M d, Y'); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
@@ -1,204 +0,0 @@
<?php
/**
* Submit a Ticket layout — search KB first, then submit form.
*/
defined('_JEXEC') or die;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$categories = $this->categories;
$token = Session::getFormToken();
$searchUrl = Route::_('index.php?option=com_mokowaas&task=display.searchKb&format=json');
$submitUrl = Route::_('index.php?option=com_mokowaas&task=display.submitTicket&format=json');
$ticketUrl = Route::_('index.php?option=com_mokowaas&view=ticket&id=');
$ticketsUrl = Route::_('index.php?option=com_mokowaas&view=tickets');
// Check if Smart Search has indexed content
$finderEnabled = false;
try {
$db = \Joomla\CMS\Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
$db->setQuery('SELECT COUNT(*) FROM #__finder_links WHERE published = 1');
$finderEnabled = (int) $db->loadResult() > 0;
} catch (\Throwable $e) {}
?>
<div class="mokowaas-portal">
<h2>Submit a Support Request</h2>
<?php if ($finderEnabled): ?>
<!-- Step 1: Search -->
<div id="step-search" class="mb-4">
<p class="text-muted">Before submitting, let's see if we already have an answer for you.</p>
<div class="card">
<div class="card-body">
<label class="form-label fw-bold" for="kb-search">Describe your issue</label>
<div class="input-group input-group-lg">
<input type="text" id="kb-search" class="form-control" placeholder="e.g. how do I reset my password?" autofocus>
<button type="button" class="btn btn-primary" id="kb-search-btn">
<span class="icon-search"></span> Search
</button>
</div>
</div>
</div>
<!-- Search results -->
<div id="kb-results" class="mt-3 d-none">
<h5>Related Articles</h5>
<div id="kb-results-list" class="list-group mb-3"></div>
<p class="text-muted">Didn't find what you need?</p>
</div>
<div class="mt-3">
<button type="button" class="btn btn-outline-primary" id="btn-show-form">
<span class="icon-plus"></span> Submit a Ticket Anyway
</button>
</div>
</div>
<?php endif; ?>
<!-- Step 2: Ticket Form -->
<div id="step-form" class="<?php echo $finderEnabled ? 'd-none' : ''; ?>">
<div class="card">
<div class="card-body">
<h5 class="card-title mb-3">Ticket Details</h5>
<form id="submitTicketForm">
<div class="mb-3">
<label class="form-label" for="ticket-subject">Subject <span class="text-danger">*</span></label>
<input type="text" id="ticket-subject" name="subject" class="form-control" required placeholder="Brief description of your issue">
</div>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label" for="ticket-category">Category</label>
<select id="ticket-category" name="category_id" class="form-select">
<option value="">Select a category</option>
<?php foreach ($categories as $cat): ?>
<option value="<?php echo $cat->id; ?>"><?php echo htmlspecialchars($cat->title); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-6">
<label class="form-label" for="ticket-priority">Priority</label>
<select id="ticket-priority" name="priority" class="form-select">
<option value="normal">Normal</option>
<option value="low">Low</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label" for="ticket-body">Description <span class="text-danger">*</span></label>
<textarea id="ticket-body" name="body" class="form-control" rows="8" required placeholder="Please describe your issue in detail."></textarea>
</div>
<input type="hidden" name="<?php echo $token; ?>" value="1">
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary btn-lg">
<span class="icon-paper-plane"></span> Submit Ticket
</button>
<a href="<?php echo $ticketsUrl; ?>" class="btn btn-outline-secondary btn-lg">
My Tickets
</a>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var searchInput = document.getElementById('kb-search');
var searchBtn = document.getElementById('kb-search-btn');
var resultBox = document.getElementById('kb-results');
var resultList = document.getElementById('kb-results-list');
var showFormBtn = document.getElementById('btn-show-form');
var stepSearch = document.getElementById('step-search');
var stepForm = document.getElementById('step-form');
var subjectField = document.getElementById('ticket-subject');
// Search
function doSearch() {
var q = (searchInput ? searchInput.value.trim() : '');
if (q.length < 3) return;
fetch('<?php echo $searchUrl; ?>&q=' + encodeURIComponent(q), {
headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(function(r) { return r.json(); })
.then(function(d) {
resultList.textContent = '';
if (d.results && d.results.length > 0) {
d.results.forEach(function(item) {
var a = document.createElement('a');
a.href = item.url;
a.target = '_blank';
a.className = 'list-group-item list-group-item-action';
var strong = document.createElement('strong');
strong.textContent = item.title;
a.appendChild(strong);
if (item.description) {
a.appendChild(document.createElement('br'));
var small = document.createElement('small');
small.className = 'text-muted';
small.textContent = item.description;
a.appendChild(small);
}
resultList.appendChild(a);
});
resultBox.classList.remove('d-none');
} else {
resultBox.classList.add('d-none');
}
// Always show the "submit anyway" button after search
if (showFormBtn) showFormBtn.classList.remove('d-none');
});
}
if (searchBtn) searchBtn.addEventListener('click', doSearch);
if (searchInput) {
searchInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); doSearch(); }
});
}
// Show form and prefill subject from search query
if (showFormBtn) {
showFormBtn.addEventListener('click', function() {
if (stepSearch) stepSearch.classList.add('d-none');
if (stepForm) stepForm.classList.remove('d-none');
if (searchInput && subjectField && !subjectField.value) {
subjectField.value = searchInput.value;
}
subjectField.focus();
});
}
// Submit ticket
var form = document.getElementById('submitTicketForm');
if (form) {
form.addEventListener('submit', function(e) {
e.preventDefault();
var btn = form.querySelector('button[type=submit]');
btn.disabled = true;
btn.textContent = ' Submitting...';
var fd = new FormData(form);
fetch('<?php echo $submitUrl; ?>', {
method: 'POST', body: fd, headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success && d.id) {
window.location.href = '<?php echo $ticketUrl; ?>' + d.id;
} else {
alert(d.message || 'Failed.');
btn.disabled = false;
btn.textContent = ' Submit Ticket';
}
})
.catch(function() { alert('Network error.'); btn.disabled = false; });
});
}
});
</script>
@@ -1,3 +0,0 @@
MOD_MOKOWAAS_CACHE="MokoWaaS Cache Cleaner"
MOD_MOKOWAAS_CACHE_DESC="One-click cache cleaner in the admin status bar. Clears all Joomla cache (site, admin, and expired)."
MOD_MOKOWAAS_CACHE_CLEAR_ALL="Clear All Cache"
@@ -1,2 +0,0 @@
MOD_MOKOWAAS_CACHE="MokoWaaS Cache Cleaner"
MOD_MOKOWAAS_CACHE_DESC="One-click cache cleaner in the admin status bar. Clears all Joomla cache (site, admin, and expired)."
@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<extension type="module" client="administrator" method="upgrade">
<name>mod_mokowaas_cache</name>
<author>Moko Consulting</author>
<creationDate>2026-06-04</creationDate>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.33.01-dev</version>
<description>MOD_MOKOWAAS_CACHE_DESC</description>
<namespace path="src">Moko\Module\MokoWaaSCache</namespace>
<files>
<folder module="mod_mokowaas_cache">services</folder>
<folder>src</folder>
<folder>tmpl</folder>
</files>
<languages folder="language">
<language tag="en-GB">en-GB/mod_mokowaas_cache.ini</language>
<language tag="en-GB">en-GB/mod_mokowaas_cache.sys.ini</language>
</languages>
</extension>
@@ -1,23 +0,0 @@
<?php
/**
* @package MokoWaaS
* @subpackage mod_mokowaas_cache
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Extension\Service\Provider\Module;
use Joomla\CMS\Extension\Service\Provider\ModuleDispatcherFactory;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
return new class implements ServiceProviderInterface
{
public function register(Container $container): void
{
$container->registerServiceProvider(new ModuleDispatcherFactory('\\Moko\\Module\\MokoWaaSCache'));
$container->registerServiceProvider(new Module());
}
};
@@ -1,14 +0,0 @@
<?php
namespace Moko\Module\MokoWaaSCache\Administrator\Dispatcher;
defined('_JEXEC') or die;
use Joomla\CMS\Dispatcher\AbstractModuleDispatcher;
class Dispatcher extends AbstractModuleDispatcher
{
protected function getLayoutData()
{
return parent::getLayoutData();
}
}
@@ -1,73 +0,0 @@
<?php
/**
* MokoWaaS Cache Cleaner — status bar module
*
* One-click button in the admin status bar that clears all Joomla cache.
* Uses native Atum header-item markup.
*/
defined('_JEXEC') or die;
use Joomla\CMS\Session\Session;
use Joomla\CMS\Language\Text;
$token = Session::getFormToken();
$ajaxUrl = 'index.php?option=com_mokowaas&task=clearCache&format=json';
?>
<a href="#" class="header-item-content" title="<?php echo Text::_('MOD_MOKOWAAS_CACHE_CLEAR_ALL'); ?>" id="mokowaas-clear-cache">
<div class="header-item-icon">
<span class="icon-bolt" aria-hidden="true" id="mokowaas-cache-icon"></span>
</div>
<div class="header-item-text">
<?php echo Text::_('MOD_MOKOWAAS_CACHE'); ?>
</div>
</a>
<script>
document.addEventListener('DOMContentLoaded', function() {
var btn = document.getElementById('mokowaas-clear-cache');
var icon = document.getElementById('mokowaas-cache-icon');
if (!btn || !icon) return;
btn.addEventListener('click', function(e) {
e.preventDefault();
if (btn.dataset.busy) return;
btn.dataset.busy = '1';
icon.className = 'icon-spinner icon-spin';
var formData = new FormData();
formData.append('<?php echo $token; ?>', '1');
fetch('<?php echo $ajaxUrl; ?>', {
method: 'POST',
headers: {'X-Requested-With': 'XMLHttpRequest'},
body: formData
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
icon.className = 'icon-check';
icon.style.color = '#198754';
} else {
icon.className = 'icon-times';
icon.style.color = '#dc3545';
}
setTimeout(function() {
icon.className = 'icon-bolt';
icon.style.color = '';
delete btn.dataset.busy;
}, 2000);
})
.catch(function() {
icon.className = 'icon-times';
icon.style.color = '#dc3545';
setTimeout(function() {
icon.className = 'icon-bolt';
icon.style.color = '';
delete btn.dataset.busy;
}, 2000);
});
});
});
</script>
@@ -7,7 +7,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.33.01-dev</version>
<version>02.34.00</version>
<description>MOD_MOKOWAAS_CPANEL_DESC</description>
<namespace path="src">Moko\Module\MokoWaaSCpanel</namespace>
@@ -24,18 +24,6 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI
{
$data = parent::getLayoutData();
// Hide on MokoWaaS dashboard — the dashboard has its own info panels
$app = Factory::getApplication();
$option = $app->getInput()->get('option', '');
$view = $app->getInput()->get('view', '');
if ($option === 'com_mokowaas' && ($view === '' || $view === 'dashboard'))
{
$data['hidden'] = true;
return $data;
}
$db = Factory::getContainer()->get(DatabaseInterface::class);
$helper = $this->getHelperFactory()->getHelper('CpanelHelper');
@@ -45,7 +33,6 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI
$data['counts'] = $helper->getCounts($db);
$data['disk'] = $helper->getDiskInfo();
$data['currentIp'] = $helper->getCurrentIp();
$data['ssl'] = $helper->getSslStatus();
return $data;
}
@@ -87,11 +87,10 @@ class CpanelHelper
public function getCounts(DatabaseInterface $db): object
{
$counts = (object) [
'articles' => 0,
'users' => 0,
'extensions' => 0,
'updates' => 0,
'moko_updates' => 0,
'articles' => 0,
'users' => 0,
'extensions' => 0,
'updates' => 0,
];
try
@@ -107,20 +106,6 @@ class CpanelHelper
$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from($db->quoteName('#__updates'))->where($db->quoteName('extension_id') . ' != 0'));
$counts->updates = (int) $db->loadResult();
// MokoWaaS-specific updates
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__updates', 'u'))
->join('INNER', $db->quoteName('#__extensions', 'e') . ' ON e.extension_id = u.extension_id')
->where('(' . $db->quoteName('e.element') . ' LIKE ' . $db->quote('mokowaas%')
. ' OR ' . $db->quoteName('e.element') . ' LIKE ' . $db->quote('pkg_mokowaas%')
. ' OR ' . $db->quoteName('e.element') . ' LIKE ' . $db->quote('com_mokowaas%')
. ' OR ' . $db->quoteName('e.element') . ' LIKE ' . $db->quote('mod_mokowaas%')
. ' OR ' . $db->quoteName('e.element') . ' = ' . $db->quote('mokoonyx') . ')')
);
$counts->moko_updates = (int) $db->loadResult();
}
catch (\Throwable $e)
{
@@ -151,54 +136,4 @@ class CpanelHelper
{
return $_SERVER['REMOTE_ADDR'] ?? '';
}
/**
* Check SSL certificate expiry (#148).
*
* @return object|null {expires, days_remaining, warning} or null if check fails
*/
public function getSslStatus(): ?object
{
try
{
$host = parse_url(\Joomla\CMS\Uri\Uri::root(), PHP_URL_HOST);
if (empty($host))
{
return null;
}
$context = stream_context_create(['ssl' => ['capture_peer_cert' => true, 'verify_peer' => false]]);
$client = @stream_socket_client('ssl://' . $host . ':443', $errno, $errstr, 5, STREAM_CLIENT_CONNECT, $context);
if (!$client)
{
return null;
}
$params = stream_context_get_params($client);
fclose($client);
$cert = openssl_x509_parse($params['options']['ssl']['peer_certificate'] ?? '');
if (empty($cert['validTo_time_t']))
{
return null;
}
$expires = $cert['validTo_time_t'];
$days = (int) floor(($expires - time()) / 86400);
return (object) [
'expires' => date('Y-m-d', $expires),
'days_remaining' => $days,
'warning' => $days <= 30,
'critical' => $days <= 7,
];
}
catch (\Throwable $e)
{
return null;
}
}
}
@@ -12,9 +12,6 @@ use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
// Hidden when on MokoWaaS dashboard (redundant info)
if (!empty($hidden)) return;
$siteInfo = $siteInfo ?? (object) [];
$plugins = $plugins ?? [];
@@ -70,16 +67,6 @@ $diskColor = ($diskPct !== null && $diskPct > 90) ? 'bg-danger' : (($diskPct !==
<?php if (!empty($siteInfo->offline)): ?>
<span class="badge bg-danger">Offline</span>
<?php endif; ?>
<?php if (($counts->moko_updates ?? 0) > 0): ?>
<a href="<?php echo Route::_('index.php?option=com_installer&view=update'); ?>" class="badge bg-info text-decoration-none" title="MokoWaaS updates available">
<span class="icon-upload" aria-hidden="true"></span> <?php echo $counts->moko_updates; ?> MokoWaaS update<?php echo $counts->moko_updates > 1 ? 's' : ''; ?>
</a>
<?php endif; ?>
<?php if ($counts->updates > 0 && $counts->updates !== ($counts->moko_updates ?? 0)): ?>
<a href="<?php echo Route::_('index.php?option=com_installer&view=update'); ?>" class="badge bg-warning text-dark text-decoration-none" title="Other updates available">
<span class="icon-upload" aria-hidden="true"></span> <?php echo $counts->updates - ($counts->moko_updates ?? 0); ?> update<?php echo ($counts->updates - ($counts->moko_updates ?? 0)) > 1 ? 's' : ''; ?>
</a>
<?php endif; ?>
<span class="icon-chevron-down small text-muted" aria-hidden="true"></span>
</a>
<a href="<?php echo Route::_('index.php?option=com_mokowaas'); ?>" class="btn btn-sm btn-primary">
@@ -143,12 +130,6 @@ $diskColor = ($diskPct !== null && $diskPct > 90) ? 'bg-danger' : (($diskPct !==
<?php if ($showIp && $currentIp): ?>
<span class="text-muted"><span class="icon-globe" aria-hidden="true"></span> <code><?php echo htmlspecialchars($currentIp); ?></code></span>
<?php endif; ?>
<?php $ssl = $ssl ?? null; if ($ssl): ?>
<span class="badge bg-<?php echo $ssl->critical ? 'danger' : ($ssl->warning ? 'warning text-dark' : 'success'); ?>" title="SSL expires <?php echo $ssl->expires; ?>">
<span class="icon-lock" aria-hidden="true"></span>
SSL <?php echo $ssl->days_remaining; ?>d
</span>
<?php endif; ?>
<?php if ($showVersions): ?>
<span class="text-muted">J<?php echo htmlspecialchars($siteInfo->joomla_version ?? ''); ?> / PHP <?php echo htmlspecialchars($siteInfo->php_version ?? ''); ?></span>
<?php endif; ?>
@@ -1 +0,0 @@
MOD_MOKOWAAS_MENU="MokoWaaS Admin Menu"
@@ -1,2 +0,0 @@
MOD_MOKOWAAS_MENU="MokoWaaS Admin Menu"
MOD_MOKOWAAS_MENU_DESC="Dedicated MokoWaaS section in the admin sidebar menu."
@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<extension type="module" client="administrator" method="upgrade">
<name>mod_mokowaas_menu</name>
<author>Moko Consulting</author>
<creationDate>2026-06-04</creationDate>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.33.01-dev</version>
<description>MokoWaaS admin sidebar menu — renders a dedicated MokoWaaS section in the admin menu before Joomla's default menu.</description>
<namespace path="src">Moko\Module\MokoWaaSMenu</namespace>
<files>
<folder module="mod_mokowaas_menu">services</folder>
<folder>src</folder>
<folder>tmpl</folder>
</files>
<languages folder="language">
<language tag="en-GB">en-GB/mod_mokowaas_menu.ini</language>
<language tag="en-GB">en-GB/mod_mokowaas_menu.sys.ini</language>
</languages>
</extension>
@@ -1,18 +0,0 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Extension\Service\Provider\HelperFactory;
use Joomla\CMS\Extension\Service\Provider\Module;
use Joomla\CMS\Extension\Service\Provider\ModuleDispatcherFactory;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
return new class implements ServiceProviderInterface
{
public function register(Container $container): void
{
$container->registerServiceProvider(new ModuleDispatcherFactory('\\Moko\\Module\\MokoWaaSMenu'));
$container->registerServiceProvider(new HelperFactory('\\Moko\\Module\\MokoWaaSMenu\\Administrator\\Helper'));
$container->registerServiceProvider(new Module());
}
};
@@ -1,14 +0,0 @@
<?php
namespace Moko\Module\MokoWaaSMenu\Administrator\Dispatcher;
defined('_JEXEC') or die;
use Joomla\CMS\Dispatcher\AbstractModuleDispatcher;
class Dispatcher extends AbstractModuleDispatcher
{
protected function getLayoutData()
{
return parent::getLayoutData();
}
}
@@ -1,66 +0,0 @@
<?php
/**
* MokoWaaS Admin Sidebar Menu
*
* Uses native Joomla admin menu classes (MetisMenu) so it renders
* identically to Joomla's own sidebar menu items.
*/
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
$items = [
['icon' => 'icon-cogs', 'title' => 'Dashboard', 'link' => 'index.php?option=com_mokowaas'],
['icon' => 'fa-solid fa-handshake-angle', 'title' => 'Helpdesk', 'link' => 'index.php?option=com_mokowaas&view=tickets'],
['icon' => 'icon-puzzle-piece', 'title' => 'Extensions', 'link' => 'index.php?option=com_mokowaas&view=extensions'],
['icon' => 'fa-solid fa-file-code', 'title' => '.htaccess Maker', 'link' => 'index.php?option=com_mokowaas&view=htaccess'],
['icon' => 'icon-lock', 'title' => 'Privacy Guard', 'link' => 'index.php?option=com_mokowaas&view=privacy'],
['icon' => 'icon-shield-alt', 'title' => 'WAF Log', 'link' => 'index.php?option=com_mokowaas&view=waflog'],
['icon' => 'icon-database', 'title' => 'Database Tools', 'link' => 'index.php?option=com_mokowaas&view=database'],
['icon' => 'icon-trash', 'title' => 'Cache Cleanup', 'link' => 'index.php?option=com_mokowaas&view=cleanup'],
['icon' => 'icon-power-off', 'title' => 'Feature Plugins', 'link' => 'index.php?option=com_plugins&filter[folder]=system&filter[search]=mokowaas'],
];
$app = \Joomla\CMS\Factory::getApplication();
$currentOption = $app->getInput()->get('option', '');
$currentView = $app->getInput()->get('view', '');
// Determine if any child is active (auto-expand)
$anyActive = ($currentOption === 'com_mokowaas');
$parentClass = 'item parent item-level-1' . ($anyActive ? ' mm-active' : '');
$collapseClass = 'collapse-level-1 mm-collapse' . ($anyActive ? ' mm-show' : '');
?>
<ul class="nav flex-column main-nav">
<li class="<?php echo $parentClass; ?>">
<a class="has-arrow" href="#" aria-label="MokoWaaS">
<span class="icon-shield-alt" aria-hidden="true"></span>
<span class="sidebar-item-title">MokoWaaS</span>
</a>
<ul class="<?php echo $collapseClass; ?>">
<?php foreach ($items as $item): ?>
<?php
$active = false;
$parsed = [];
parse_str(parse_url($item['link'], PHP_URL_QUERY) ?? '', $parsed);
if (($parsed['option'] ?? '') === $currentOption)
{
$active = empty($parsed['view'])
? ($currentView === '' || $currentView === 'dashboard')
: ($currentView === ($parsed['view'] ?? ''));
}
$liClass = 'item item-level-2' . ($active ? ' mm-active' : '');
$aClass = 'no-dropdown' . ($active ? ' mm-active' : '');
?>
<li class="<?php echo $liClass; ?>">
<a class="<?php echo $aClass; ?>" href="<?php echo Route::_($item['link']); ?>"<?php echo $active ? ' aria-current="page"' : ''; ?>>
<span class="<?php echo $item['icon']; ?>" aria-hidden="true" style="display:inline-block!important;width:1.25em;text-align:center;margin-inline-end:0.4em;"></span>
<span class="sidebar-item-title"><?php echo $item['title']; ?></span>
</a>
</li>
<?php endforeach; ?>
</ul>
</li>
</ul>
File diff suppressed because it is too large Load Diff
@@ -7,7 +7,7 @@
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* VERSION: 02.33.01
* VERSION: 02.34.00
* PATH: /src/Field/AllowedIpsField.php
* BRIEF: Custom form field that displays the current IP whitelist
*/
@@ -8,7 +8,7 @@
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* VERSION: 02.33.01
* VERSION: 02.34.00
* PATH: /src/Field/CopyableTokenField.php
* BRIEF: Read-only token field with a copy-to-clipboard button
*/
@@ -7,7 +7,7 @@
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* VERSION: 02.33.01
* VERSION: 02.34.00
* PATH: /src/Field/CurrentIpField.php
* BRIEF: Read-only field that displays the current user's IP address
*/
@@ -8,7 +8,7 @@
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* VERSION: 02.33.01
* VERSION: 02.34.00
* PATH: /src/Field/DemoTaskInfoField.php
* BRIEF: Read-only field showing scheduled task info with link to manage it
*/
@@ -8,7 +8,7 @@
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* VERSION: 02.33.01
* VERSION: 02.34.00
* PATH: /src/Field/NextResetField.php
* BRIEF: Read-only field showing next reset time from Joomla scheduled task
*/
@@ -8,7 +8,7 @@
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* VERSION: 02.33.01
* VERSION: 02.34.00
* PATH: /src/Field/SnapshotTablesField.php
* BRIEF: Multi-select list field that loads DB tables with sensible defaults
*/
@@ -52,7 +52,7 @@ final class MokoWaaSHelper
*
* @return array
*/
private static function getMasterUsernames(): array
public static function getMasterUsernames(): array
{
if (self::$masterNames !== null)
{
@@ -10,7 +10,7 @@
* INGROUP: MokoWaaS
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
* PATH: /src/packages/plg_system_mokowaas/Service/ContentSyncReceiver.php
* VERSION: 02.33.01
* VERSION: 02.34.00
* BRIEF: Receiver-side content sync applies incoming payload to local DB
*/
@@ -10,7 +10,7 @@
* INGROUP: MokoWaaS
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
* PATH: /src/packages/plg_system_mokowaas/Service/ContentSyncService.php
* VERSION: 02.33.01
* VERSION: 02.34.00
* BRIEF: Sender-side content sync builds payload and pushes to remote sites
*/
@@ -10,7 +10,7 @@
* INGROUP: MokoWaaS
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
* PATH: /src/packages/plg_system_mokowaas/Service/DemoResetService.php
* VERSION: 02.33.01
* VERSION: 02.34.00
* BRIEF: Content-only snapshot/restore for demo site reset
*/

Some files were not shown because too many files have changed in this diff Show More