Compare commits

...

79 Commits

Author SHA1 Message Date
gitea-actions[bot] 9cf3b51024 chore: update development channel 02.32.38 [skip ci] 2026-06-03 00:29:36 +00:00
gitea-actions[bot] 6f762534fe chore(version): auto-bump 02.32.38 [skip ci] 2026-06-03 00:29:35 +00:00
Jonathan Miller c8af0fa5ca Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Failing after 4s
Update Server / Update Server (push) Successful in 11s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 23s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-02 19:29:18 -05:00
Jonathan Miller ac920b997a feat: helpdesk automation engine + scheduled task plugin
Automation engine in TicketsModel:
- Condition evaluator: field/op/value with eq, neq, gt, lt, in, not_in
- Action executor: set_status, set_priority, assign, add_note
- Trigger events: ticket_created, ticket_replied, status_changed, scheduled
- Hooks wired into createTicket, addReply, updateStatus

Scheduled task plugin (plg_task_mokowaas_tickets):
- Runs all 'scheduled' automation rules against non-closed tickets
- Evaluates age_hours, status, priority, sla_responded
- Joomla Scheduler integration via TaskPluginTrait

Default automation rules:
1. Auto-close resolved tickets after 7 days
2. Escalate urgent tickets with no response in 1 hour
3. Notify on high/urgent ticket creation

Also:
- Added #__mokowaas_ticket_automation table
- Fixed dashboard ImportModel null error (direct instantiation)
- Added task plugin to package manifest + script.php

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 19:29:10 -05:00
gitea-actions[bot] c5552a94fb chore: update development channel 02.32.37 [skip ci] 2026-06-02 23:48:58 +00:00
gitea-actions[bot] 8168bfb2dc chore(version): auto-bump 02.32.37 [skip ci] 2026-06-02 23:48:57 +00:00
Jonathan Miller d73b8b06ef Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Failing after 5s
Update Server / Update Server (push) Successful in 13s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 32s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-02 18:48:42 -05:00
Jonathan Miller f3a3bc90b3 feat: frontend ticket management with smart permissions
Ticket List (view=tickets):
- Staff see all tickets with Submitted By + Assigned To columns
- Customers see only their own tickets
- Status filter dropdown for staff
- isStaff check: core.admin OR mokowaas.tickets ACL

Ticket Detail (view=ticket):
- Staff see all tickets + internal notes (yellow highlight)
- Customers see only their own + no internal notes
- Staff sidebar: ticket details, change status buttons, assign to me
- Staff can post internal notes from frontend
- Customers can reply (disabled for closed tickets)
- Ownership enforced: customers can't view/reply to others' tickets

Controller:
- updateStatus: staff only
- assignTicket: mokowaas.tickets.assign ACL
- submitReply: verifies ownership OR staff

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 18:48:13 -05:00
jmiller 756c2bff32 chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-06-02 23:47:10 +00:00
gitea-actions[bot] b8fbb0d1d6 chore: update development channel 02.32.36 [skip ci] 2026-06-02 23:46:31 +00:00
gitea-actions[bot] fd6c79d3a2 chore(version): auto-bump 02.32.36 [skip ci] 2026-06-02 23:46:30 +00:00
Jonathan Miller f350cd0169 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Failing after 4s
Update Server / Update Server (push) Successful in 11s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 23s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-02 18:46:15 -05:00
Jonathan Miller 4a18318cb9 feat: search KB before submitting ticket (frontend + backend)
Both frontend and admin ticket creation now show a KB search step
first. User types their issue, Smart Search (com_finder) returns
matching articles. If nothing helps, they click through to the
ticket form with the search query pre-filled as the subject.

Frontend (/support/submit-ticket):
- Search input with live results from #__finder_links
- Article links open in new tab
- "Submit Anyway" shows the ticket form
- Falls back to direct form if Smart Search has no indexed content

Admin (New Ticket modal):
- Same KB search step before the form
- Results as clickable list-group items
- Modal resets on close

Also:
- Added searchKb controller task (both frontend + admin)
- Added "Submit a Ticket" child menu item under Support on install
- Fixed innerHTML → safe DOM methods for search results

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 18:46:12 -05:00
jmiller ce04701616 chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-02 21:51:27 +00:00
Jonathan Miller b37120341f feat: auto-create Support menu item on package install
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (push) Has been skipped
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 23s
Universal: Auto Version Bump / Version Bump (push) Failing after 3s
Update Server / Update Server (push) Failing after 8s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Creates a frontend menu item at /support pointing to the customer
portal (com_mokowaas&view=tickets). Access level: Registered.
Idempotent — skips if already exists.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 16:47:21 -05:00
Jonathan Miller 7f0b7756e4 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 24s
Universal: Auto Version Bump / Version Bump (push) Failing after 3s
Update Server / Update Server (push) Failing after 8s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-02 16:39:14 -05:00
Jonathan Miller 80c2658b06 feat: customer portal - frontend ticket submission and history
Frontend (site-side) views for authenticated users:

Ticket List (view=tickets):
- Shows user's own tickets with status badges
- Collapsible new ticket form with category + priority
- Redirects guests to login with return URL

Ticket Detail (view=ticket):
- Conversation thread (excludes internal notes)
- Staff replies highlighted with badge
- Reply form (disabled for closed/resolved tickets)
- User can only see their own tickets

Also fixed .gitignore to allow src/packages/*/site/ directories.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 16:39:06 -05:00
Moko Consulting 995fc4b591 chore(ci): sync CI issue reporter from Template-Joomla
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (push) Has been skipped
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 26s
Universal: Auto Version Bump / Version Bump (push) Failing after 5s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-02 21:33:07 +00:00
Moko Consulting 240a947bec chore(ci): sync CI issue reporter from Template-Joomla
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 26s
Universal: Auto Version Bump / Version Bump (push) Failing after 4s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-02 21:33:06 +00:00
Moko Consulting a2091b1a67 chore(ci): sync CI issue reporter from Template-Joomla
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (push) Has been skipped
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 28s
Universal: Auto Version Bump / Version Bump (push) Failing after 5s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-02 21:33:04 +00:00
gitea-actions[bot] eab0ed1b80 chore: update development channel 02.32.35 [skip ci] 2026-06-02 20:51:48 +00:00
gitea-actions[bot] 23d6a1ad44 chore(version): auto-bump 02.32.35 [skip ci] 2026-06-02 20:51:45 +00:00
Jonathan Miller 2706d81267 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Failing after 5s
Update Server / Update Server (push) Successful in 17s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 31s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
2026-06-02 15:51:29 -05:00
Jonathan Miller ed138fdc57 feat: Akeeba Admin Tools + Ticket System importers with auto-disable
New ImportModel with two importers:

Admin Tools Importer:
- Reads #__admintools_wafconfig → maps WAF shields, admin secret,
  session timeout, template blocking to firewall plugin params
- Reads #__admintools_storage → maps server signature, HSTS, GZip,
  expires, ETag, www redirect, directory listing to htaccess options
- Reads #__admintools_ipblock → merges into firewall IP deny list
- Disables com_admintools + all admintools plugins after import

Akeeba Ticket System Importer:
- Imports tickets with status/priority mapping
- First post becomes ticket body, rest become replies
- Imports canned replies
- Disables com_ats + all ATS plugins after import

Dashboard shows blue import banner when Akeeba data is detected.
Both importers require core.admin (Super Users only).
Idempotent — safe to run multiple times.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 15:51:22 -05:00
gitea-actions[bot] 883e7c72f0 chore: update development channel 02.32.34 [skip ci] 2026-06-02 20:47:40 +00:00
gitea-actions[bot] cb33aabb0c chore(version): auto-bump 02.32.34 [skip ci] 2026-06-02 20:47:38 +00:00
Jonathan Miller fe87e9038a feat: Akeeba Ticket System importer
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (push) Has been skipped
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 29s
Universal: Auto Version Bump / Version Bump (push) Failing after 4s
Update Server / Update Server (push) Successful in 15s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Auto-detects ATS tables (#__ats_tickets, #__ats_posts, #__ats_cannedreplies)
and shows an import button on the helpdesk ticket list with counts.

Imports:
- Tickets: maps ATS status (O/P/C) to MokoWaaS statuses, priority 1-5
  to low/normal/high/urgent, preserves created_by/assigned_to/dates
- Posts: first post becomes ticket body, subsequent posts become replies
- Canned replies: imported with title and body (HTML stripped)
- Skips duplicates on re-import (idempotent)

ACL enforced: requires mokowaas.tickets permission.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 15:44:32 -05:00
Jonathan Miller c4b3892d9c fix: resolve merge conflict in offline plugin manifest [skip ci]
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 15:39:28 -05:00
Jonathan Miller adccf3bd2a feat: rename mokowaas_tos to mokowaas_offline + auto-migrate from standalone MokoJoomTOS
Renamed plugin element from mokowaas_tos to mokowaas_offline to better
reflect its purpose (offline bypass, not just TOS).

Added migrateStandalonePlugins() to package script:
- Detects if standalone mokojoomtos plugin is installed
- Copies its params (configured slugs) to mokowaas_offline
- Removes old extension record, update sites, and files
- Logs the migration

Future standalone-to-MokoWaaS migrations can be added to the
$migrations array.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 15:39:04 -05:00
gitea-actions[bot] bb03cd94d6 chore: update development channel 02.32.33 [skip ci] 2026-06-02 20:33:50 +00:00
gitea-actions[bot] 6ae5daffa2 chore(version): auto-bump 02.32.33 [skip ci] 2026-06-02 20:33:48 +00:00
Jonathan Miller 614b813056 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 0s
Universal: Auto Version Bump / Version Bump (push) Failing after 3s
Update Server / Update Server (push) Successful in 10s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 23s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
2026-06-02 15:33:39 -05:00
Jonathan Miller 33ce8b115c feat: wire ACL checks into all controller actions and views
Every view and AJAX task now checks its specific ACL permission:
- display() checks VIEW_ACL map per view name
- togglePlugin → mokowaas.plugins.toggle
- clearCache → mokowaas.cache
- installExtension → mokowaas.extensions
- saveHtaccess/generateHtaccess → mokowaas.htaccess
- createTicket → mokowaas.tickets.create
- addTicketReply/updateTicketStatus → mokowaas.tickets

Super admins (core.admin) always bypass all checks.
Refactored to use checkAcl/jsonResponse/jsonForbidden helpers.

Default ACL applied on dev:
- Super Users: all permissions
- Administrator: cache + ticket assign
- Manager: dashboard + tickets (view/create)
- Others: no access

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 15:33:33 -05:00
gitea-actions[bot] cf85a560e4 chore: update development channel 02.32.32 [skip ci] 2026-06-02 20:31:15 +00:00
gitea-actions[bot] 4684c4a1eb chore(version): auto-bump 02.32.32 [skip ci] 2026-06-02 20:31:13 +00:00
Jonathan Miller e69953ad17 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Failing after 5s
Update Server / Update Server (push) Successful in 14s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 28s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
2026-06-02 15:31:02 -05:00
Jonathan Miller b50661d9ee feat: component ACL permissions for MokoWaaS
Added access.xml with granular permissions:
- core.admin / core.manage (Joomla standard)
- mokowaas.dashboard — view the control panel
- mokowaas.extensions — install/uninstall Moko packages
- mokowaas.htaccess — edit .htaccess configuration
- mokowaas.tickets — view helpdesk tickets
- mokowaas.tickets.create — create new tickets
- mokowaas.tickets.assign — assign tickets to users
- mokowaas.plugins.toggle — enable/disable feature plugins
- mokowaas.cache — clear Joomla cache

Configurable per user group in Options > Permissions tab.
Also moved MokoWaaS to top of admin sidebar menu.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 15:30:57 -05:00
gitea-actions[bot] 1ce287cb2e chore: update development channel 02.32.31 [skip ci] 2026-06-02 20:25:39 +00:00
gitea-actions[bot] 6798a5da7e chore(version): auto-bump 02.32.31 [skip ci] 2026-06-02 20:25:37 +00:00
Jonathan Miller 7425c412fc Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Failing after 4s
Update Server / Update Server (push) Successful in 10s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 23s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
2026-06-02 15:25:14 -05:00
Jonathan Miller 7f64651517 feat: helpdesk / ticket system with SLA tracking
Full helpdesk built into com_mokowaas:

Ticket List View (view=tickets):
- Status summary cards (open, in progress, waiting, resolved, closed, SLA overdue)
- Filterable table (status, priority, category)
- SLA highlighting (red=overdue, yellow=at risk)
- New ticket modal with category, priority, description

Ticket Detail View (view=ticket):
- Conversation thread with replies
- Internal notes (staff-only, yellow highlight)
- Canned response selector
- Status action buttons (reopen, in progress, waiting, resolve, close)
- SLA response/resolution due with overdue indicators
- Ticket metadata sidebar

Model (TicketsModel):
- getTickets with filters, getTicket with replies
- createTicket with auto-assign from category + SLA deadlines
- addReply with SLA response tracking
- updateStatus with resolved/closed timestamps
- getCategories, getCannedResponses
- getStatusCounts, getOverdueTickets

Database:
- #__mokowaas_tickets (status, priority, SLA fields)
- #__mokowaas_ticket_replies (with internal notes)
- #__mokowaas_ticket_categories (SLA + auto-assign config)
- #__mokowaas_ticket_canned (response templates)
- 5 default categories with SLA presets

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 15:25:08 -05:00
gitea-actions[bot] db260008a2 chore: update development channel 02.32.30 [skip ci] 2026-06-02 19:59:14 +00:00
gitea-actions[bot] 8ea724116c chore(version): auto-bump 02.32.30 [skip ci] 2026-06-02 19:59:12 +00:00
Jonathan Miller 94b20b0c54 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Failing after 4s
Update Server / Update Server (push) Successful in 13s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 26s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
2026-06-02 14:58:59 -05:00
Jonathan Miller 0e5caf6b3f fix: move boot() session lifetime logic from core to firewall plugin
Core plugin's boot() called ipIsTrusted() which was deleted in the
cleanup. The session lifetime extension for trusted IPs is now in the
firewall plugin's boot() method where ipIsTrusted() still exists.

Firewall now implements BootableExtensionInterface.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 14:58:24 -05:00
gitea-actions[bot] 25e2c29e2e chore: update development channel 02.32.29 [skip ci] 2026-06-02 19:52:39 +00:00
gitea-actions[bot] b5eebb0acc chore(version): auto-bump 02.32.29 [skip ci] 2026-06-02 19:52:38 +00:00
Jonathan Miller f3d6ef948b Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Failing after 3s
Update Server / Update Server (push) Successful in 11s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 23s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
2026-06-02 14:52:17 -05:00
Jonathan Miller 1cdbfd035d feat: integrate MokoJoomTOS as Offline Bypass feature plugin + add OpenGraph to catalog
New plugin: plg_system_mokowaas_tos (Offline Bypass)
- Keeps configured pages accessible during offline mode
- SEF path matching + Itemid fallback
- Custom MenuslugField for multi-select menu items
- Include children option for parent menu matching
- Renamed from MokoJoomTOS to MokoWaaS Offline Bypass

Also:
- Added MokoJoomOpenGraph to extension manager catalog
- Added to package manifest, script.php, dashboard, cascade list

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 14:52:06 -05:00
gitea-actions[bot] b7d90f9b18 chore: update development channel 02.32.28 [skip ci] 2026-06-02 19:34:30 +00:00
gitea-actions[bot] 3be42ec37a chore(version): auto-bump 02.32.28 [skip ci] 2026-06-02 19:34:28 +00:00
Jonathan Miller 9565911089 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Failing after 4s
Update Server / Update Server (push) Successful in 14s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 30s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
2026-06-02 14:34:13 -05:00
Jonathan Miller 9a375740b9 refactor: remove 12 dead method bodies from core plugin (-607 lines)
Removed: resetAllHits, deleteAllVersions, warnMissingLicenseKey,
enforceDevMode, onDevModeDisabled, enforceHttps,
enforceAdminSessionTimeout, ipIsTrusted, enforceUploadRestrictions,
enforceAdminRestrictions, blockAccess, getHiddenMenuComponents

These methods were already unreachable after the call sites were
removed in the previous commit. The functionality lives in the
firewall, tenant, and devtools feature plugins.

Core plugin: 5366 -> 4759 lines

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 14:33:57 -05:00
gitea-actions[bot] a89d516623 chore: update development channel 02.32.27 [skip ci] 2026-06-02 19:31:59 +00:00
gitea-actions[bot] cf39c169d2 chore(version): auto-bump 02.32.27 [skip ci] 2026-06-02 19:31:57 +00:00
Jonathan Miller 1ad1f1c010 refactor: remove duplicate features from core plugin
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Failing after 3s
Update Server / Update Server (push) Successful in 10s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 23s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Removed from onAfterInitialise:
- enforceHttps (now in firewall plugin)
- enforceDevMode (now in devtools plugin)
- enforceAdminSessionTimeout (now in firewall plugin)
- enforceUploadRestrictions (now in firewall plugin)

Removed from onAfterRoute:
- warnMissingLicenseKey (deferred - licensing not ready)
- enforceAdminRestrictions (now in tenant plugin)

Removed event handlers:
- onPreprocessMenuItems (now in tenant plugin)
- onUserBeforeSave (now in firewall plugin)

Removed from onExtensionAfterSave:
- resetAllHits / deleteAllVersions (now in devtools plugin)

Removed param fieldsets from manifest:
- tenant_restrictions (now in tenant plugin)
- dev_mode, reset_hits, delete_versions (now in devtools plugin)
- force_https, session_timeout, trusted_ips, password_*, upload_*
  (now in firewall plugin)

Core plugin now only handles: branding, master user, emergency access,
site aliases, Atum branding, language overrides, health API, Grafana
heartbeat, demo banner, plugin protection, and content sync.

Settings migration handled by migrateFeatureParams() in script.php.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 14:31:42 -05:00
Jonathan Miller 1e6a255fab fix: duplicate Core card on dashboard - webservices plugin matching core metadata [skip ci]
The webservices plugin (element=mokowaas, folder=webservices) was
matching the PLUGIN_META entry for the core system plugin since the
lookup key was just the element name. Fixed by only matching system
and task plugins against PLUGIN_META; webservices/other folders use
folder_element as the key and fall through to guessPluginMeta.

Also includes auto-remove retired monitor plugin on package update.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 14:21:03 -05:00
Jonathan Miller a78178b5dd feat: auto-remove retired monitor plugin on package update
Added removeRetiredExtensions() to package postflight script.
On every install/update, it checks for retired extensions and
removes them (DB record + files). Currently retires:
- plg_system_mokowaas_monitor (merged into core in 02.32.00)

Future retired extensions can be added to the $retired array.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 14:17:21 -05:00
gitea-actions[bot] 79c3cfc1f0 chore: update development channel 02.32.26 [skip ci] 2026-06-02 19:14:30 +00:00
gitea-actions[bot] dac5c6c052 chore(version): auto-bump 02.32.26 [skip ci] 2026-06-02 19:14:28 +00:00
Jonathan Miller b4beaf5bc9 Merge branches 'dev' and 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Failing after 4s
Update Server / Update Server (push) Successful in 14s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 30s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
2026-06-02 14:14:14 -05:00
Jonathan Miller d563e2eac8 refactor: remove Health Monitor plugin - heartbeat built into core (#115)
The health monitor/Grafana heartbeat is mandatory infrastructure that
must always be active. The core system plugin (plg_system_mokowaas)
already has handleGrafanaProvisioning() and sendHeartbeat(), making
the separate monitor plugin redundant.

- Removed plg_system_mokowaas_monitor from package manifest
- Removed from script.php enable/protect lists
- Removed from cascade enable/disable logic
- Removed from dashboard plugin metadata
- Excluded from dashboard plugin discovery query
- Disabled on dev server (can be uninstalled manually)

Health monitoring is now entirely handled by the core plugin which
is protected and cannot be disabled.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 14:14:07 -05:00
Jonathan Miller 267beea8f9 chore: update MokoOnyx submodule to 02.17.00 stable
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 14:13:02 -05:00
gitea-actions[bot] 5a1a2f98b0 chore: update development channel 02.32.25 [skip ci] 2026-06-02 19:09:24 +00:00
gitea-actions[bot] ed4b06d330 chore(version): auto-bump 02.32.25 [skip ci] 2026-06-02 19:09:22 +00:00
Jonathan Miller 23dc30b5f9 Merge branches 'dev' and 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Failing after 4s
Update Server / Update Server (push) Successful in 11s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 23s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
2026-06-02 14:09:10 -05:00
Jonathan Miller af841ace19 fix: dashboard plugin cards - correct protected/enabled/configure-only states
- Task plugins (demo reset, content sync) show Enabled/Disabled badge
  instead of Protected — DB protected flag is for uninstall protection,
  not dashboard display
- DevTools shows Enabled badge only (configure-only, no toggle) since
  features are controlled inside the plugin settings
- Health Monitor marked as protected (cannot be disabled)
- Added explicit metadata for task plugins instead of relying on
  guessPluginMeta

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 14:09:01 -05:00
gitea-actions[bot] 8ce3452125 chore: update development channel 02.32.24 [skip ci] 2026-06-02 19:05:59 +00:00
gitea-actions[bot] 12e9115a6a chore(version): auto-bump 02.32.24 [skip ci] 2026-06-02 19:05:56 +00:00
Jonathan Miller eeb4822b37 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 3s
Universal: Auto Version Bump / Version Bump (push) Failing after 9s
Update Server / Update Server (push) Successful in 27s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 48s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
2026-06-02 14:05:31 -05:00
Jonathan Miller 0632981d88 feat: .htaccess / NginX configuration maker (#123)
GUI-based server config generator with toggle switches:

Security: directory listing, sensitive files, PHP in uploads,
server signature, clickjacking, MIME sniffing, XSS, TRACE/TRACK,
referrer policy, HSTS, CSP, permissions policy

Performance: GZip compression, browser caching with configurable
expiry times, ETag control

SEO: www/non-www redirect, index.php redirect, trailing slash

Features:
- Live preview updates as toggles change (AJAX)
- Save to disk with automatic .htaccess.mokowaas.bak backup
- Download generated config
- NginX equivalent tab
- Current file viewer tab
- Options persist in component params
- Added to sidebar submenu

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 14:05:25 -05:00
gitea-actions[bot] a013755ce4 chore(version): auto-bump 02.32.23 [skip ci] 2026-06-02 18:57:17 +00:00
Jonathan Miller 8240e693fb Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Failing after 4s
Update Server / Update Server (push) Failing after 10s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 23s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
2026-06-02 13:57:04 -05:00
Jonathan Miller 6a02a2b4e5 feat: add all quick action buttons to dashboard + Community Builder detection
8 action buttons in a 4-column grid:
- Clear Cache, Check Updates, Moko Extensions (primary)
- Global Check-in, View Logs, Scheduled Tasks, User Manager, Redirects (secondary)

User Manager auto-detects Community Builder — links to com_comprofiler
if installed, otherwise com_users.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 13:56:58 -05:00
gitea-actions[bot] 5d94419d9f chore: update development channel 02.32.22 [skip ci] 2026-06-02 18:54:54 +00:00
gitea-actions[bot] 3d3c918848 chore(version): auto-bump 02.32.22 [skip ci] 2026-06-02 18:54:53 +00:00
Jonathan Miller d0a3b5d6a4 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Failing after 3s
Update Server / Update Server (push) Successful in 9s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 21s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
2026-06-02 13:54:40 -05:00
Jonathan Miller 4f2aea75f5 feat: add admin submenus under MokoWaaS sidebar menu
Submenus:
- Dashboard (control panel)
- Moko Extensions (install packages)
- Feature Plugins (filtered plugin manager)
- Joomla Updates (com_installer update view)
- Global Check-in (com_checkin)
- Cache Management (com_cache)

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 13:54:30 -05:00
jmiller 7be52a964e chore: sync updates.xml 02.33.00 from main [skip ci] 2026-06-02 18:53:37 +00:00
92 changed files with 6985 additions and 2377 deletions
+1
View File
@@ -122,6 +122,7 @@ 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.32.21</version>
<version>02.32.38</version>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity>
<governance>
+283 -272
View File
@@ -1,272 +1,283 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/universal/auto-release.yml.template
# VERSION: 09.23.00
# BRIEF: Universal build & release detects platform from manifest.xml
#
# +========================================================================+
# | UNIVERSAL BUILD & RELEASE PIPELINE |
# +========================================================================+
# | |
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
# | |
# | Platform-specific: |
# | joomla: XML manifest, updates.xml, type-prefixed packages |
# | dolibarr: mod*.class.php, update.txt, dev version reset |
# | generic: README-only, no update stream |
# | |
# +========================================================================+
name: "Universal: Build & Release"
on:
pull_request:
types: [opened, closed]
branches:
- main
workflow_dispatch:
inputs:
action:
description: 'Action to perform'
required: false
type: choice
default: release
options:
- release
- promote-rc
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
permissions:
contents: write
jobs:
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────
promote-rc:
name: Promote to RC
runs-on: release
if: >-
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1
submodules: recursive
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
run: |
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
# Always fetch latest CLI tools — never use stale cache from previous runs
rm -rf /tmp/moko-platform-api
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
/tmp/moko-platform-api
cd /tmp/moko-platform-api
composer install --no-dev --no-interaction --quiet
- name: Rename branch to rc
run: |
php /tmp/moko-platform-api/cli/branch_rename.php \
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
--pr "${{ github.event.pull_request.number }}"
- name: Checkout rc and configure git
run: |
git fetch origin rc
git checkout rc
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
- name: Publish RC release
run: |
php /tmp/moko-platform-api/cli/release_publish.php \
--path . --stability rc --bump minor --branch rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}"
- name: Summary
if: always()
run: |
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
echo "Branch renamed to rc, minor bump, RC + lesser stream releases built, updates.xml synced" >> $GITHUB_STEP_SUMMARY
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
release:
name: Build & Release Pipeline
runs-on: release
if: >-
github.event.pull_request.merged == true ||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 0
submodules: recursive
- name: Configure git for bot pushes
run: |
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
run: |
# Ensure PHP + Composer are available
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
# Always fetch latest CLI tools — never use stale cache from previous runs
rm -rf /tmp/moko-platform-api
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
/tmp/moko-platform-api
cd /tmp/moko-platform-api
composer install --no-dev --no-interaction --quiet
- name: "Publish stable release"
run: |
php /tmp/moko-platform-api/cli/release_publish.php \
--path . --stability stable --bump minor --branch main \
--token "${{ secrets.MOKOGITEA_TOKEN }}"
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
- name: "Step 9: Mirror release to GitHub"
if: >-
steps.version.outputs.skip != 'true' &&
secrets.GH_MIRROR_TOKEN != ''
continue-on-error: true
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php /tmp/moko-platform-api/cli/release_mirror.php \
--version "$VERSION" --tag "$RELEASE_TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
--branch main 2>&1 || true
echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY
# -- STEP 10: Sync main branch to GitHub mirror ----------------------------
- name: "Step 10: Push main to GitHub mirror"
if: >-
steps.version.outputs.skip != 'true' &&
secrets.GH_MIRROR_TOKEN != ''
continue-on-error: true
run: |
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
git fetch origin main --depth=1
git push github origin/main:refs/heads/main --force 2>/dev/null \
&& echo "main branch pushed to GitHub mirror" \
|| echo "WARNING: GitHub mirror push failed"
- name: "Step 11: Delete rc branch and recreate dev from main"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Delete rc branch (ephemeral — created by promote-rc)
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/branches/rc" 2>/dev/null \
&& echo "Deleted rc branch" || echo "rc branch not found"
# Delete dev branch
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
# Recreate dev from main (now includes version bump + changelog promotion)
curl -sf -X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API_BASE}/branches" \
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY
- name: "Step 12: Create version branch from main"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
BRANCH_NAME="version/${VERSION}"
MAIN_SHA=$(git rev-parse HEAD)
# Delete old version branch if it exists (same version re-release)
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}"
# Create version/XX.YY.ZZ from main
curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed"
echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY
# -- Dolibarr post-release: Reset dev version -----------------------------
- name: "Post-release: Reset dev version"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php /tmp/moko-platform-api/cli/version_reset_dev.php \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
--branch dev --path . 2>&1 || true
# -- Summary --------------------------------------------------------------
- name: Pipeline Summary
if: always()
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
PLATFORM="${{ steps.platform.outputs.platform }}"
if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY
else
echo "" >> $GITHUB_STEP_SUMMARY
echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
fi
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/universal/auto-release.yml.template
# VERSION: 05.00.00
# BRIEF: Universal build & release detects platform from manifest.xml
#
# +========================================================================+
# | UNIVERSAL BUILD & RELEASE PIPELINE |
# +========================================================================+
# | |
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
# | |
# | Platform-specific: |
# | joomla: XML manifest, updates.xml, type-prefixed packages |
# | dolibarr: mod*.class.php, update.txt, dev version reset |
# | generic: README-only, no update stream |
# | |
# +========================================================================+
name: "Universal: Build & Release"
on:
pull_request:
types: [opened, closed]
branches:
- main
workflow_dispatch:
inputs:
action:
description: 'Action to perform'
required: false
type: choice
default: release
options:
- release
- promote-rc
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
permissions:
contents: write
jobs:
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────
promote-rc:
name: Promote to RC
runs-on: release
if: >-
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
run: |
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
# Always fetch latest CLI tools — never use stale cache from previous runs
rm -rf /tmp/moko-platform-api
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
/tmp/moko-platform-api
cd /tmp/moko-platform-api
composer install --no-dev --no-interaction --quiet
- name: Rename branch to rc
run: |
php /tmp/moko-platform-api/cli/branch_rename.php \
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
--pr "${{ github.event.pull_request.number }}"
- name: Checkout rc and configure git
run: |
git fetch origin rc
git checkout rc
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
- name: Publish RC release
run: |
php /tmp/moko-platform-api/cli/release_publish.php \
--path . --stability rc --bump minor --branch rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}"
- name: Summary
if: always()
run: |
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
echo "Branch renamed to rc, minor bump, RC + lesser stream releases built, updates.xml synced" >> $GITHUB_STEP_SUMMARY
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
release:
name: Build & Release Pipeline
runs-on: release
if: >-
github.event.pull_request.merged == true ||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 0
- name: Configure git for bot pushes
run: |
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
- name: Check for merge conflict markers
run: |
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
if [ -n "$CONFLICTS" ]; then
echo "::error::Merge conflict markers found — aborting release"
echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "No conflict markers found"
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
run: |
# Ensure PHP + Composer are available
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
# Always fetch latest CLI tools — never use stale cache from previous runs
rm -rf /tmp/moko-platform-api
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
/tmp/moko-platform-api
cd /tmp/moko-platform-api
composer install --no-dev --no-interaction --quiet
- name: "Publish stable release"
run: |
php /tmp/moko-platform-api/cli/release_publish.php \
--path . --stability stable --bump minor --branch main \
--token "${{ secrets.MOKOGITEA_TOKEN }}"
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
- name: "Step 9: Mirror release to GitHub"
if: >-
steps.version.outputs.skip != 'true' &&
secrets.GH_MIRROR_TOKEN != ''
continue-on-error: true
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php /tmp/moko-platform-api/cli/release_mirror.php \
--version "$VERSION" --tag "$RELEASE_TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
--branch main 2>&1 || true
echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY
# -- STEP 10: Sync main branch to GitHub mirror ----------------------------
- name: "Step 10: Push main to GitHub mirror"
if: >-
steps.version.outputs.skip != 'true' &&
secrets.GH_MIRROR_TOKEN != ''
continue-on-error: true
run: |
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
git fetch origin main --depth=1
git push github origin/main:refs/heads/main --force 2>/dev/null \
&& echo "main branch pushed to GitHub mirror" \
|| echo "WARNING: GitHub mirror push failed"
- name: "Step 11: Delete rc branch and recreate dev from main"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Delete rc branch (ephemeral — created by promote-rc)
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/branches/rc" 2>/dev/null \
&& echo "Deleted rc branch" || echo "rc branch not found"
# Delete dev branch
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
# Recreate dev from main (now includes version bump + changelog promotion)
curl -sf -X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API_BASE}/branches" \
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY
- name: "Step 12: Create version branch from main"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
BRANCH_NAME="version/${VERSION}"
MAIN_SHA=$(git rev-parse HEAD)
# Delete old version branch if it exists (same version re-release)
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}"
# Create version/XX.YY.ZZ from main
curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed"
echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY
# -- Dolibarr post-release: Reset dev version -----------------------------
- name: "Post-release: Reset dev version"
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php /tmp/moko-platform-api/cli/version_reset_dev.php \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
--branch dev --path . 2>&1 || true
# -- Summary --------------------------------------------------------------
- name: Pipeline Summary
if: always()
run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
PLATFORM="${{ steps.platform.outputs.platform }}"
if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY
else
echo "" >> $GITHUB_STEP_SUMMARY
echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
fi
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation
# VERSION: 02.32.21
# VERSION: 02.32.38
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
+277 -236
View File
@@ -1,236 +1,277 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.CI
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/universal/pr-check.yml.template
# VERSION: 09.23.00
# BRIEF: PR gate — branch policy + code validation before merge
name: "Universal: PR Check"
on:
pull_request:
types: [opened, synchronize, reopened, edited]
permissions:
contents: read
pull-requests: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ── Branch Policy ──────────────────────────────────────────────────────
branch-policy:
name: Branch Policy
runs-on: ubuntu-latest
steps:
- name: Check branch merge target
run: |
HEAD="${{ github.head_ref }}"
BASE="${{ github.base_ref }}"
echo "PR: ${HEAD} → ${BASE}"
ALLOWED=true
REASON=""
case "$HEAD" in
feature/*|feat/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Feature branches must target 'dev', not '${BASE}'"
fi
;;
fix/*|bugfix/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Fix branches must target 'dev', not '${BASE}'"
fi
;;
patch/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
ALLOWED=false
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
fi
;;
hotfix/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
fi
;;
rc)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="RC branch can only merge into 'main', not '${BASE}'"
fi
;;
dev)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Dev branch can only merge into 'main', not '${BASE}'"
fi
;;
esac
if [ "$ALLOWED" = false ]; then
echo "::error::${REASON}"
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Branch policy: OK (${HEAD} → ${BASE})"
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
# ── Code Validation ────────────────────────────────────────────────────
validate:
name: Validate PR
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Detect platform
id: platform
run: |
# Read platform from XML manifest (<platform> tag) or plain text fallback
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
[ -z "$PLATFORM" ] && PLATFORM="generic"
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
- name: Setup PHP
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
fi
- name: PHP syntax check
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
ERRORS=0
while IFS= read -r -d '' file; do
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
echo "PHP lint: ${ERRORS} error(s)"
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
- name: Validate platform manifest
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
if [ -z "$MANIFEST" ]; then
echo "::warning::No Joomla manifest found (WaaS site)"
exit 0
fi
echo "Manifest: ${MANIFEST}"
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
fi
for ELEMENT in name version description; do
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
done
echo "Joomla manifest valid"
;;
dolibarr)
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
if [ -z "$MOD_FILE" ]; then
echo "::error::No mod*.class.php found"
exit 1
fi
echo "Dolibarr module: ${MOD_FILE}"
;;
*)
echo "Generic platform — no manifest validation"
;;
esac
- name: Check update stream format
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
if [ -f "updates.xml" ]; then
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
fi
echo "updates.xml valid"
fi
;;
dolibarr)
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
;;
esac
- name: Check changelog has unreleased entry
run: |
if [ ! -f "CHANGELOG.md" ]; then
echo "::warning::No CHANGELOG.md found"
exit 0
fi
# Check for content under [Unreleased] section
if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then
echo "::error::CHANGELOG.md missing [Unreleased] section"
exit 1
fi
# Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased
UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true)
if [ "$UNRELEASED_CONTENT" -eq 0 ]; then
echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes."
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY
echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]"
- name: Verify package source
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
if [ ! -d "$SOURCE_DIR" ]; then
echo "::warning::No src/ or htdocs/ directory"
exit 0
fi
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
echo "Source: ${FILE_COUNT} files"
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
# ── Pre-Release RC Build ─────────────────────────────────────────────────
pre-release:
name: Build RC Package
runs-on: ubuntu-latest
needs: [branch-policy, validate]
steps:
- name: Trigger RC pre-release
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.head_ref }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.CI
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/universal/pr-check.yml.template
# VERSION: 09.23.00
# BRIEF: PR gate — branch policy + code validation before merge
name: "Universal: PR Check"
on:
pull_request:
types: [opened, synchronize, reopened, edited]
permissions:
contents: read
pull-requests: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ── Branch Policy ──────────────────────────────────────────────────────
branch-policy:
name: Branch Policy
runs-on: ubuntu-latest
steps:
- name: Check branch merge target
run: |
HEAD="${{ github.head_ref }}"
BASE="${{ github.base_ref }}"
echo "PR: ${HEAD} → ${BASE}"
ALLOWED=true
REASON=""
case "$HEAD" in
feature/*|feat/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Feature branches must target 'dev', not '${BASE}'"
fi
;;
fix/*|bugfix/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Fix branches must target 'dev', not '${BASE}'"
fi
;;
patch/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
ALLOWED=false
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
fi
;;
hotfix/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
fi
;;
rc)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="RC branch can only merge into 'main', not '${BASE}'"
fi
;;
dev)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Dev branch can only merge into 'main', not '${BASE}'"
fi
;;
esac
if [ "$ALLOWED" = false ]; then
echo "::error::${REASON}"
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Branch policy: OK (${HEAD} → ${BASE})"
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
# ── Code Validation ────────────────────────────────────────────────────
validate:
name: Validate PR
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check for merge conflict markers
run: |
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
if [ -n "$CONFLICTS" ]; then
echo "::error::Merge conflict markers found in source files"
echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "No conflict markers found"
- name: Detect platform
id: platform
run: |
# Read platform from XML manifest (<platform> tag) or plain text fallback
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
[ -z "$PLATFORM" ] && PLATFORM="generic"
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
- name: Setup PHP
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
fi
- name: PHP syntax check
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
ERRORS=0
while IFS= read -r -d '' file; do
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
echo "PHP lint: ${ERRORS} error(s)"
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
- name: Validate platform manifest
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
if [ -z "$MANIFEST" ]; then
echo "::warning::No Joomla manifest found (WaaS site)"
exit 0
fi
echo "Manifest: ${MANIFEST}"
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
fi
for ELEMENT in name version description; do
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
done
echo "Joomla manifest valid"
;;
dolibarr)
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
if [ -z "$MOD_FILE" ]; then
echo "::error::No mod*.class.php found"
exit 1
fi
echo "Dolibarr module: ${MOD_FILE}"
;;
*)
echo "Generic platform — no manifest validation"
;;
esac
- name: Check update stream format
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
if [ -f "updates.xml" ]; then
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
fi
echo "updates.xml valid"
fi
;;
dolibarr)
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
;;
esac
- name: Check changelog has unreleased entry
run: |
if [ ! -f "CHANGELOG.md" ]; then
echo "::warning::No CHANGELOG.md found"
exit 0
fi
# Check for content under [Unreleased] section
if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then
echo "::error::CHANGELOG.md missing [Unreleased] section"
exit 1
fi
# Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased
UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true)
if [ "$UNRELEASED_CONTENT" -eq 0 ]; then
echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes."
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY
echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]"
- name: Verify package source
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
if [ ! -d "$SOURCE_DIR" ]; then
echo "::warning::No src/ or htdocs/ directory"
exit 0
fi
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
echo "Source: ${FILE_COUNT} files"
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
# ── Pre-Release RC Build ─────────────────────────────────────────────────
pre-release:
name: Build RC Package
runs-on: ubuntu-latest
needs: [branch-policy, validate]
steps:
- name: Trigger RC pre-release
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.head_ref }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
# ── Issue Reporter ──────────────────────────────────────────────────────
report-issues:
name: Report Issues
runs-on: ubuntu-latest
needs: [branch-policy, validate]
if: >-
always() &&
needs.validate.result == 'failure'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
sparse-checkout: automation/ci-issue-reporter.sh
sparse-checkout-cone-mode: false
- name: "File issue for PR validation failure"
env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
chmod +x automation/ci-issue-reporter.sh
./automation/ci-issue-reporter.sh \
--gate "PR Validation" \
--workflow "PR Check" \
--severity error \
--details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed."
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -14,7 +14,7 @@
INGROUP: MokoWaaS.Documentation
REPO: https://github.com/mokoconsulting-tech/mokowaas
PATH: ./CHANGELOG.md
VERSION: 02.32.21
VERSION: 02.32.38
BRIEF: Version history using `Keep a Changelog`
-->
+1 -1
View File
@@ -14,7 +14,7 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Documentation
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.32.21
VERSION: 02.32.38
PATH: ./CODE_OF_CONDUCT.md
BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default
-->
+1 -1
View File
@@ -19,7 +19,7 @@
DEFGROUP: mokoconsulting-tech.MokoWaaSBrand
INGROUP: MokoStandards.Governance
REPO: https://github.com/mokoconsulting-tech/MokoWaaSBrand
VERSION: 02.32.21
VERSION: 02.32.38
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.32.21
VERSION: 02.32.38
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.32.21
VERSION: 02.32.38
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.32.21
VERSION: 02.32.38
BRIEF: Security vulnerability reporting and handling policy
-->
+237
View File
@@ -0,0 +1,237 @@
#!/usr/bin/env bash
# ============================================================================
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Automation.CI
# INGROUP: moko-platform.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /automation/ci-issue-reporter.sh
# VERSION: 09.23.00
# BRIEF: Creates or updates a Gitea issue when a CI gate fails.
# Deduplicates by searching open issues with the "ci-auto" label
# whose title matches the gate. If a matching issue exists, a comment
# is appended instead of opening a duplicate.
# ============================================================================
set -euo pipefail
# ── Defaults ────────────────────────────────────────────────────────────────
GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}"
GITEA_TOKEN="${GITEA_TOKEN:-}"
REPO="${GITHUB_REPOSITORY:-}"
RUN_URL="${GITHUB_SERVER_URL:-${GITEA_URL}}/${REPO}/actions/runs/${GITHUB_RUN_ID:-0}"
LABEL_NAME="ci-auto"
LABEL_COLOR="#e11d48"
GATE=""
DETAILS=""
SEVERITY="error"
WORKFLOW=""
# ── Parse arguments ─────────────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: ci-issue-reporter.sh --gate NAME --details TEXT [OPTIONS]
Required:
--gate CI gate name (e.g. "Code Quality", "Self-Health")
--details Human-readable failure description
Optional:
--severity "error" (default) or "warning"
--workflow Workflow name for the issue title
--repo owner/repo (default: \$GITHUB_REPOSITORY)
--run-url URL to the CI run (auto-detected from env)
--token Gitea API token (default: \$GITEA_TOKEN)
--url Gitea base URL (default: \$GITEA_URL)
EOF
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--gate) GATE="$2"; shift 2 ;;
--details) DETAILS="$2"; shift 2 ;;
--severity) SEVERITY="$2"; shift 2 ;;
--workflow) WORKFLOW="$2"; shift 2 ;;
--repo) REPO="$2"; shift 2 ;;
--run-url) RUN_URL="$2"; shift 2 ;;
--token) GITEA_TOKEN="$2"; shift 2 ;;
--url) GITEA_URL="$2"; shift 2 ;;
-h|--help) usage ;;
*) echo "Unknown option: $1"; usage ;;
esac
done
[[ -z "$GATE" ]] && { echo "ERROR: --gate is required"; usage; }
[[ -z "$DETAILS" ]] && { echo "ERROR: --details is required"; usage; }
[[ -z "$GITEA_TOKEN" ]] && { echo "ERROR: GITEA_TOKEN not set"; exit 1; }
[[ -z "$REPO" ]] && { echo "ERROR: GITHUB_REPOSITORY not set"; exit 1; }
API="${GITEA_URL}/api/v1/repos/${REPO}"
# ── Build title ─────────────────────────────────────────────────────────────
if [[ -n "$WORKFLOW" ]]; then
TITLE="[CI] ${WORKFLOW}: ${GATE} failed"
else
TITLE="[CI] ${GATE} failed"
fi
# ── Ensure label exists ─────────────────────────────────────────────────────
ensure_label() {
local exists
exists=$(curl -sf -o /dev/null -w '%{http_code}' \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null || echo "000")
if [[ "$exists" == "200" ]]; then
# Check if label already exists
local found
found=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null \
| grep -o "\"name\":\"${LABEL_NAME}\"" || true)
if [[ -z "$found" ]]; then
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/labels" \
-d "{\"name\":\"${LABEL_NAME}\",\"color\":\"${LABEL_COLOR}\",\"description\":\"Auto-created by CI issue reporter\"}" \
> /dev/null 2>&1 || true
fi
fi
}
# ── Search for existing open issue ──────────────────────────────────────────
find_existing_issue() {
# URL-encode the gate name for the query
local query
query=$(printf '%s' "[CI] ${GATE}" | sed 's/ /%20/g; s/\[/%5B/g; s/\]/%5D/g')
local response
response=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/issues?type=issues&state=open&labels=${LABEL_NAME}&q=${query}&limit=5" \
2>/dev/null || echo "[]")
# Extract the first matching issue number
echo "$response" \
| grep -oP '"number":\s*\K[0-9]+' \
| head -1
}
# ── Build issue body ────────────────────────────────────────────────────────
build_body() {
local severity_badge
if [[ "$SEVERITY" == "error" ]]; then
severity_badge="**Severity:** Error"
else
severity_badge="**Severity:** Warning"
fi
cat <<BODY
## CI Gate Failure: ${GATE}
${severity_badge}
**Workflow:** ${WORKFLOW:-unknown}
**Branch:** ${GITHUB_REF_NAME:-unknown}
**Commit:** \`${GITHUB_SHA:0:8}\`
**Run:** [View CI run](${RUN_URL})
### Details
${DETAILS}
### Resolution
Fix the issue described above and push a new commit. This issue will be closed automatically when the gate passes, or can be closed manually.
---
*Auto-created by [ci-issue-reporter](${GITEA_URL}/${REPO}/src/branch/main/automation/ci-issue-reporter.sh)*
BODY
}
# ── Build comment body (for existing issues) ────────────────────────────────
build_comment() {
cat <<COMMENT
### CI failure recurrence
**Branch:** ${GITHUB_REF_NAME:-unknown}
**Commit:** \`${GITHUB_SHA:0:8}\`
**Run:** [View CI run](${RUN_URL})
${DETAILS}
COMMENT
}
# ── Main ────────────────────────────────────────────────────────────────────
ensure_label
EXISTING=$(find_existing_issue)
if [[ -n "$EXISTING" ]]; then
# Append comment to existing issue
COMMENT_BODY=$(build_comment)
COMMENT_JSON=$(printf '%s' "$COMMENT_BODY" | python3 -c "
import sys, json
print(json.dumps({'body': sys.stdin.read()}))" 2>/dev/null)
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${EXISTING}/comments" \
-d "${COMMENT_JSON}" 2>/dev/null || echo "000")
if [[ "$HTTP" == "201" ]]; then
echo "Commented on existing issue #${EXISTING}"
else
echo "WARNING: Failed to comment on issue #${EXISTING} (HTTP ${HTTP})"
fi
else
# Create new issue
ISSUE_BODY=$(build_body)
ISSUE_JSON=$(python3 -c "
import sys, json
body = sys.stdin.read()
print(json.dumps({
'title': sys.argv[1],
'body': body,
'labels': []
}))" "$TITLE" <<< "$ISSUE_BODY" 2>/dev/null)
# Create the issue
RESPONSE=$(curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues" \
-d "${ISSUE_JSON}" 2>/dev/null || echo "{}")
ISSUE_NUM=$(echo "$RESPONSE" | grep -oP '"number":\s*\K[0-9]+' | head -1)
if [[ -n "$ISSUE_NUM" ]]; then
# Apply label (separate call — more reliable across Gitea versions)
LABEL_ID=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null \
| grep -oP "\"id\":\s*\K[0-9]+(?=[^}]*\"name\":\s*\"${LABEL_NAME}\")" \
| head -1 || true)
if [[ -n "$LABEL_ID" ]]; then
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${ISSUE_NUM}/labels" \
-d "{\"labels\":[${LABEL_ID}]}" \
> /dev/null 2>&1 || true
fi
echo "Created issue #${ISSUE_NUM}: ${TITLE}"
else
echo "WARNING: Failed to create issue"
echo "Response: ${RESPONSE}"
fi
fi
+2 -2
View File
@@ -11,13 +11,13 @@
INGROUP: MokoWaaS.Build
REPO: https://github.com/mokoconsulting-tech/mokowaas
FILE: build-guide.md
VERSION: 02.32.21
VERSION: 02.32.38
PATH: /docs/guides/
BRIEF: Build and packaging guide for the MokoWaaS system plugin
NOTE: Defines environment setup, repository layout, packaging rules, and release preparation
-->
# MokoWaaS Build Guide (VERSION: 02.32.21)
# MokoWaaS Build Guide (VERSION: 02.32.38)
## 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.32.21
VERSION: 02.32.38
PATH: /docs/guides/configuration-guide.md
BRIEF: Configuration guide for the MokoWaaS system plugin
NOTE: Defines plugin parameters, expected behaviors, and recommended defaults
-->
# MokoWaaS Configuration Guide (VERSION: 02.32.21)
# MokoWaaS Configuration Guide (VERSION: 02.32.38)
## 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.32.21
VERSION: 02.32.38
PATH: /docs/guides/installation-guide.md
BRIEF: Installation guide for the MokoWaaS system plugin
NOTE: First document in the guide set
-->
# MokoWaaS Installation Guide (VERSION: 02.32.21)
# MokoWaaS Installation Guide (VERSION: 02.32.38)
## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Guides
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.32.21
VERSION: 02.32.38
PATH: /docs/guides/operations-guide.md
BRIEF: Operational guide for administering and managing the MokoWaaS system plugin
NOTE: Defines lifecycle, responsibilities, and operational behaviors
-->
# MokoWaaS Operations Guide (VERSION: 02.32.21)
# MokoWaaS Operations Guide (VERSION: 02.32.38)
## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Guides
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.32.21
VERSION: 02.32.38
PATH: /docs/guides/rollback-and-recovery-guide.md
BRIEF: Rollback and recovery guide for restoring stable operation after plugin related incidents
NOTE: Completes the core guide set for WaaS plugin governance
-->
# MokoWaaS Rollback and Recovery Guide (VERSION: 02.32.21)
# MokoWaaS Rollback and Recovery Guide (VERSION: 02.32.38)
## Introduction
+2 -2
View File
@@ -7,13 +7,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Guides
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.32.21
VERSION: 02.32.38
PATH: /docs/guides/testing-guide.md
BRIEF: Testing guide for MokoWaaS v02.01.08
NOTE: Covers manual test procedures for language overrides, install/uninstall, and configuration
-->
# MokoWaaS Testing Guide (VERSION: 02.32.21)
# MokoWaaS Testing Guide (VERSION: 02.32.38)
## 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.32.21
VERSION: 02.32.38
PATH: /docs/guides/troubleshooting-guide.md
BRIEF: Troubleshooting guide for diagnosing and resolving issues related to the MokoWaaS plugin
NOTE: Designed for administrators and WaaS operations teams
-->
# MokoWaaS Troubleshooting Guide (VERSION: 02.32.21)
# MokoWaaS Troubleshooting Guide (VERSION: 02.32.38)
## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Guides
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.32.21
VERSION: 02.32.38
PATH: /docs/guides/upgrade-and-versioning-guide.md
BRIEF: Guide for updating, versioning, and maintaining the MokoWaaS plugin
NOTE: Defines release flow, version rules, and upgrade validation
-->
# MokoWaaS Upgrade and Versioning Guide (VERSION: 02.32.21)
# MokoWaaS Upgrade and Versioning Guide (VERSION: 02.32.38)
## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Documentation
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.32.21
VERSION: 02.32.38
PATH: /docs/index.md
BRIEF: Master index of all documentation for the MokoWaaS plugin
NOTE: Automatically maintained index for all guide canvases
-->
# MokoWaaS Documentation Index (VERSION: 02.32.21)
# MokoWaaS Documentation Index (VERSION: 02.32.38)
## 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.32.21
VERSION: 02.32.38
BRIEF: Baseline documentation for the MokoWaaS system plugin
NOTE: Foundational reference for internal and external stakeholders
-->
# MokoWaaS Plugin Overview (VERSION: 02.32.21)
# MokoWaaS Plugin Overview (VERSION: 02.32.38)
## 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.32.21
VERSION: 02.32.38
BRIEF: How this extension's Joomla update server file (update.xml) is managed
-->
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<access component="com_mokowaas">
<section name="component">
<action name="core.admin" title="JACTION_ADMIN" description="JACTION_ADMIN_COMPONENT_DESC" />
<action name="core.manage" title="JACTION_MANAGE" description="JACTION_MANAGE_COMPONENT_DESC" />
<action name="mokowaas.dashboard" title="COM_MOKOWAAS_ACL_DASHBOARD" description="COM_MOKOWAAS_ACL_DASHBOARD_DESC" />
<action name="mokowaas.extensions" title="COM_MOKOWAAS_ACL_EXTENSIONS" description="COM_MOKOWAAS_ACL_EXTENSIONS_DESC" />
<action name="mokowaas.htaccess" title="COM_MOKOWAAS_ACL_HTACCESS" description="COM_MOKOWAAS_ACL_HTACCESS_DESC" />
<action name="mokowaas.tickets" title="COM_MOKOWAAS_ACL_TICKETS" description="COM_MOKOWAAS_ACL_TICKETS_DESC" />
<action name="mokowaas.tickets.create" title="COM_MOKOWAAS_ACL_TICKETS_CREATE" description="COM_MOKOWAAS_ACL_TICKETS_CREATE_DESC" />
<action name="mokowaas.tickets.assign" title="COM_MOKOWAAS_ACL_TICKETS_ASSIGN" description="COM_MOKOWAAS_ACL_TICKETS_ASSIGN_DESC" />
<action name="mokowaas.plugins.toggle" title="COM_MOKOWAAS_ACL_PLUGINS_TOGGLE" description="COM_MOKOWAAS_ACL_PLUGINS_TOGGLE_DESC" />
<action name="mokowaas.cache" title="COM_MOKOWAAS_ACL_CACHE" description="COM_MOKOWAAS_ACL_CACHE_DESC" />
</section>
</access>
@@ -19,3 +19,23 @@ COM_MOKOWAAS_CACHE_CLEARED="Cache cleared successfully."
COM_MOKOWAAS_EXTENSIONS_TITLE="Moko Extensions"
COM_MOKOWAAS_EXTENSIONS_INFO="Install Moko Consulting Joomla packages from the official release server. Updates are handled through Joomla's native System > Update mechanism — each package registers its own update server."
COM_MOKOWAAS_EXTENSIONS_LINK="Moko Extensions"
COM_MOKOWAAS_HTACCESS_TITLE=".htaccess Maker"
COM_MOKOWAAS_TICKETS_TITLE="Helpdesk"
; ACL
COM_MOKOWAAS_ACL_DASHBOARD="View Dashboard"
COM_MOKOWAAS_ACL_DASHBOARD_DESC="Allow viewing the MokoWaaS control panel dashboard."
COM_MOKOWAAS_ACL_EXTENSIONS="Manage Extensions"
COM_MOKOWAAS_ACL_EXTENSIONS_DESC="Allow installing and uninstalling Moko extensions."
COM_MOKOWAAS_ACL_HTACCESS="Manage .htaccess"
COM_MOKOWAAS_ACL_HTACCESS_DESC="Allow editing and saving the .htaccess configuration."
COM_MOKOWAAS_ACL_TICKETS="View Tickets"
COM_MOKOWAAS_ACL_TICKETS_DESC="Allow viewing helpdesk tickets."
COM_MOKOWAAS_ACL_TICKETS_CREATE="Create Tickets"
COM_MOKOWAAS_ACL_TICKETS_CREATE_DESC="Allow creating new helpdesk tickets."
COM_MOKOWAAS_ACL_TICKETS_ASSIGN="Assign Tickets"
COM_MOKOWAAS_ACL_TICKETS_ASSIGN_DESC="Allow assigning tickets to other users."
COM_MOKOWAAS_ACL_PLUGINS_TOGGLE="Toggle Plugins"
COM_MOKOWAAS_ACL_PLUGINS_TOGGLE_DESC="Allow enabling and disabling MokoWaaS feature plugins."
COM_MOKOWAAS_ACL_CACHE="Clear Cache"
COM_MOKOWAAS_ACL_CACHE_DESC="Allow clearing the Joomla cache from the dashboard."
@@ -5,3 +5,11 @@
COM_MOKOWAAS="MokoWaaS"
COM_MOKOWAAS_DESCRIPTION="MokoWaaS admin dashboard and REST API. Control panel for managing site features, health monitoring, and remote management."
COM_MOKOWAAS_DASHBOARD_TITLE="MokoWaaS Control Panel"
COM_MOKOWAAS_MENU_DASHBOARD="Dashboard"
COM_MOKOWAAS_MENU_EXTENSIONS="Moko Extensions"
COM_MOKOWAAS_MENU_PLUGINS="Feature Plugins"
COM_MOKOWAAS_MENU_UPDATES="Joomla Updates"
COM_MOKOWAAS_MENU_CHECKIN="Global Check-in"
COM_MOKOWAAS_MENU_TICKETS="Helpdesk"
COM_MOKOWAAS_MENU_HTACCESS=".htaccess Maker"
COM_MOKOWAAS_MENU_CACHE="Cache Management"
@@ -0,0 +1,87 @@
--
-- MokoWaaS Helpdesk Tables
--
CREATE TABLE IF NOT EXISTS `#__mokowaas_ticket_categories` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`title` VARCHAR(255) NOT NULL,
`alias` VARCHAR(255) NOT NULL DEFAULT '',
`description` TEXT,
`auto_assign_user` INT DEFAULT NULL,
`sla_response_minutes` INT UNSIGNED NOT NULL DEFAULT 480,
`sla_resolution_minutes` INT UNSIGNED NOT NULL DEFAULT 2880,
`ordering` INT NOT NULL DEFAULT 0,
`published` TINYINT NOT NULL DEFAULT 1,
PRIMARY KEY (`id`),
KEY `idx_alias` (`alias`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokowaas_tickets` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`subject` VARCHAR(512) NOT NULL,
`body` TEXT NOT NULL,
`status` ENUM('open','in_progress','waiting','resolved','closed') NOT NULL DEFAULT 'open',
`priority` ENUM('low','normal','high','urgent') NOT NULL DEFAULT 'normal',
`category_id` INT UNSIGNED DEFAULT NULL,
`created_by` INT NOT NULL DEFAULT 0,
`assigned_to` INT DEFAULT NULL,
`created` DATETIME NOT NULL,
`modified` DATETIME DEFAULT NULL,
`resolved` DATETIME DEFAULT NULL,
`closed` DATETIME DEFAULT NULL,
`sla_response_due` DATETIME DEFAULT NULL,
`sla_resolution_due` DATETIME DEFAULT NULL,
`sla_responded` TINYINT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_status` (`status`),
KEY `idx_priority` (`priority`),
KEY `idx_assigned` (`assigned_to`),
KEY `idx_category` (`category_id`),
KEY `idx_created` (`created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokowaas_ticket_replies` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`ticket_id` INT UNSIGNED NOT NULL,
`user_id` INT NOT NULL DEFAULT 0,
`body` TEXT NOT NULL,
`is_internal` TINYINT NOT NULL DEFAULT 0,
`created` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_ticket` (`ticket_id`),
KEY `idx_created` (`created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokowaas_ticket_canned` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`title` VARCHAR(255) NOT NULL,
`body` TEXT NOT NULL,
`category_id` INT UNSIGNED DEFAULT NULL,
`ordering` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokowaas_ticket_automation` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`title` VARCHAR(255) NOT NULL,
`trigger_event` VARCHAR(50) NOT NULL DEFAULT 'ticket_created',
`conditions` TEXT NOT NULL DEFAULT '[]',
`actions` TEXT NOT NULL DEFAULT '[]',
`enabled` TINYINT NOT NULL DEFAULT 1,
`ordering` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Default automation rules
INSERT IGNORE INTO `#__mokowaas_ticket_automation` (`id`, `title`, `trigger_event`, `conditions`, `actions`, `enabled`, `ordering`) VALUES
(1, 'Auto-close resolved tickets after 7 days', 'scheduled', '[{"field":"status","op":"eq","value":"resolved"},{"field":"age_hours","op":"gt","value":"168"}]', '[{"type":"set_status","value":"closed"},{"type":"add_note","value":"Auto-closed after 7 days with no response."}]', 1, 1),
(2, 'Escalate urgent tickets with no response in 1 hour', 'scheduled', '[{"field":"priority","op":"eq","value":"urgent"},{"field":"sla_responded","op":"eq","value":"0"},{"field":"age_hours","op":"gt","value":"1"}]', '[{"type":"add_note","value":"SLA BREACH: Urgent ticket has no staff response after 1 hour."}]', 1, 2),
(3, 'Notify on high priority ticket creation', 'ticket_created', '[{"field":"priority","op":"in","value":"high,urgent"}]', '[{"type":"add_note","value":"High/urgent ticket created — requires immediate attention."}]', 1, 3);
-- Default categories
INSERT IGNORE INTO `#__mokowaas_ticket_categories` (`id`, `title`, `alias`, `description`, `sla_response_minutes`, `sla_resolution_minutes`, `ordering`) VALUES
(1, 'General Support', 'general-support', 'General questions and assistance', 480, 2880, 1),
(2, 'Bug Report', 'bug-report', 'Report a software bug or issue', 240, 1440, 2),
(3, 'Feature Request', 'feature-request', 'Request a new feature or enhancement', 1440, 10080, 3),
(4, 'Billing', 'billing', 'Billing, invoicing, and payment questions', 240, 1440, 4),
(5, 'Urgent / Outage', 'urgent-outage', 'Site down or critical issue', 60, 240, 5);
@@ -20,106 +20,318 @@ class DisplayController extends BaseController
{
protected $default_view = 'dashboard';
/**
* ACL map: view name => required permission.
*/
private const VIEW_ACL = [
'dashboard' => 'mokowaas.dashboard',
'extensions' => 'mokowaas.extensions',
'htaccess' => 'mokowaas.htaccess',
'tickets' => 'mokowaas.tickets',
'ticket' => 'mokowaas.tickets',
];
public function display($cachable = false, $urlparams = [])
{
$view = $this->input->get('view', $this->default_view);
$acl = self::VIEW_ACL[$view] ?? 'core.manage';
if (!$this->checkAcl($acl))
{
Factory::getApplication()->enqueueMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
Factory::getApplication()->redirect(Route::_('index.php', false));
return;
}
return parent::display($cachable, $urlparams);
}
/**
* Toggle a MokoWaaS feature plugin on or off.
*
* Expects POST with extension_id and enabled (0 or 1).
* Returns JSON response for AJAX calls.
*/
// ==================================================================
// Plugin toggle
// ==================================================================
public function togglePlugin()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.plugins.toggle'))
{
$this->jsonForbidden();
}
$app = Factory::getApplication();
$input = $app->getInput();
$model = $this->getModel('Dashboard');
$user = $app->getIdentity();
if (!$user->authorise('core.manage', 'com_plugins'))
{
$app->setHeader('Content-Type', 'application/json');
echo json_encode(['success' => false, 'message' => Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN')]);
$app->close();
}
$result = $model->togglePlugin(
$app->getInput()->getInt('extension_id', 0),
$app->getInput()->getInt('enabled', 0)
);
$extensionId = $input->getInt('extension_id', 0);
$enabled = $input->getInt('enabled', 0);
if (!$extensionId)
{
$app->setHeader('Content-Type', 'application/json');
echo json_encode(['success' => false, 'message' => 'Missing extension_id']);
$app->close();
}
$model = $this->getModel('Dashboard');
$result = $model->togglePlugin($extensionId, $enabled);
$app->setHeader('Content-Type', 'application/json');
echo json_encode($result);
$app->close();
$this->jsonResponse($result);
}
/**
* Clear the Joomla cache.
*/
// ==================================================================
// Cache
// ==================================================================
public function clearCache()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
$app = Factory::getApplication();
$user = $app->getIdentity();
if (!$user->authorise('core.admin'))
if (!$this->checkAcl('mokowaas.cache'))
{
$app->setHeader('Content-Type', 'application/json');
echo json_encode(['success' => false, 'message' => Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN')]);
$app->close();
$this->jsonForbidden();
}
$model = $this->getModel('Dashboard');
$result = $model->clearCache();
$app->setHeader('Content-Type', 'application/json');
echo json_encode($result);
$app->close();
$this->jsonResponse($this->getModel('Dashboard')->clearCache());
}
/**
* Install a Moko extension from a download URL.
*/
// ==================================================================
// Extensions
// ==================================================================
public function installExtension()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
$app = Factory::getApplication();
$user = $app->getIdentity();
if (!$user->authorise('core.admin'))
if (!$this->checkAcl('mokowaas.extensions'))
{
$app->setHeader('Content-Type', 'application/json');
echo json_encode(['success' => false, 'message' => Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN')]);
$app->close();
$this->jsonForbidden();
}
$downloadUrl = $app->getInput()->getString('download_url', '');
$downloadUrl = Factory::getApplication()->getInput()->getString('download_url', '');
if (empty($downloadUrl))
{
$app->setHeader('Content-Type', 'application/json');
echo json_encode(['success' => false, 'message' => 'Missing download URL.']);
$app->close();
$this->jsonResponse(['success' => false, 'message' => 'Missing download URL.']);
}
$model = $this->getModel('Extensions');
$result = $model->installFromUrl($downloadUrl);
$this->jsonResponse($this->getModel('Extensions')->installFromUrl($downloadUrl));
}
// ==================================================================
// .htaccess
// ==================================================================
public function saveHtaccess()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.htaccess'))
{
$this->jsonForbidden();
}
$app = Factory::getApplication();
$input = $app->getInput();
$model = $this->getModel('Htaccess');
$options = [];
foreach ($input->getArray() as $key => $value)
{
if (str_starts_with($key, 'opt_'))
{
$options[substr($key, 4)] = $value;
}
}
if (!empty($options))
{
$model->saveOptions($options);
}
$this->jsonResponse($model->saveHtaccess($input->getRaw('content', '')));
}
public function generateHtaccess()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.htaccess'))
{
$this->jsonForbidden();
}
$model = $this->getModel('Htaccess');
$options = Factory::getApplication()->getInput()->getArray();
$model->saveOptions($options);
$app = Factory::getApplication();
$app->setHeader('Content-Type', 'application/json');
echo json_encode($result);
echo json_encode([
'htaccess' => $model->generateHtaccess($options),
'nginx' => $model->generateNginx($options),
]);
$app->close();
}
// ==================================================================
// Tickets
// ==================================================================
public function createTicket()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.tickets.create'))
{
$this->jsonForbidden();
}
$input = Factory::getApplication()->getInput();
$this->jsonResponse($this->getModel('Tickets')->createTicket([
'subject' => $input->getString('subject', ''),
'body' => $input->getRaw('body', ''),
'priority' => $input->getString('priority', 'normal'),
'category_id' => $input->getInt('category_id', 0),
]));
}
public function addTicketReply()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.tickets'))
{
$this->jsonForbidden();
}
$input = Factory::getApplication()->getInput();
$this->jsonResponse($this->getModel('Tickets')->addReply(
$input->getInt('ticket_id', 0),
$input->getRaw('body', ''),
(bool) $input->getInt('is_internal', 0)
));
}
public function updateTicketStatus()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.tickets'))
{
$this->jsonForbidden();
}
$input = Factory::getApplication()->getInput();
$this->jsonResponse($this->getModel('Tickets')->updateStatus(
$input->getInt('ticket_id', 0),
$input->getString('status', '')
));
}
// ==================================================================
// KB Search
// ==================================================================
public function searchKb()
{
$query = Factory::getApplication()->getInput()->getString('q', '');
if (strlen($query) < 3)
{
$this->jsonResponse(['results' => []]);
}
try
{
$db = Factory::getDbo();
$escaped = $db->quote('%' . $db->escape($query, true) . '%');
$results = $db->setQuery(
$db->getQuery(true)
->select([$db->quoteName('l.title'), $db->quoteName('l.url'), $db->quoteName('l.description')])
->from($db->quoteName('#__finder_links', 'l'))
->where($db->quoteName('l.published') . ' = 1')
->where('(' . $db->quoteName('l.title') . ' LIKE ' . $escaped
. ' OR ' . $db->quoteName('l.description') . ' LIKE ' . $escaped . ')')
->order($db->quoteName('l.title') . ' ASC')
->setLimit(8)
)->loadObjectList() ?: [];
foreach ($results as $r)
{
$r->description = mb_substr(strip_tags($r->description ?? ''), 0, 150);
}
$this->jsonResponse(['results' => $results]);
}
catch (\Throwable $e)
{
$this->jsonResponse(['results' => []]);
}
}
// ==================================================================
// Importers
// ==================================================================
public function importAts()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.tickets'))
{
$this->jsonForbidden();
}
$this->jsonResponse($this->getModel('Import')->importAts());
}
public function importAdminTools()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin'))
{
$this->jsonForbidden();
}
$this->jsonResponse($this->getModel('Import')->importAdminTools());
}
// ==================================================================
// Helpers
// ==================================================================
/**
* Check a MokoWaaS ACL permission for the current user.
*/
private function checkAcl(string $action): bool
{
$user = Factory::getApplication()->getIdentity();
// Super admins always pass
if ($user->authorise('core.admin', 'com_mokowaas'))
{
return true;
}
return $user->authorise($action, 'com_mokowaas');
}
/**
* Send a JSON response and close.
*/
private function jsonResponse(array $data): void
{
$app = Factory::getApplication();
$app->setHeader('Content-Type', 'application/json');
echo json_encode($data);
$app->close();
}
/**
* Send a 403 JSON response and close.
*/
private function jsonForbidden(): void
{
$this->jsonResponse(['success' => false, 'message' => Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN')]);
}
}
@@ -22,39 +22,60 @@ class DashboardModel extends BaseDatabaseModel
*/
private const PLUGIN_META = [
'mokowaas' => [
'icon' => 'icon-shield-alt',
'category' => 'core',
'label' => 'Core — Branding & Identity',
'description' => 'White-label branding, master user enforcement, emergency access, and plugin protection.',
'protected' => true,
'icon' => 'icon-shield-alt',
'category' => 'core',
'label' => 'Core — Branding & Identity',
'description' => 'White-label branding, master user enforcement, emergency access, and plugin protection.',
'protected' => true,
'configure_only' => false,
],
'mokowaas_firewall' => [
'icon' => 'icon-lock',
'category' => 'security',
'label' => 'Firewall',
'description' => 'Web Application Firewall — SQLi, XSS, RFI, DFI shields, IP blocklist, admin secret URL, file protection.',
'protected' => false,
'icon' => 'icon-lock',
'category' => 'security',
'label' => 'Firewall',
'description' => 'Web Application Firewall — SQLi, XSS, RFI, DFI shields, IP blocklist, admin secret URL, file protection.',
'protected' => false,
'configure_only' => false,
],
'mokowaas_tenant' => [
'icon' => 'icon-users',
'category' => 'security',
'label' => 'Tenant Restrictions',
'description' => 'Installer, sysinfo, config, and template access restrictions for non-master users.',
'protected' => false,
'icon' => 'icon-users',
'category' => 'security',
'label' => 'Tenant Restrictions',
'description' => 'Installer, sysinfo, config, and template access restrictions for non-master users.',
'protected' => false,
'configure_only' => false,
],
'mokowaas_offline' => [
'icon' => 'icon-globe',
'category' => 'security',
'label' => 'Offline Bypass',
'description' => 'Keep selected pages (TOS, Privacy Policy) accessible during offline mode.',
'protected' => false,
'configure_only' => true,
],
'mokowaas_devtools' => [
'icon' => 'icon-wrench',
'category' => 'tools',
'label' => 'Developer Tools',
'description' => 'Dev mode, hit counter reset, content version cleanup.',
'protected' => false,
'icon' => 'icon-wrench',
'category' => 'tools',
'label' => 'Developer Tools',
'description' => 'Dev mode, hit counter reset, content version cleanup. Features are controlled inside the plugin settings.',
'protected' => false,
'configure_only' => true,
],
'mokowaas_monitor' => [
'icon' => 'icon-heartbeat',
'category' => 'monitoring',
'label' => 'Health Monitor',
'description' => 'Site health checks, Grafana heartbeat integration, and diagnostics.',
'protected' => false,
'mokowaasdemo' => [
'icon' => 'icon-undo',
'category' => 'content',
'label' => 'Demo Reset Task',
'description' => 'Scheduled demo site reset with content snapshots.',
'protected' => false,
'configure_only' => true,
],
'mokowaassync' => [
'icon' => 'icon-sync',
'category' => 'content',
'label' => 'Content Sync Task',
'description' => 'Scheduled content synchronisation to remote MokoWaaS sites.',
'protected' => false,
'configure_only' => true,
],
];
@@ -97,7 +118,8 @@ class DashboardModel extends BaseDatabaseModel
'(' . $db->quoteName('type') . ' = ' . $db->quote('plugin')
. ' AND ' . $db->quoteName('folder') . ' = ' . $db->quote('system')
. ' AND (' . $db->quoteName('element') . ' = ' . $db->quote('mokowaas')
. ' OR ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mokowaas\_%') . '))'
. ' OR ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mokowaas\_%') . ')'
. ' AND ' . $db->quoteName('element') . ' != ' . $db->quote('mokowaas_monitor') . ')'
// Webservices plugins
. ' OR (' . $db->quoteName('type') . ' = ' . $db->quote('plugin')
. ' AND ' . $db->quoteName('folder') . ' = ' . $db->quote('webservices')
@@ -120,8 +142,10 @@ class DashboardModel extends BaseDatabaseModel
$manifest = json_decode($row->manifest_cache ?? '{}');
$version = $manifest->version ?? '';
// Build a lookup key: system plugins use element, others use folder_element
$metaKey = $row->element;
// Only system plugins and task plugins match PLUGIN_META by element
$metaKey = ($row->folder === 'system' || $row->folder === 'task')
? $row->element
: $row->folder . '_' . $row->element;
$meta = self::PLUGIN_META[$metaKey] ?? null;
@@ -135,19 +159,20 @@ class DashboardModel extends BaseDatabaseModel
$categoryInfo = self::CATEGORIES[$categoryKey] ?? self::CATEGORIES['tools'];
$plugins[] = (object) [
'extension_id' => (int) $row->extension_id,
'name' => $meta['label'] ?? $row->name,
'element' => $row->element,
'folder' => $row->folder,
'type' => $row->type,
'enabled' => (int) $row->enabled,
'protected' => (int) $row->protected || ($meta['protected'] ?? false),
'version' => $version,
'icon' => $meta['icon'] ?? 'icon-puzzle-piece',
'category' => $categoryKey,
'extension_id' => (int) $row->extension_id,
'name' => $meta['label'] ?? $row->name,
'element' => $row->element,
'folder' => $row->folder,
'type' => $row->type,
'enabled' => (int) $row->enabled,
'protected' => (bool) ($meta['protected'] ?? false),
'configure_only' => (bool) ($meta['configure_only'] ?? false),
'version' => $version,
'icon' => $meta['icon'] ?? 'icon-puzzle-piece',
'category' => $categoryKey,
'categoryLabel' => $categoryInfo['label'],
'categoryBadge' => $categoryInfo['badge'],
'description' => $meta['description'] ?? '',
'description' => $meta['description'] ?? '',
];
}
@@ -96,6 +96,16 @@ class ExtensionsModel extends BaseDatabaseModel
'article' => 'https://mokoconsulting.tech/kb/mokogallerycalendar',
'protected' => false,
],
'MokoJoomOpenGraph' => [
'label' => 'MokoJoomOpenGraph',
'description' => 'Open Graph meta tags for articles, categories, and pages. Controls Facebook, Twitter, and LinkedIn link previews.',
'element' => 'pkg_mokoog',
'type' => 'package',
'icon' => 'icon-share-alt',
'category' => 'Components',
'article' => 'https://mokoconsulting.tech/kb/mokojoomopengraph',
'protected' => false,
],
];
private const GITEA_URL = 'https://git.mokoconsulting.tech';
@@ -0,0 +1,522 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Joomla\Registry\Registry;
/**
* .htaccess / NginX configuration generator.
*
* @since 02.32.00
*/
class HtaccessModel extends BaseDatabaseModel
{
private const DEFAULTS = [
// Security
'disable_directory_listing' => 1,
'block_sensitive_files' => 1,
'block_php_in_uploads' => 1,
'disable_server_signature' => 1,
'prevent_clickjacking' => 1,
'prevent_mime_sniffing' => 1,
'xss_protection' => 1,
'disable_trace_track' => 1,
'referrer_policy' => 'strict-origin-when-cross-origin',
'hsts_enabled' => 0,
'hsts_max_age' => 31536000,
'hsts_subdomains' => 0,
'csp_enabled' => 0,
'csp_value' => '',
'permissions_policy' => 0,
'permissions_value' => '',
// Performance
'enable_gzip' => 1,
'enable_expires' => 1,
'expires_html' => 3600,
'expires_css_js' => 2592000,
'expires_images' => 31536000,
'etag_control' => 0,
// SEO
'www_redirect' => 'off',
'redirect_index_php' => 1,
'force_trailing_slash' => 0,
// Custom
'custom_rules' => '',
];
/**
* Get saved options or defaults.
*/
public function getOptions(): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'));
$db->setQuery($query);
$params = new Registry($db->loadResult() ?? '{}');
$htaccess = $params->get('htaccess', null);
if ($htaccess)
{
return array_merge(self::DEFAULTS, (array) json_decode(json_encode($htaccess), true));
}
return self::DEFAULTS;
}
/**
* Save options to component params.
*/
public function saveOptions(array $options): array
{
try
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'));
$db->setQuery($query);
$params = new Registry($db->loadResult() ?? '{}');
$clean = [];
foreach (self::DEFAULTS as $key => $default)
{
$clean[$key] = $options[$key] ?? $default;
}
$params->set('htaccess', $clean);
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('params') . ' = ' . $db->quote($params->toString()))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
)->execute();
return ['success' => true, 'message' => 'Options saved.'];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Save failed: ' . $e->getMessage()];
}
}
/**
* Read the current .htaccess file.
*/
public function readCurrentHtaccess(): string
{
$path = JPATH_ROOT . '/.htaccess';
return file_exists($path) ? file_get_contents($path) : '';
}
/**
* Write .htaccess to disk with backup.
*/
public function saveHtaccess(string $content): array
{
$path = JPATH_ROOT . '/.htaccess';
$backup = JPATH_ROOT . '/.htaccess.mokowaas.bak';
try
{
// Backup existing
if (file_exists($path))
{
copy($path, $backup);
}
$result = file_put_contents($path, $content);
if ($result === false)
{
// Restore backup
if (file_exists($backup))
{
copy($backup, $path);
}
return ['success' => false, 'message' => '.htaccess is not writable.'];
}
return ['success' => true, 'message' => '.htaccess saved. Backup at .htaccess.mokowaas.bak'];
}
catch (\Throwable $e)
{
if (file_exists($backup))
{
@copy($backup, $path);
}
return ['success' => false, 'message' => 'Write failed: ' . $e->getMessage()];
}
}
/**
* Generate .htaccess content from options.
*/
public function generateHtaccess(array $opts): string
{
$lines = [];
$lines[] = '##';
$lines[] = '## MokoWaaS Generated .htaccess';
$lines[] = '## Generated: ' . gmdate('Y-m-d H:i:s') . ' UTC';
$lines[] = '## DO NOT EDIT — regenerate from MokoWaaS > .htaccess Maker';
$lines[] = '##';
$lines[] = '';
// --- Security ---
if (!empty($opts['disable_directory_listing']))
{
$lines[] = '## Disable directory listing';
$lines[] = 'Options -Indexes';
$lines[] = '';
}
if (!empty($opts['disable_server_signature']))
{
$lines[] = '## Hide server signature';
$lines[] = 'ServerSignature Off';
$lines[] = '<IfModule mod_headers.c>';
$lines[] = ' Header unset X-Powered-By';
$lines[] = ' Header unset Server';
$lines[] = '</IfModule>';
$lines[] = '';
}
if (!empty($opts['block_sensitive_files']))
{
$lines[] = '## Block access to sensitive files';
$lines[] = '<FilesMatch "^(htaccess\.txt|web\.config\.txt|configuration\.php-dist|README\.txt|LICENSE\.txt|joomla\.xml|robots\.txt\.dist)$">';
$lines[] = ' <IfModule mod_authz_core.c>';
$lines[] = ' Require all denied';
$lines[] = ' </IfModule>';
$lines[] = '</FilesMatch>';
$lines[] = '';
}
if (!empty($opts['block_php_in_uploads']))
{
$lines[] = '## Block PHP execution in upload directories';
$dirs = ['images', 'media', 'tmp', 'cache', 'logs'];
foreach ($dirs as $dir)
{
$lines[] = '<Directory "' . $dir . '">';
$lines[] = ' <FilesMatch "\.php$">';
$lines[] = ' <IfModule mod_authz_core.c>';
$lines[] = ' Require all denied';
$lines[] = ' </IfModule>';
$lines[] = ' </FilesMatch>';
$lines[] = '</Directory>';
}
$lines[] = '';
}
if (!empty($opts['disable_trace_track']))
{
$lines[] = '## Disable TRACE and TRACK methods';
$lines[] = '<IfModule mod_rewrite.c>';
$lines[] = ' RewriteEngine On';
$lines[] = ' RewriteCond %{REQUEST_METHOD} ^(TRACE|TRACK)';
$lines[] = ' RewriteRule .* - [F]';
$lines[] = '</IfModule>';
$lines[] = '';
}
// Security headers
$headers = [];
if (!empty($opts['prevent_clickjacking']))
{
$headers[] = ' Header always set X-Frame-Options "SAMEORIGIN"';
}
if (!empty($opts['prevent_mime_sniffing']))
{
$headers[] = ' Header always set X-Content-Type-Options "nosniff"';
}
if (!empty($opts['xss_protection']))
{
$headers[] = ' Header always set X-XSS-Protection "1; mode=block"';
}
$referrer = $opts['referrer_policy'] ?? '';
if (!empty($referrer) && $referrer !== 'off')
{
$headers[] = ' Header always set Referrer-Policy "' . $referrer . '"';
}
if (!empty($opts['hsts_enabled']))
{
$maxAge = (int) ($opts['hsts_max_age'] ?? 31536000);
$hsts = 'max-age=' . $maxAge;
if (!empty($opts['hsts_subdomains']))
{
$hsts .= '; includeSubDomains';
}
$headers[] = ' Header always set Strict-Transport-Security "' . $hsts . '"';
}
if (!empty($opts['csp_enabled']) && !empty($opts['csp_value']))
{
$headers[] = ' Header always set Content-Security-Policy "' . str_replace('"', '', $opts['csp_value']) . '"';
}
if (!empty($opts['permissions_policy']) && !empty($opts['permissions_value']))
{
$headers[] = ' Header always set Permissions-Policy "' . str_replace('"', '', $opts['permissions_value']) . '"';
}
if (!empty($headers))
{
$lines[] = '## Security headers';
$lines[] = '<IfModule mod_headers.c>';
$lines = array_merge($lines, $headers);
$lines[] = '</IfModule>';
$lines[] = '';
}
// --- Performance ---
if (!empty($opts['enable_gzip']))
{
$lines[] = '## GZip compression';
$lines[] = '<IfModule mod_deflate.c>';
$lines[] = ' AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css';
$lines[] = ' AddOutputFilterByType DEFLATE text/javascript application/javascript application/x-javascript';
$lines[] = ' AddOutputFilterByType DEFLATE application/json application/xml application/rss+xml';
$lines[] = ' AddOutputFilterByType DEFLATE image/svg+xml application/font-woff application/font-woff2';
$lines[] = '</IfModule>';
$lines[] = '';
}
if (!empty($opts['enable_expires']))
{
$html = (int) ($opts['expires_html'] ?? 3600);
$cssJs = (int) ($opts['expires_css_js'] ?? 2592000);
$images = (int) ($opts['expires_images'] ?? 31536000);
$lines[] = '## Browser caching';
$lines[] = '<IfModule mod_expires.c>';
$lines[] = ' ExpiresActive On';
$lines[] = ' ExpiresDefault "access plus ' . $html . ' seconds"';
$lines[] = ' ExpiresByType text/html "access plus ' . $html . ' seconds"';
$lines[] = ' ExpiresByType text/css "access plus ' . $cssJs . ' seconds"';
$lines[] = ' ExpiresByType text/javascript "access plus ' . $cssJs . ' seconds"';
$lines[] = ' ExpiresByType application/javascript "access plus ' . $cssJs . ' seconds"';
$lines[] = ' ExpiresByType image/jpeg "access plus ' . $images . ' seconds"';
$lines[] = ' ExpiresByType image/png "access plus ' . $images . ' seconds"';
$lines[] = ' ExpiresByType image/gif "access plus ' . $images . ' seconds"';
$lines[] = ' ExpiresByType image/webp "access plus ' . $images . ' seconds"';
$lines[] = ' ExpiresByType image/svg+xml "access plus ' . $images . ' seconds"';
$lines[] = ' ExpiresByType font/woff2 "access plus ' . $images . ' seconds"';
$lines[] = '</IfModule>';
$lines[] = '';
}
if (!empty($opts['etag_control']))
{
$lines[] = '## Disable ETags (for load-balanced environments)';
$lines[] = '<IfModule mod_headers.c>';
$lines[] = ' Header unset ETag';
$lines[] = '</IfModule>';
$lines[] = 'FileETag None';
$lines[] = '';
}
// --- SEO / Redirects ---
$wwwRedirect = $opts['www_redirect'] ?? 'off';
if ($wwwRedirect !== 'off' || !empty($opts['redirect_index_php']) || !empty($opts['force_trailing_slash']))
{
$lines[] = '## SEO redirects';
$lines[] = '<IfModule mod_rewrite.c>';
$lines[] = ' RewriteEngine On';
if ($wwwRedirect === 'www')
{
$lines[] = '';
$lines[] = ' ## Force www';
$lines[] = ' RewriteCond %{HTTP_HOST} !^www\. [NC]';
$lines[] = ' RewriteRule ^(.*)$ https://www.%{HTTP_HOST}/$1 [R=301,L]';
}
elseif ($wwwRedirect === 'non-www')
{
$lines[] = '';
$lines[] = ' ## Force non-www';
$lines[] = ' RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]';
$lines[] = ' RewriteRule ^(.*)$ https://%1/$1 [R=301,L]';
}
if (!empty($opts['redirect_index_php']))
{
$lines[] = '';
$lines[] = ' ## Redirect /index.php to root';
$lines[] = ' RewriteCond %{THE_REQUEST} ^[A-Z]{3,}\s/+index\.php\s [NC]';
$lines[] = ' RewriteRule ^index\.php/?(.*)$ /$1 [R=301,L]';
}
if (!empty($opts['force_trailing_slash']))
{
$lines[] = '';
$lines[] = ' ## Force trailing slash';
$lines[] = ' RewriteCond %{REQUEST_FILENAME} !-f';
$lines[] = ' RewriteCond %{REQUEST_URI} !(.*)/$';
$lines[] = ' RewriteRule ^(.*)$ /$1/ [R=301,L]';
}
$lines[] = '</IfModule>';
$lines[] = '';
}
// --- Custom rules ---
$custom = trim($opts['custom_rules'] ?? '');
if (!empty($custom))
{
$lines[] = '## Custom rules';
$lines[] = $custom;
$lines[] = '';
}
return implode("\n", $lines);
}
/**
* Generate equivalent NginX configuration snippet.
*/
public function generateNginx(array $opts): string
{
$lines = [];
$lines[] = '## MokoWaaS Generated NginX Configuration';
$lines[] = '## Add these directives inside your server { } block';
$lines[] = '';
if (!empty($opts['disable_directory_listing']))
{
$lines[] = '# Disable directory listing';
$lines[] = 'autoindex off;';
$lines[] = '';
}
if (!empty($opts['disable_server_signature']))
{
$lines[] = '# Hide server version';
$lines[] = 'server_tokens off;';
$lines[] = '';
}
if (!empty($opts['block_sensitive_files']))
{
$lines[] = '# Block sensitive files';
$lines[] = 'location ~* (htaccess\.txt|web\.config\.txt|configuration\.php-dist|README\.txt|LICENSE\.txt)$ {';
$lines[] = ' deny all;';
$lines[] = '}';
$lines[] = '';
}
if (!empty($opts['block_php_in_uploads']))
{
$lines[] = '# Block PHP in upload directories';
$lines[] = 'location ~* ^/(images|media|tmp|cache|logs)/.*\.php$ {';
$lines[] = ' deny all;';
$lines[] = '}';
$lines[] = '';
}
// Headers
$hdrs = [];
if (!empty($opts['prevent_clickjacking']))
{
$hdrs[] = 'add_header X-Frame-Options "SAMEORIGIN" always;';
}
if (!empty($opts['prevent_mime_sniffing']))
{
$hdrs[] = 'add_header X-Content-Type-Options "nosniff" always;';
}
if (!empty($opts['xss_protection']))
{
$hdrs[] = 'add_header X-XSS-Protection "1; mode=block" always;';
}
$referrer = $opts['referrer_policy'] ?? '';
if (!empty($referrer) && $referrer !== 'off')
{
$hdrs[] = 'add_header Referrer-Policy "' . $referrer . '" always;';
}
if (!empty($opts['hsts_enabled']))
{
$maxAge = (int) ($opts['hsts_max_age'] ?? 31536000);
$hsts = 'max-age=' . $maxAge;
if (!empty($opts['hsts_subdomains']))
{
$hsts .= '; includeSubDomains';
}
$hdrs[] = 'add_header Strict-Transport-Security "' . $hsts . '" always;';
}
if (!empty($hdrs))
{
$lines[] = '# Security headers';
$lines = array_merge($lines, $hdrs);
$lines[] = '';
}
if (!empty($opts['enable_gzip']))
{
$lines[] = '# GZip compression';
$lines[] = 'gzip on;';
$lines[] = 'gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;';
$lines[] = 'gzip_min_length 256;';
$lines[] = '';
}
if (!empty($opts['enable_expires']))
{
$cssJs = (int) ($opts['expires_css_js'] ?? 2592000);
$images = (int) ($opts['expires_images'] ?? 31536000);
$lines[] = '# Browser caching';
$lines[] = 'location ~* \.(css|js)$ {';
$lines[] = ' expires ' . round($cssJs / 86400) . 'd;';
$lines[] = '}';
$lines[] = 'location ~* \.(jpg|jpeg|png|gif|webp|svg|ico|woff2)$ {';
$lines[] = ' expires ' . round($images / 86400) . 'd;';
$lines[] = '}';
$lines[] = '';
}
return implode("\n", $lines);
}
}
@@ -0,0 +1,617 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Log\Log;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Joomla\Registry\Registry;
/**
* Importer for migrating from Akeeba Admin Tools to MokoWaaS.
*
* Reads Admin Tools WAF config, htaccess settings, IP blocklists,
* and security headers — maps them to MokoWaaS firewall plugin params
* and htaccess maker options.
*
* @since 02.32.00
*/
class ImportModel extends BaseDatabaseModel
{
/**
* Check if Admin Tools data is available for import.
*/
public function checkAdminToolsAvailable(): ?object
{
$db = $this->getDatabase();
try
{
$result = (object) [
'component' => false,
'waf_config' => false,
'storage' => false,
'ip_blocks' => 0,
];
// Check component
$db->setQuery("SELECT COUNT(*) FROM #__extensions WHERE element = 'com_admintools' AND type = 'component'");
$result->component = (int) $db->loadResult() > 0;
// Check WAF config table
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_wafconfig%'));
if ($db->loadResult())
{
$result->waf_config = true;
$db->setQuery('SELECT COUNT(*) FROM #__admintools_wafconfig');
$result->waf_settings = (int) $db->loadResult();
}
// Check storage table
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_storage%'));
if ($db->loadResult())
{
$result->storage = true;
}
// Check IP blocklist
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_ipblock%'));
if ($db->loadResult())
{
$db->setQuery('SELECT COUNT(*) FROM #__admintools_ipblock');
$result->ip_blocks = (int) $db->loadResult();
}
// Only available if at least one data source exists
if (!$result->component && !$result->waf_config && !$result->storage)
{
return null;
}
return $result;
}
catch (\Throwable $e)
{
return null;
}
}
/**
* Import Admin Tools settings into MokoWaaS.
*/
public function importAdminTools(): array
{
$db = $this->getDatabase();
$results = ['firewall' => 0, 'htaccess' => 0, 'ip_blocks' => 0, 'disabled' => false];
try
{
// ============================================================
// 1. Import WAF Config → Firewall plugin params
// ============================================================
$wafSettings = $this->readWafConfig($db);
$firewallParams = $this->mapWafToFirewall($wafSettings);
if (!empty($firewallParams))
{
$this->mergePluginParams('mokowaas_firewall', 'system', $firewallParams);
$results['firewall'] = \count($firewallParams);
}
// ============================================================
// 2. Import htaccess settings → component htaccess options
// ============================================================
$htaccessSettings = $this->readHtaccessConfig($db);
$htaccessOptions = $this->mapToHtaccess($htaccessSettings, $wafSettings);
if (!empty($htaccessOptions))
{
$this->mergeComponentHtaccessOptions($htaccessOptions);
$results['htaccess'] = \count($htaccessOptions);
}
// ============================================================
// 3. Import IP blocklist → Firewall IP deny list
// ============================================================
$ipBlocks = $this->readIpBlocklist($db);
if (!empty($ipBlocks))
{
$this->mergeIpBlocklist($ipBlocks);
$results['ip_blocks'] = \count($ipBlocks);
}
// ============================================================
// 4. Disable Admin Tools
// ============================================================
$this->disableAdminTools($db);
$results['disabled'] = true;
return [
'success' => true,
'message' => \sprintf(
'Imported %d firewall settings, %d htaccess options, %d blocked IPs from Admin Tools. Admin Tools has been disabled.',
$results['firewall'], $results['htaccess'], $results['ip_blocks']
),
'counts' => $results,
];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Import failed: ' . $e->getMessage()];
}
}
/**
* Read WAF config from #__admintools_wafconfig.
*/
private function readWafConfig($db): array
{
try
{
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_wafconfig%'));
if (!$db->loadResult())
{
return [];
}
$db->setQuery('SELECT * FROM #__admintools_wafconfig');
$rows = $db->loadObjectList() ?: [];
$config = [];
foreach ($rows as $row)
{
$key = $row->key ?? $row->option ?? '';
if (!empty($key))
{
$config[$key] = $row->value ?? '';
}
}
return $config;
}
catch (\Throwable $e)
{
return [];
}
}
/**
* Read htaccess/server config from #__admintools_storage.
*/
private function readHtaccessConfig($db): array
{
try
{
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_storage%'));
if (!$db->loadResult())
{
return [];
}
$db->setQuery('SELECT * FROM #__admintools_storage');
$rows = $db->loadObjectList() ?: [];
$config = [];
foreach ($rows as $row)
{
$key = $row->key ?? '';
if (!empty($key))
{
$config[$key] = $row->value ?? '';
}
}
return $config;
}
catch (\Throwable $e)
{
return [];
}
}
/**
* Read IP blocklist from #__admintools_ipblock.
*/
private function readIpBlocklist($db): array
{
try
{
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_ipblock%'));
if (!$db->loadResult())
{
return [];
}
$db->setQuery('SELECT ip FROM #__admintools_ipblock');
return $db->loadColumn() ?: [];
}
catch (\Throwable $e)
{
return [];
}
}
/**
* Map Admin Tools WAF config to MokoWaaS firewall plugin params.
*/
private function mapWafToFirewall(array $waf): array
{
$params = [];
// WAF shields
if (isset($waf['sqlishield']))
{
$params['waf_sqli'] = (int) $waf['sqlishield'] ? 1 : 0;
}
if (isset($waf['antispam']))
{
$params['waf_xss'] = (int) $waf['antispam'] ? 1 : 0;
}
if (isset($waf['muashield']))
{
$params['waf_mua'] = (int) $waf['muashield'] ? 1 : 0;
}
if (isset($waf['rfishield']))
{
$params['waf_rfi'] = (int) $waf['rfishield'] ? 1 : 0;
}
if (isset($waf['dfishield']))
{
$params['waf_dfi'] = (int) $waf['dfishield'] ? 1 : 0;
}
if (isset($waf['uploadshield']))
{
// Map to our block_direct_php
$params['block_direct_php'] = (int) $waf['uploadshield'] ? 1 : 0;
}
// Admin secret URL
if (!empty($waf['adminpw']))
{
$params['admin_secret'] = $waf['adminpw'];
}
// Block frontend super user login
if (isset($waf['nofesalogin']))
{
$params['block_frontend_superuser'] = (int) $waf['nofesalogin'] ? 1 : 0;
}
// Session timeout
if (!empty($waf['sessionshield']) && !empty($waf['session_timeout']))
{
$params['admin_session_timeout'] = (int) $waf['session_timeout'];
}
// Template switch blocking
if (isset($waf['tmpl']))
{
$params['block_template_switch'] = (int) $waf['tmpl'] ? 1 : 0;
}
// Blocked sensitive files
if (isset($waf['hogfiles']))
{
$params['block_sensitive_files'] = (int) $waf['hogfiles'] ? 1 : 0;
}
return $params;
}
/**
* Map Admin Tools config to MokoWaaS htaccess maker options.
*/
private function mapToHtaccess(array $storage, array $waf): array
{
$opts = [];
// Server signature
if (isset($waf['serversignature']) || isset($storage['serversignature']))
{
$opts['disable_server_signature'] = 1;
}
// Clickjacking
if (isset($waf['clickjacking']) || isset($storage['xframeoptions']))
{
$opts['prevent_clickjacking'] = 1;
}
// HSTS
if (!empty($storage['hstsheader']) || !empty($waf['hstsheader']))
{
$opts['hsts_enabled'] = 1;
if (!empty($storage['hstsmaxage']))
{
$opts['hsts_max_age'] = (int) $storage['hstsmaxage'];
}
}
// GZip
if (isset($storage['gzipcompression']))
{
$opts['enable_gzip'] = (int) $storage['gzipcompression'] ? 1 : 0;
}
// Expiration
if (isset($storage['exptime']))
{
$opts['enable_expires'] = (int) $storage['exptime'] ? 1 : 0;
}
// ETag
if (isset($storage['etagtype']))
{
$opts['etag_control'] = ($storage['etagtype'] === 'none') ? 1 : 0;
}
// Redirect www / non-www
if (!empty($storage['wwwredir']))
{
$map = ['www' => 'www', 'nowww' => 'non-www'];
$opts['www_redirect'] = $map[$storage['wwwredir']] ?? 'off';
}
// Directory listing
if (isset($storage['nodirlisting']))
{
$opts['disable_directory_listing'] = (int) $storage['nodirlisting'] ? 1 : 0;
}
// Block PHP in uploads
if (isset($storage['phpuploadexec']))
{
$opts['block_php_in_uploads'] = (int) $storage['phpuploadexec'] ? 1 : 0;
}
// Sensitive files
if (isset($storage['hogfiles']))
{
$opts['block_sensitive_files'] = (int) $storage['hogfiles'] ? 1 : 0;
}
return $opts;
}
/**
* Merge params into a plugin's existing params.
*/
private function mergePluginParams(string $element, string $folder, array $newParams): void
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote($element))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote($folder));
$db->setQuery($query);
$current = new Registry($db->loadResult() ?? '{}');
foreach ($newParams as $key => $value)
{
$current->set($key, $value);
}
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('params') . ' = ' . $db->quote($current->toString()))
->where($db->quoteName('element') . ' = ' . $db->quote($element))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote($folder))
)->execute();
}
/**
* Merge htaccess options into the component params.
*/
private function mergeComponentHtaccessOptions(array $options): void
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'));
$db->setQuery($query);
$params = new Registry($db->loadResult() ?? '{}');
$htaccess = (array) json_decode(json_encode($params->get('htaccess', new \stdClass())), true);
foreach ($options as $key => $value)
{
$htaccess[$key] = $value;
}
$params->set('htaccess', $htaccess);
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('params') . ' = ' . $db->quote($params->toString()))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
)->execute();
}
/**
* Merge imported IPs into the firewall IP blocklist.
*/
private function mergeIpBlocklist(array $ips): void
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall'))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('system'));
$db->setQuery($query);
$params = new Registry($db->loadResult() ?? '{}');
$blocklist = json_decode($params->get('ip_blocklist', '[]'), true) ?: [];
$existingIps = array_column($blocklist, 'ip');
foreach ($ips as $ip)
{
$ip = trim($ip);
if (empty($ip) || \in_array($ip, $existingIps, true))
{
continue;
}
$blocklist[] = [
'ip' => $ip,
'enabled' => '1',
'label' => 'Imported from Admin Tools',
];
}
$params->set('ip_blocklist', json_encode($blocklist));
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('params') . ' = ' . $db->quote($params->toString()))
->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall'))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
)->execute();
}
/**
* Disable Admin Tools component and plugins.
*/
private function disableAdminTools($db): void
{
// Disable component
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('enabled') . ' = 0')
->where($db->quoteName('element') . ' = ' . $db->quote('com_admintools'))
)->execute();
// Disable all Admin Tools plugins
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('enabled') . ' = 0')
->where($db->quoteName('element') . ' LIKE ' . $db->quote('admintools%'))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
)->execute();
Log::add('Admin Tools component and plugins disabled after MokoWaaS import', Log::INFO, 'mokowaas');
}
// ==================================================================
// Akeeba Ticket System Import
// ==================================================================
/**
* Check if ATS tables exist.
*/
public function checkAtsAvailable(): ?object
{
$db = $this->getDatabase();
try
{
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%ats_tickets%'));
if (!$db->loadResult())
{
return null;
}
$db->setQuery('SELECT COUNT(*) FROM #__ats_tickets');
$tickets = (int) $db->loadResult();
$db->setQuery('SELECT COUNT(*) FROM #__ats_posts');
$posts = (int) $db->loadResult();
return (object) ['tickets' => $tickets, 'posts' => $posts];
}
catch (\Throwable $e)
{
return null;
}
}
/**
* Import from Akeeba Ticket System and disable it.
*/
public function importAts(): array
{
// Delegate to TicketsModel for the actual import
$ticketsModel = new TicketsModel();
$result = $ticketsModel->importFromAts();
if (!$result['success'])
{
return $result;
}
// Disable ATS after successful import
try
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('enabled') . ' = 0')
->where($db->quoteName('element') . ' = ' . $db->quote('com_ats'))
)->execute();
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('enabled') . ' = 0')
->where($db->quoteName('element') . ' LIKE ' . $db->quote('ats%'))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
)->execute();
$result['message'] .= ' Akeeba Ticket System has been disabled.';
Log::add('Akeeba Ticket System disabled after MokoWaaS import', Log::INFO, 'mokowaas');
}
catch (\Throwable $e)
{
$result['message'] .= ' Warning: could not disable ATS: ' . $e->getMessage();
}
return $result;
}
}
@@ -0,0 +1,793 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
class TicketsModel extends BaseDatabaseModel
{
/**
* Get ticket list with filters.
*/
public function getTickets(array $filters = []): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select([
$db->quoteName('t.id'),
$db->quoteName('t.subject'),
$db->quoteName('t.status'),
$db->quoteName('t.priority'),
$db->quoteName('t.created'),
$db->quoteName('t.modified'),
$db->quoteName('t.sla_response_due'),
$db->quoteName('t.sla_resolution_due'),
$db->quoteName('t.sla_responded'),
$db->quoteName('c.title', 'category_title'),
$db->quoteName('u.name', 'created_by_name'),
$db->quoteName('a.name', 'assigned_to_name'),
])
->from($db->quoteName('#__mokowaas_tickets', 't'))
->leftJoin($db->quoteName('#__mokowaas_ticket_categories', 'c') . ' ON c.id = t.category_id')
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to');
if (!empty($filters['status']))
{
$query->where($db->quoteName('t.status') . ' = ' . $db->quote($filters['status']));
}
if (!empty($filters['priority']))
{
$query->where($db->quoteName('t.priority') . ' = ' . $db->quote($filters['priority']));
}
if (!empty($filters['assigned_to']))
{
$query->where($db->quoteName('t.assigned_to') . ' = ' . (int) $filters['assigned_to']);
}
if (!empty($filters['category_id']))
{
$query->where($db->quoteName('t.category_id') . ' = ' . (int) $filters['category_id']);
}
$query->order($db->quoteName('t.created') . ' DESC');
$query->setLimit(50);
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
/**
* Get a single ticket with all replies.
*/
public function getTicket(int $id): ?object
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select([
$db->quoteName('t') . '.*',
$db->quoteName('c.title', 'category_title'),
$db->quoteName('u.name', 'created_by_name'),
$db->quoteName('u.email', 'created_by_email'),
$db->quoteName('a.name', 'assigned_to_name'),
])
->from($db->quoteName('#__mokowaas_tickets', 't'))
->leftJoin($db->quoteName('#__mokowaas_ticket_categories', 'c') . ' ON c.id = t.category_id')
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to')
->where($db->quoteName('t.id') . ' = ' . $id);
$db->setQuery($query);
$ticket = $db->loadObject();
if (!$ticket)
{
return null;
}
// Load replies
$query = $db->getQuery(true)
->select([
$db->quoteName('r') . '.*',
$db->quoteName('u.name', 'user_name'),
])
->from($db->quoteName('#__mokowaas_ticket_replies', 'r'))
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id')
->where($db->quoteName('r.ticket_id') . ' = ' . $id)
->order($db->quoteName('r.created') . ' ASC');
$db->setQuery($query);
$ticket->replies = $db->loadObjectList() ?: [];
// Reply count
$ticket->reply_count = \count($ticket->replies);
return $ticket;
}
/**
* Create a new ticket.
*/
public function createTicket(array $data): array
{
try
{
$db = $this->getDatabase();
$user = Factory::getApplication()->getIdentity();
$now = Factory::getDate()->toSql();
$ticket = (object) [
'subject' => $data['subject'] ?? '',
'body' => $data['body'] ?? '',
'status' => 'open',
'priority' => $data['priority'] ?? 'normal',
'category_id' => (int) ($data['category_id'] ?? 0) ?: null,
'created_by' => $user->id,
'assigned_to' => (int) ($data['assigned_to'] ?? 0) ?: null,
'created' => $now,
'modified' => $now,
];
// Auto-assign from category
if (!$ticket->assigned_to && $ticket->category_id)
{
$query = $db->getQuery(true)
->select($db->quoteName('auto_assign_user'))
->from($db->quoteName('#__mokowaas_ticket_categories'))
->where($db->quoteName('id') . ' = ' . (int) $ticket->category_id);
$db->setQuery($query);
$autoAssign = (int) $db->loadResult();
if ($autoAssign)
{
$ticket->assigned_to = $autoAssign;
}
}
// SLA deadlines from category
if ($ticket->category_id)
{
$query = $db->getQuery(true)
->select([$db->quoteName('sla_response_minutes'), $db->quoteName('sla_resolution_minutes')])
->from($db->quoteName('#__mokowaas_ticket_categories'))
->where($db->quoteName('id') . ' = ' . (int) $ticket->category_id);
$db->setQuery($query);
$sla = $db->loadObject();
if ($sla)
{
$ticket->sla_response_due = Factory::getDate($now)->modify('+' . (int) $sla->sla_response_minutes . ' minutes')->toSql();
$ticket->sla_resolution_due = Factory::getDate($now)->modify('+' . (int) $sla->sla_resolution_minutes . ' minutes')->toSql();
}
}
$db->insertObject('#__mokowaas_tickets', $ticket, 'id');
// Run automation
$this->runAutomation('ticket_created', (int) $ticket->id);
return ['success' => true, 'message' => 'Ticket #' . $ticket->id . ' created.', 'id' => (int) $ticket->id];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()];
}
}
/**
* Add a reply to a ticket.
*/
public function addReply(int $ticketId, string $body, bool $isInternal = false): array
{
try
{
$db = $this->getDatabase();
$user = Factory::getApplication()->getIdentity();
$now = Factory::getDate()->toSql();
$reply = (object) [
'ticket_id' => $ticketId,
'user_id' => $user->id,
'body' => $body,
'is_internal' => $isInternal ? 1 : 0,
'created' => $now,
];
$db->insertObject('#__mokowaas_ticket_replies', $reply, 'id');
// Mark SLA as responded if first staff reply
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__mokowaas_tickets'))
->set($db->quoteName('modified') . ' = ' . $db->quote($now))
->set($db->quoteName('sla_responded') . ' = 1')
->where($db->quoteName('id') . ' = ' . $ticketId)
->where($db->quoteName('sla_responded') . ' = 0')
)->execute();
// Run automation
$this->runAutomation('ticket_replied', $ticketId);
return ['success' => true, 'message' => 'Reply added.'];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()];
}
}
/**
* Update ticket status.
*/
public function updateStatus(int $ticketId, string $status): array
{
$valid = ['open', 'in_progress', 'waiting', 'resolved', 'closed'];
if (!\in_array($status, $valid, true))
{
return ['success' => false, 'message' => 'Invalid status.'];
}
try
{
$db = $this->getDatabase();
$now = Factory::getDate()->toSql();
$sets = [
$db->quoteName('status') . ' = ' . $db->quote($status),
$db->quoteName('modified') . ' = ' . $db->quote($now),
];
if ($status === 'resolved')
{
$sets[] = $db->quoteName('resolved') . ' = ' . $db->quote($now);
}
if ($status === 'closed')
{
$sets[] = $db->quoteName('closed') . ' = ' . $db->quote($now);
}
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__mokowaas_tickets'))
->set($sets)
->where($db->quoteName('id') . ' = ' . $ticketId)
)->execute();
// Run automation
$this->runAutomation('status_changed', $ticketId);
return ['success' => true, 'message' => 'Status updated to ' . $status . '.'];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()];
}
}
/**
* Get all ticket categories.
*/
public function getCategories(): array
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_ticket_categories'))
->where($db->quoteName('published') . ' = 1')
->order($db->quoteName('ordering') . ' ASC')
);
return $db->loadObjectList() ?: [];
}
/**
* Get canned responses, optionally filtered by category.
*/
public function getCannedResponses(int $categoryId = 0): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_ticket_canned'))
->order($db->quoteName('ordering') . ' ASC');
if ($categoryId)
{
$query->where('(' . $db->quoteName('category_id') . ' = ' . $categoryId
. ' OR ' . $db->quoteName('category_id') . ' IS NULL)');
}
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
/**
* Get ticket counts by status for dashboard.
*/
public function getStatusCounts(): object
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select([$db->quoteName('status'), 'COUNT(*) AS ' . $db->quoteName('cnt')])
->from($db->quoteName('#__mokowaas_tickets'))
->group($db->quoteName('status'))
);
$rows = $db->loadObjectList('status') ?: [];
return (object) [
'open' => (int) ($rows['open']->cnt ?? 0),
'in_progress' => (int) ($rows['in_progress']->cnt ?? 0),
'waiting' => (int) ($rows['waiting']->cnt ?? 0),
'resolved' => (int) ($rows['resolved']->cnt ?? 0),
'closed' => (int) ($rows['closed']->cnt ?? 0),
'total' => array_sum(array_map(fn($r) => (int) $r->cnt, $rows)),
];
}
/**
* Get overdue tickets (SLA breached).
*/
public function getOverdueTickets(): array
{
$db = $this->getDatabase();
$now = Factory::getDate()->toSql();
$query = $db->getQuery(true)
->select([$db->quoteName('id'), $db->quoteName('subject'), $db->quoteName('priority'),
$db->quoteName('sla_response_due'), $db->quoteName('sla_resolution_due'), $db->quoteName('sla_responded')])
->from($db->quoteName('#__mokowaas_tickets'))
->where($db->quoteName('status') . ' NOT IN (' . $db->quote('resolved') . ',' . $db->quote('closed') . ')')
->where('((' . $db->quoteName('sla_response_due') . ' < ' . $db->quote($now) . ' AND ' . $db->quoteName('sla_responded') . ' = 0)'
. ' OR ' . $db->quoteName('sla_resolution_due') . ' < ' . $db->quote($now) . ')')
->order($db->quoteName('sla_resolution_due') . ' ASC');
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
// ==================================================================
// Automation Engine
// ==================================================================
/**
* Run automation rules for a specific trigger event against a ticket.
*
* @param string $event trigger_event: ticket_created, ticket_replied, status_changed, scheduled
* @param int $ticketId The ticket to evaluate
*/
public function runAutomation(string $event, int $ticketId): void
{
try
{
$db = $this->getDatabase();
// Load enabled rules for this event
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_ticket_automation'))
->where($db->quoteName('trigger_event') . ' = ' . $db->quote($event))
->where($db->quoteName('enabled') . ' = 1')
->order($db->quoteName('ordering') . ' ASC');
$db->setQuery($query);
$rules = $db->loadObjectList() ?: [];
if (empty($rules))
{
return;
}
// Load the ticket
$ticket = $this->getTicket($ticketId);
if (!$ticket)
{
return;
}
// Calculate age in hours
$ticket->age_hours = (time() - strtotime($ticket->created)) / 3600;
foreach ($rules as $rule)
{
$conditions = json_decode($rule->conditions, true) ?: [];
$actions = json_decode($rule->actions, true) ?: [];
if ($this->evaluateConditions($conditions, $ticket))
{
$this->executeActions($actions, $ticketId, $ticket);
}
}
}
catch (\Throwable $e)
{
\Joomla\CMS\Log\Log::add('Automation error: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokowaas');
}
}
/**
* Run all scheduled automation rules against all open tickets.
*/
public function runScheduledAutomation(): array
{
$db = $this->getDatabase();
$results = ['evaluated' => 0, 'acted' => 0];
// Load scheduled rules
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_ticket_automation'))
->where($db->quoteName('trigger_event') . ' = ' . $db->quote('scheduled'))
->where($db->quoteName('enabled') . ' = 1')
->order($db->quoteName('ordering') . ' ASC');
$db->setQuery($query);
$rules = $db->loadObjectList() ?: [];
if (empty($rules))
{
return $results;
}
// Load all non-closed tickets
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_tickets'))
->where($db->quoteName('status') . ' != ' . $db->quote('closed'));
$db->setQuery($query);
$tickets = $db->loadObjectList() ?: [];
foreach ($tickets as $ticket)
{
$ticket->age_hours = (time() - strtotime($ticket->created)) / 3600;
$ticket->replies = [];
$results['evaluated']++;
foreach ($rules as $rule)
{
$conditions = json_decode($rule->conditions, true) ?: [];
$actions = json_decode($rule->actions, true) ?: [];
if ($this->evaluateConditions($conditions, $ticket))
{
$this->executeActions($actions, (int) $ticket->id, $ticket);
$results['acted']++;
}
}
}
return $results;
}
/**
* Evaluate a set of conditions against a ticket (all must match).
*/
private function evaluateConditions(array $conditions, object $ticket): bool
{
foreach ($conditions as $cond)
{
$field = $cond['field'] ?? '';
$op = $cond['op'] ?? 'eq';
$value = $cond['value'] ?? '';
$ticketValue = $ticket->{$field} ?? null;
if ($ticketValue === null)
{
return false;
}
switch ($op)
{
case 'eq':
if ((string) $ticketValue !== (string) $value) return false;
break;
case 'neq':
if ((string) $ticketValue === (string) $value) return false;
break;
case 'gt':
if ((float) $ticketValue <= (float) $value) return false;
break;
case 'lt':
if ((float) $ticketValue >= (float) $value) return false;
break;
case 'in':
$list = array_map('trim', explode(',', $value));
if (!\in_array((string) $ticketValue, $list, true)) return false;
break;
case 'not_in':
$list = array_map('trim', explode(',', $value));
if (\in_array((string) $ticketValue, $list, true)) return false;
break;
default:
return false;
}
}
return true;
}
/**
* Execute a set of actions on a ticket.
*/
private function executeActions(array $actions, int $ticketId, object $ticket): void
{
$db = $this->getDatabase();
$now = Factory::getDate()->toSql();
foreach ($actions as $action)
{
$type = $action['type'] ?? '';
$value = $action['value'] ?? '';
switch ($type)
{
case 'set_status':
$this->updateStatus($ticketId, $value);
break;
case 'set_priority':
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__mokowaas_tickets'))
->set($db->quoteName('priority') . ' = ' . $db->quote($value))
->set($db->quoteName('modified') . ' = ' . $db->quote($now))
->where($db->quoteName('id') . ' = ' . $ticketId)
)->execute();
break;
case 'assign':
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__mokowaas_tickets'))
->set($db->quoteName('assigned_to') . ' = ' . (int) $value)
->set($db->quoteName('modified') . ' = ' . $db->quote($now))
->where($db->quoteName('id') . ' = ' . $ticketId)
)->execute();
break;
case 'add_note':
$reply = (object) [
'ticket_id' => $ticketId,
'user_id' => 0,
'body' => $value,
'is_internal' => 1,
'created' => $now,
];
$db->insertObject('#__mokowaas_ticket_replies', $reply, 'id');
break;
}
}
}
/**
* Get all automation rules.
*/
public function getAutomationRules(): array
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_ticket_automation'))
->order($db->quoteName('ordering') . ' ASC')
);
return $db->loadObjectList() ?: [];
}
// ==================================================================
// Akeeba Ticket System Importer
// ==================================================================
/**
* Check if ATS tables exist and return counts.
*/
public function checkAtsAvailable(): ?object
{
$db = $this->getDatabase();
try
{
$db->setQuery('SELECT COUNT(*) FROM #__ats_tickets');
$tickets = (int) $db->loadResult();
$db->setQuery('SELECT COUNT(*) FROM #__ats_posts');
$posts = (int) $db->loadResult();
$db->setQuery('SELECT COUNT(*) FROM #__ats_cannedreplies');
$canned = (int) $db->loadResult();
return (object) ['tickets' => $tickets, 'posts' => $posts, 'canned' => $canned];
}
catch (\Throwable $e)
{
return null;
}
}
/**
* Import tickets, replies, and canned responses from Akeeba Ticket System.
*/
public function importFromAts(): array
{
$db = $this->getDatabase();
$results = ['tickets' => 0, 'replies' => 0, 'canned' => 0, 'errors' => []];
try
{
// Status mapping: ATS → MokoWaaS
$statusMap = [
'O' => 'open', // Open
'P' => 'in_progress', // Pending (staff action needed)
'C' => 'closed', // Closed
];
// Numeric statuses 1-99 are custom — map to open
for ($i = 1; $i <= 99; $i++)
{
$statusMap[(string) $i] = 'open';
}
// Priority mapping: ATS uses 1-5, we use enum
$priorityMap = [
1 => 'low',
2 => 'low',
3 => 'normal',
4 => 'high',
5 => 'urgent',
];
// Category mapping: ATS uses Joomla categories, map catid to our category
// Default all to General Support (1) — admin can reassign later
$defaultCategory = 1;
// Import canned replies first
$db->setQuery('SELECT * FROM #__ats_cannedreplies WHERE enabled = 1 ORDER BY ordering');
$atsCanned = $db->loadObjectList() ?: [];
foreach ($atsCanned as $c)
{
$exists = $db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from('#__mokowaas_ticket_canned')
->where($db->quoteName('title') . ' = ' . $db->quote($c->title))
)->loadResult();
if ((int) $exists > 0)
{
continue;
}
$row = (object) [
'title' => $c->title,
'body' => strip_tags($c->reply ?? ''),
'category_id' => null,
'ordering' => (int) ($c->ordering ?? 0),
];
$db->insertObject('#__mokowaas_ticket_canned', $row, 'id');
$results['canned']++;
}
// Import tickets
$db->setQuery('SELECT * FROM #__ats_tickets ORDER BY id');
$atsTickets = $db->loadObjectList() ?: [];
$ticketIdMap = []; // ATS id → MokoWaaS id
foreach ($atsTickets as $t)
{
// Skip if already imported (check by subject + created_by + created)
$exists = $db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from('#__mokowaas_tickets')
->where($db->quoteName('subject') . ' = ' . $db->quote($t->title))
->where($db->quoteName('created_by') . ' = ' . (int) $t->created_by)
)->loadResult();
if ((int) $exists > 0)
{
continue;
}
$status = $statusMap[$t->status] ?? 'open';
$priority = $priorityMap[(int) $t->priority] ?? 'normal';
$row = (object) [
'subject' => $t->title,
'body' => '',
'status' => $status,
'priority' => $priority,
'category_id' => $defaultCategory,
'created_by' => (int) $t->created_by,
'assigned_to' => (int) $t->assigned_to ?: null,
'created' => $t->created ?: Factory::getDate()->toSql(),
'modified' => $t->modified,
'resolved' => $status === 'closed' ? ($t->modified ?: $t->created) : null,
'closed' => $status === 'closed' ? ($t->modified ?: $t->created) : null,
'sla_responded' => 1,
];
$db->insertObject('#__mokowaas_tickets', $row, 'id');
$ticketIdMap[(int) $t->id] = (int) $row->id;
$results['tickets']++;
}
// Import posts (replies)
$db->setQuery('SELECT * FROM #__ats_posts ORDER BY id');
$atsPosts = $db->loadObjectList() ?: [];
foreach ($atsPosts as $p)
{
$newTicketId = $ticketIdMap[(int) $p->ticket_id] ?? null;
if (!$newTicketId)
{
continue;
}
// First post of a ticket is usually the ticket body — update the ticket
if (empty($results['first_post_' . $p->ticket_id]))
{
$results['first_post_' . $p->ticket_id] = true;
$body = strip_tags($p->content_html ?? '');
$db->setQuery(
$db->getQuery(true)
->update('#__mokowaas_tickets')
->set($db->quoteName('body') . ' = ' . $db->quote($body))
->where($db->quoteName('id') . ' = ' . $newTicketId)
)->execute();
continue;
}
$row = (object) [
'ticket_id' => $newTicketId,
'user_id' => (int) $p->created_by,
'body' => strip_tags($p->content_html ?? ''),
'is_internal' => 0,
'created' => $p->created ?: Factory::getDate()->toSql(),
];
$db->insertObject('#__mokowaas_ticket_replies', $row, 'id');
$results['replies']++;
}
// Clean up temp tracking keys
foreach (array_keys($results) as $k)
{
if (str_starts_with($k, 'first_post_'))
{
unset($results[$k]);
}
}
return [
'success' => true,
'message' => sprintf(
'Imported %d tickets, %d replies, %d canned responses from Akeeba Ticket System.',
$results['tickets'], $results['replies'], $results['canned']
),
'counts' => $results,
];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Import failed: ' . $e->getMessage()];
}
}
}
@@ -35,6 +35,19 @@ class HtmlView extends BaseHtmlView
$this->checkedOutItems = $model->getCheckedOutItems();
$this->wafBlocks = $model->getRecentWafBlocks(5);
// Check for importable Akeeba data
try
{
$importModel = new \Moko\Component\MokoWaaS\Administrator\Model\ImportModel();
$this->adminToolsAvailable = $importModel->checkAdminToolsAvailable();
$this->atsAvailable = $importModel->checkAtsAvailable();
}
catch (\Throwable $e)
{
$this->adminToolsAvailable = null;
$this->atsAvailable = null;
}
$this->addToolbar();
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
@@ -0,0 +1,47 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\View\Htaccess;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $options = [];
protected $preview = '';
protected $nginxPreview = '';
protected $currentHtaccess = '';
public function display($tpl = null)
{
$model = $this->getModel();
$this->options = $model->getOptions();
$this->preview = $model->generateHtaccess($this->options);
$this->nginxPreview = $model->generateNginx($this->options);
$this->currentHtaccess = $model->readCurrentHtaccess();
$this->addToolbar();
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
parent::display($tpl);
}
protected function addToolbar(): void
{
ToolbarHelper::title(Text::_('COM_MOKOWAAS_HTACCESS_TITLE'), 'file-code');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas');
}
}
@@ -0,0 +1,53 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\View\Ticket;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $ticket;
protected $cannedResponses = [];
public function display($tpl = null)
{
$model = $this->getModel('Tickets');
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$this->ticket = $model->getTicket($id);
$this->cannedResponses = $model->getCannedResponses((int) ($this->ticket->category_id ?? 0));
if (!$this->ticket)
{
Factory::getApplication()->enqueueMessage('Ticket not found.', 'error');
Factory::getApplication()->redirect('index.php?option=com_mokowaas&view=tickets');
return;
}
$this->addToolbar();
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
parent::display($tpl);
}
protected function addToolbar(): void
{
$title = $this->ticket ? 'Ticket #' . $this->ticket->id . ' — ' . $this->ticket->subject : 'Ticket';
ToolbarHelper::title($title, 'headphones');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas&view=tickets');
}
}
@@ -0,0 +1,56 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\View\Tickets;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $tickets = [];
protected $categories = [];
protected $statusCounts;
protected $overdue = [];
protected $atsAvailable = null;
public function display($tpl = null)
{
$model = $this->getModel();
$app = Factory::getApplication();
$filters = [
'status' => $app->getInput()->getString('filter_status', ''),
'priority' => $app->getInput()->getString('filter_priority', ''),
'category_id' => $app->getInput()->getInt('filter_category', 0),
];
$this->tickets = $model->getTickets($filters);
$this->categories = $model->getCategories();
$this->statusCounts = $model->getStatusCounts();
$this->overdue = $model->getOverdueTickets();
$this->atsAvailable = $model->checkAtsAvailable();
$this->addToolbar();
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
parent::display($tpl);
}
protected function addToolbar(): void
{
ToolbarHelper::title(Text::_('COM_MOKOWAAS_TICKETS_TITLE'), 'headphones');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas');
}
}
@@ -19,6 +19,8 @@ $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();
@@ -66,26 +68,86 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
</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-12 col-md-4">
<div class="col-6 col-md-4 col-xl-3">
<button type="button" class="btn btn-outline-primary w-100 py-3" id="mokowaas-btn-cache"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.clearCache&format=json'); ?>"
data-token="<?php echo $token; ?>">
<span class="icon-trash d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
<?php echo Text::_('COM_MOKOWAAS_CLEAR_CACHE'); ?>
<span class="icon-bolt d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
Clear Cache
</button>
</div>
<div class="col-12 col-md-4">
<div class="col-6 col-md-4 col-xl-3">
<a href="<?php echo Route::_('index.php?option=com_installer&view=update'); ?>" class="btn btn-outline-primary w-100 py-3">
<span class="icon-refresh d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
<?php echo Text::_('COM_MOKOWAAS_CHECK_UPDATES'); ?>
Check Updates
</a>
</div>
<div class="col-12 col-md-4">
<div class="col-6 col-md-4 col-xl-3">
<a href="<?php echo Route::_('index.php?option=com_mokowaas&view=extensions'); ?>" class="btn btn-outline-primary w-100 py-3">
<span class="icon-puzzle-piece d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
<?php echo Text::_('COM_MOKOWAAS_EXTENSIONS_LINK'); ?>
Moko Extensions
</a>
</div>
<div class="col-6 col-md-4 col-xl-3">
<a href="<?php echo Route::_('index.php?option=com_checkin'); ?>" class="btn btn-outline-secondary w-100 py-3">
<span class="icon-check-square d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
Global Check-in
</a>
</div>
<div class="col-6 col-md-4 col-xl-3">
<a href="<?php echo Route::_('index.php?option=com_actionlogs'); ?>" class="btn btn-outline-secondary w-100 py-3">
<span class="icon-list d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
View Logs
</a>
</div>
<div class="col-6 col-md-4 col-xl-3">
<a href="<?php echo Route::_('index.php?option=com_scheduler'); ?>" class="btn btn-outline-secondary w-100 py-3">
<span class="icon-clock d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
Scheduled Tasks
</a>
</div>
<div class="col-6 col-md-4 col-xl-3">
<?php
// Use Community Builder if available, otherwise Joomla user manager
$useCB = file_exists(JPATH_ADMINISTRATOR . '/components/com_comprofiler/comprofiler.php');
$userUrl = $useCB
? Route::_('index.php?option=com_comprofiler&task=showusers')
: Route::_('index.php?option=com_users');
$userLabel = $useCB ? 'Community Builder' : 'User Manager';
?>
<a href="<?php echo $userUrl; ?>" class="btn btn-outline-secondary w-100 py-3">
<span class="icon-users d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
<?php echo $userLabel; ?>
</a>
</div>
<div class="col-6 col-md-4 col-xl-3">
<a href="<?php echo Route::_('index.php?option=com_redirect'); ?>" class="btn btn-outline-secondary w-100 py-3">
<span class="icon-arrow-right d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
Redirects
</a>
</div>
</div>
@@ -122,6 +184,10 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
<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"
@@ -0,0 +1,306 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$opts = $this->options;
$preview = $this->preview;
$nginx = $this->nginxPreview;
$current = $this->currentHtaccess;
$token = Session::getFormToken();
$saveUrl = Route::_('index.php?option=com_mokowaas&task=display.saveHtaccess&format=json');
$genUrl = Route::_('index.php?option=com_mokowaas&task=display.generateHtaccess&format=json');
// Helper for toggle switch
$sw = function($name, $label, $desc = '') use ($opts) {
$checked = !empty($opts[$name]) ? 'checked' : '';
echo '<div class="d-flex justify-content-between align-items-center py-2 border-bottom">';
echo '<div><strong>' . htmlspecialchars($label) . '</strong>';
if ($desc) echo '<br><small class="text-muted">' . htmlspecialchars($desc) . '</small>';
echo '</div>';
echo '<div class="form-check form-switch">';
echo '<input type="checkbox" class="form-check-input htaccess-opt" name="' . $name . '" id="htopt-' . $name . '" ' . $checked . '>';
echo '</div></div>';
};
?>
<div id="mokowaas-htaccess">
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item"><a class="nav-link active" data-bs-toggle="tab" href="#tab-htaccess" role="tab">.htaccess</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-nginx" role="tab">NginX</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-current" role="tab">Current File</a></li>
</ul>
<div class="tab-content">
<!-- .htaccess Tab -->
<div class="tab-pane fade show active" id="tab-htaccess" role="tabpanel">
<div class="row">
<!-- Left: Options -->
<div class="col-12 col-xl-6">
<div class="card mb-3">
<div class="card-header"><strong><span class="icon-shield-alt"></span> Security</strong></div>
<div class="card-body">
<?php $sw('disable_directory_listing', 'Disable Directory Listing', 'Options -Indexes'); ?>
<?php $sw('block_sensitive_files', 'Block Sensitive Files', 'htaccess.txt, configuration.php-dist, etc.'); ?>
<?php $sw('block_php_in_uploads', 'Block PHP in Uploads', 'Prevent .php in images/, media/, tmp/'); ?>
<?php $sw('disable_server_signature', 'Hide Server Signature', 'ServerSignature Off, remove X-Powered-By'); ?>
<?php $sw('prevent_clickjacking', 'Clickjacking Protection', 'X-Frame-Options: SAMEORIGIN'); ?>
<?php $sw('prevent_mime_sniffing', 'MIME Sniffing Prevention', 'X-Content-Type-Options: nosniff'); ?>
<?php $sw('xss_protection', 'XSS Protection Header', 'X-XSS-Protection: 1; mode=block'); ?>
<?php $sw('disable_trace_track', 'Disable TRACE/TRACK', 'Block HTTP TRACE and TRACK methods'); ?>
<div class="py-2 border-bottom">
<label class="form-label fw-bold" for="htopt-referrer_policy">Referrer Policy</label>
<select class="form-select form-select-sm htaccess-opt" name="referrer_policy" id="htopt-referrer_policy">
<option value="off" <?php echo ($opts['referrer_policy'] ?? '') === 'off' ? 'selected' : ''; ?>>Off</option>
<option value="no-referrer" <?php echo ($opts['referrer_policy'] ?? '') === 'no-referrer' ? 'selected' : ''; ?>>no-referrer</option>
<option value="same-origin" <?php echo ($opts['referrer_policy'] ?? '') === 'same-origin' ? 'selected' : ''; ?>>same-origin</option>
<option value="strict-origin-when-cross-origin" <?php echo ($opts['referrer_policy'] ?? '') === 'strict-origin-when-cross-origin' ? 'selected' : ''; ?>>strict-origin-when-cross-origin</option>
</select>
</div>
<?php $sw('hsts_enabled', 'HSTS (Force HTTPS)', 'Strict-Transport-Security header'); ?>
<div class="ps-4 <?php echo empty($opts['hsts_enabled']) ? 'd-none' : ''; ?>" id="hsts-options">
<div class="row g-2 py-2">
<div class="col-6">
<label class="form-label small">Max Age (seconds)</label>
<input type="number" class="form-control form-control-sm htaccess-opt" name="hsts_max_age" value="<?php echo (int) ($opts['hsts_max_age'] ?? 31536000); ?>">
</div>
<div class="col-6 d-flex align-items-end">
<div class="form-check">
<input type="checkbox" class="form-check-input htaccess-opt" name="hsts_subdomains" id="htopt-hsts_sub" <?php echo !empty($opts['hsts_subdomains']) ? 'checked' : ''; ?>>
<label class="form-check-label small" for="htopt-hsts_sub">Include Subdomains</label>
</div>
</div>
</div>
</div>
<?php $sw('csp_enabled', 'Content Security Policy', 'CSP header'); ?>
<div class="ps-4 <?php echo empty($opts['csp_enabled']) ? 'd-none' : ''; ?>" id="csp-options">
<textarea class="form-control form-control-sm htaccess-opt mt-1" name="csp_value" rows="2" placeholder="default-src 'self'; script-src 'self' 'unsafe-inline'"><?php echo htmlspecialchars($opts['csp_value'] ?? ''); ?></textarea>
</div>
<?php $sw('permissions_policy', 'Permissions Policy', 'Camera, microphone, geolocation controls'); ?>
<div class="ps-4 <?php echo empty($opts['permissions_policy']) ? 'd-none' : ''; ?>" id="perms-options">
<textarea class="form-control form-control-sm htaccess-opt mt-1" name="permissions_value" rows="2" placeholder="camera=(), microphone=(), geolocation=()"><?php echo htmlspecialchars($opts['permissions_value'] ?? ''); ?></textarea>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><strong><span class="icon-bolt"></span> Performance</strong></div>
<div class="card-body">
<?php $sw('enable_gzip', 'GZip Compression', 'Compress CSS, JS, HTML, XML, JSON'); ?>
<?php $sw('enable_expires', 'Browser Caching', 'Set expiration headers for static files'); ?>
<div class="ps-4 <?php echo empty($opts['enable_expires']) ? 'd-none' : ''; ?>" id="expires-options">
<div class="row g-2 py-2">
<div class="col-4">
<label class="form-label small">HTML (sec)</label>
<input type="number" class="form-control form-control-sm htaccess-opt" name="expires_html" value="<?php echo (int) ($opts['expires_html'] ?? 3600); ?>">
</div>
<div class="col-4">
<label class="form-label small">CSS/JS (sec)</label>
<input type="number" class="form-control form-control-sm htaccess-opt" name="expires_css_js" value="<?php echo (int) ($opts['expires_css_js'] ?? 2592000); ?>">
</div>
<div class="col-4">
<label class="form-label small">Images (sec)</label>
<input type="number" class="form-control form-control-sm htaccess-opt" name="expires_images" value="<?php echo (int) ($opts['expires_images'] ?? 31536000); ?>">
</div>
</div>
</div>
<?php $sw('etag_control', 'Disable ETags', 'For load-balanced environments'); ?>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><strong><span class="icon-search"></span> SEO / Redirects</strong></div>
<div class="card-body">
<div class="py-2 border-bottom">
<label class="form-label fw-bold">WWW Redirect</label>
<select class="form-select form-select-sm htaccess-opt" name="www_redirect">
<option value="off" <?php echo ($opts['www_redirect'] ?? 'off') === 'off' ? 'selected' : ''; ?>>Off</option>
<option value="www" <?php echo ($opts['www_redirect'] ?? '') === 'www' ? 'selected' : ''; ?>>Force www</option>
<option value="non-www" <?php echo ($opts['www_redirect'] ?? '') === 'non-www' ? 'selected' : ''; ?>>Force non-www</option>
</select>
</div>
<?php $sw('redirect_index_php', 'Redirect /index.php to /', 'SEO-friendly root redirect'); ?>
<?php $sw('force_trailing_slash', 'Force Trailing Slash', 'Append / to URLs without file extension'); ?>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><strong><span class="icon-code"></span> Custom Rules</strong></div>
<div class="card-body">
<textarea class="form-control htaccess-opt" name="custom_rules" rows="4" placeholder="# Add custom Apache directives here"><?php echo htmlspecialchars($opts['custom_rules'] ?? ''); ?></textarea>
</div>
</div>
</div>
<!-- Right: Preview -->
<div class="col-12 col-xl-6">
<div class="card mb-3 sticky-top" style="top:1rem">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Preview</strong>
<span class="badge bg-secondary" id="htaccess-line-count"><?php echo substr_count($preview, "\n"); ?> lines</span>
</div>
<div class="card-body p-0">
<textarea id="htaccess-preview" class="form-control font-monospace border-0" rows="30" readonly style="font-size:0.8rem;resize:none"><?php echo htmlspecialchars($preview); ?></textarea>
</div>
<div class="card-footer d-flex gap-2">
<button type="button" class="btn btn-primary" id="htaccess-save"
data-url="<?php echo $saveUrl; ?>" data-token="<?php echo $token; ?>">
<span class="icon-save"></span> Save to .htaccess
</button>
<button type="button" class="btn btn-outline-secondary" id="htaccess-download">
<span class="icon-download"></span> Download
</button>
</div>
</div>
</div>
</div>
</div>
<!-- NginX Tab -->
<div class="tab-pane fade" id="tab-nginx" role="tabpanel">
<div class="card">
<div class="card-header"><strong>NginX Configuration Snippet</strong></div>
<div class="card-body p-0">
<textarea id="nginx-preview" class="form-control font-monospace border-0" rows="25" readonly style="font-size:0.8rem;resize:none"><?php echo htmlspecialchars($nginx); ?></textarea>
</div>
<div class="card-footer">
<button type="button" class="btn btn-outline-secondary" id="nginx-download">
<span class="icon-download"></span> Download NginX Config
</button>
</div>
</div>
</div>
<!-- Current File Tab -->
<div class="tab-pane fade" id="tab-current" role="tabpanel">
<div class="card">
<div class="card-header"><strong>Current .htaccess on Disk</strong></div>
<div class="card-body p-0">
<textarea class="form-control font-monospace border-0" rows="25" readonly style="font-size:0.8rem;resize:none"><?php echo htmlspecialchars($current); ?></textarea>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var saveBtn = document.getElementById('htaccess-save');
var preview = document.getElementById('htaccess-preview');
var lineCount = document.getElementById('htaccess-line-count');
// Toggle sub-option visibility
document.getElementById('htopt-hsts_enabled').addEventListener('change', function() {
document.getElementById('hsts-options').classList.toggle('d-none', !this.checked);
});
document.getElementById('htopt-csp_enabled').addEventListener('change', function() {
document.getElementById('csp-options').classList.toggle('d-none', !this.checked);
});
document.getElementById('htopt-permissions_policy').addEventListener('change', function() {
document.getElementById('perms-options').classList.toggle('d-none', !this.checked);
});
document.getElementById('htopt-enable_expires') && document.getElementById('htopt-enable_expires').addEventListener('change', function() {
document.getElementById('expires-options').classList.toggle('d-none', !this.checked);
});
// Regenerate preview on any option change
document.querySelectorAll('.htaccess-opt').forEach(function(el) {
el.addEventListener('change', regeneratePreview);
el.addEventListener('input', regeneratePreview);
});
function collectOptions() {
var opts = {};
document.querySelectorAll('.htaccess-opt').forEach(function(el) {
if (el.type === 'checkbox') {
opts[el.name] = el.checked ? 1 : 0;
} else {
opts[el.name] = el.value;
}
});
return opts;
}
var debounceTimer;
function regeneratePreview() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(function() {
var fd = new FormData();
var opts = collectOptions();
for (var k in opts) fd.append(k, opts[k]);
fd.append('<?php echo $token; ?>', '1');
fetch('<?php echo $genUrl; ?>', {
method: 'POST', body: fd,
headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.htaccess) {
preview.value = d.htaccess;
lineCount.textContent = d.htaccess.split('\n').length + ' lines';
}
if (d.nginx) {
document.getElementById('nginx-preview').value = d.nginx;
}
});
}, 300);
}
// Save to disk
saveBtn.addEventListener('click', function() {
if (!confirm('This will overwrite your current .htaccess file. A backup will be created at .htaccess.mokowaas.bak. Continue?')) return;
var btn = this;
btn.disabled = true;
var fd = new FormData();
fd.append('content', preview.value);
var opts = collectOptions();
for (var k in opts) fd.append('opt_' + k, opts[k]);
fd.append('<?php echo $token; ?>', '1');
fetch(btn.dataset.url, {
method: 'POST', body: fd,
headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) Joomla.renderMessages({message: [d.message]});
else Joomla.renderMessages({error: [d.message]});
})
.catch(function() { Joomla.renderMessages({error: ['Network error']}); })
.finally(function() { btn.disabled = false; });
});
// Download buttons
function downloadText(content, filename) {
var blob = new Blob([content], {type: 'text/plain'});
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}
document.getElementById('htaccess-download').addEventListener('click', function() {
downloadText(preview.value, '.htaccess');
});
document.getElementById('nginx-download').addEventListener('click', function() {
downloadText(document.getElementById('nginx-preview').value, 'mokowaas-nginx.conf');
});
});
</script>
@@ -0,0 +1,198 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$t = $this->ticket;
$canned = $this->cannedResponses;
$token = Session::getFormToken();
$statusBadge = [
'open' => 'bg-primary', 'in_progress' => 'bg-info',
'waiting' => 'bg-warning text-dark', 'resolved' => 'bg-success', 'closed' => 'bg-secondary',
];
$priorityBadge = [
'low' => 'bg-secondary', 'normal' => 'bg-primary', 'high' => 'bg-warning text-dark', 'urgent' => 'bg-danger',
];
?>
<div id="mokowaas-ticket" class="row">
<!-- Left: conversation thread -->
<div class="col-12 col-xl-8">
<!-- Original ticket -->
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<strong><?php echo $this->escape($t->created_by_name); ?></strong>
<small class="text-muted ms-2"><?php echo HTMLHelper::_('date', $t->created, 'M d, Y H:i'); ?></small>
</div>
<span class="badge bg-dark">Original</span>
</div>
<div class="card-body"><?php echo nl2br($this->escape($t->body)); ?></div>
</div>
<!-- Replies -->
<?php foreach ($t->replies as $reply): ?>
<div class="card mb-3 <?php echo $reply->is_internal ? 'border-warning' : ''; ?>">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<strong><?php echo $this->escape($reply->user_name ?? 'System'); ?></strong>
<small class="text-muted ms-2"><?php echo HTMLHelper::_('date', $reply->created, 'M d, Y H:i'); ?></small>
</div>
<?php if ($reply->is_internal): ?>
<span class="badge bg-warning text-dark">Internal Note</span>
<?php endif; ?>
</div>
<div class="card-body"><?php echo nl2br($this->escape($reply->body)); ?></div>
</div>
<?php endforeach; ?>
<!-- Reply form -->
<div class="card mb-3">
<div class="card-header"><strong>Reply</strong></div>
<div class="card-body">
<?php if (!empty($canned)): ?>
<div class="mb-2">
<select class="form-select form-select-sm" id="canned-select">
<option value="">Insert canned response...</option>
<?php foreach ($canned as $c): ?>
<option value="<?php echo $this->escape($c->body); ?>"><?php echo $this->escape($c->title); ?></option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
<textarea id="reply-body" class="form-control mb-2" rows="5" placeholder="Type your reply..."></textarea>
<div class="d-flex gap-2">
<button type="button" class="btn btn-primary" id="btn-reply"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.addTicketReply&format=json'); ?>"
data-ticket="<?php echo $t->id; ?>" data-token="<?php echo $token; ?>">
<span class="icon-reply"></span> Send Reply
</button>
<button type="button" class="btn btn-outline-warning" id="btn-internal"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.addTicketReply&format=json'); ?>"
data-ticket="<?php echo $t->id; ?>" data-token="<?php echo $token; ?>" data-internal="1">
<span class="icon-eye-slash"></span> Internal Note
</button>
</div>
</div>
</div>
</div>
<!-- Right: ticket metadata -->
<div class="col-12 col-xl-4">
<div class="card mb-3">
<div class="card-header"><strong>Details</strong></div>
<div class="card-body">
<table class="table table-sm mb-0">
<tr><td class="text-muted">Status</td><td><span class="badge <?php echo $statusBadge[$t->status] ?? ''; ?>"><?php echo ucwords(str_replace('_', ' ', $t->status)); ?></span></td></tr>
<tr><td class="text-muted">Priority</td><td><span class="badge <?php echo $priorityBadge[$t->priority] ?? ''; ?>"><?php echo ucfirst($t->priority); ?></span></td></tr>
<tr><td class="text-muted">Category</td><td><?php echo $this->escape($t->category_title ?? '—'); ?></td></tr>
<tr><td class="text-muted">Created By</td><td><?php echo $this->escape($t->created_by_name); ?><br><small><?php echo $this->escape($t->created_by_email ?? ''); ?></small></td></tr>
<tr><td class="text-muted">Assigned To</td><td><?php echo $this->escape($t->assigned_to_name ?? 'Unassigned'); ?></td></tr>
<tr><td class="text-muted">Created</td><td><?php echo HTMLHelper::_('date', $t->created, 'M d, Y H:i'); ?></td></tr>
<?php if ($t->resolved): ?><tr><td class="text-muted">Resolved</td><td><?php echo HTMLHelper::_('date', $t->resolved, 'M d, Y H:i'); ?></td></tr><?php endif; ?>
<?php if ($t->closed): ?><tr><td class="text-muted">Closed</td><td><?php echo HTMLHelper::_('date', $t->closed, 'M d, Y H:i'); ?></td></tr><?php endif; ?>
<tr><td class="text-muted">Replies</td><td><?php echo $t->reply_count; ?></td></tr>
</table>
</div>
</div>
<!-- SLA -->
<?php if ($t->sla_response_due || $t->sla_resolution_due): ?>
<div class="card mb-3">
<div class="card-header"><strong>SLA</strong></div>
<div class="card-body">
<?php if ($t->sla_response_due): ?>
<div class="mb-2">
<small class="text-muted">Response Due</small><br>
<?php
$responseOverdue = !$t->sla_responded && strtotime($t->sla_response_due) < time();
?>
<span class="<?php echo $t->sla_responded ? 'text-success' : ($responseOverdue ? 'text-danger fw-bold' : ''); ?>">
<?php echo $t->sla_responded ? 'Responded' : HTMLHelper::_('date', $t->sla_response_due, 'M d H:i'); ?>
<?php echo $responseOverdue ? ' OVERDUE' : ''; ?>
</span>
</div>
<?php endif; ?>
<?php if ($t->sla_resolution_due): ?>
<div>
<small class="text-muted">Resolution Due</small><br>
<?php
$resolutionOverdue = !\in_array($t->status, ['resolved','closed']) && strtotime($t->sla_resolution_due) < time();
?>
<span class="<?php echo \in_array($t->status, ['resolved','closed']) ? 'text-success' : ($resolutionOverdue ? 'text-danger fw-bold' : ''); ?>">
<?php echo \in_array($t->status, ['resolved','closed']) ? 'Met' : HTMLHelper::_('date', $t->sla_resolution_due, 'M d H:i'); ?>
<?php echo $resolutionOverdue ? ' OVERDUE' : ''; ?>
</span>
</div>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<!-- Status actions -->
<div class="card mb-3">
<div class="card-header"><strong>Actions</strong></div>
<div class="card-body d-grid gap-2">
<?php foreach (['open' => 'Reopen', 'in_progress' => 'In Progress', 'waiting' => 'Waiting', 'resolved' => 'Resolve', 'closed' => 'Close'] as $s => $label): ?>
<?php if ($s !== $t->status): ?>
<button type="button" class="btn btn-sm btn-outline-<?php echo $s === 'closed' ? 'danger' : ($s === 'resolved' ? 'success' : 'secondary'); ?> btn-status"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.updateTicketStatus&format=json'); ?>"
data-ticket="<?php echo $t->id; ?>" data-status="<?php echo $s; ?>" data-token="<?php echo $token; ?>">
<?php echo $label; ?>
</button>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Canned response insert
var cannedSel = document.getElementById('canned-select');
if (cannedSel) {
cannedSel.addEventListener('change', function() {
if (this.value) { document.getElementById('reply-body').value = this.value; this.selectedIndex = 0; }
});
}
// Reply buttons
document.querySelectorAll('#btn-reply, #btn-internal').forEach(function(btn) {
btn.addEventListener('click', function() {
var body = document.getElementById('reply-body').value.trim();
if (!body) return;
var el = this;
el.disabled = true;
var fd = new FormData();
fd.append('ticket_id', el.dataset.ticket);
fd.append('body', body);
fd.append('is_internal', el.dataset.internal || '0');
fd.append(el.dataset.token, '1');
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){ if(d.success) location.reload(); else Joomla.renderMessages({error:[d.message]}); })
.finally(function(){ el.disabled = false; });
});
});
// Status buttons
document.querySelectorAll('.btn-status').forEach(function(btn) {
btn.addEventListener('click', function() {
var el = this;
el.disabled = true;
var fd = new FormData();
fd.append('ticket_id', el.dataset.ticket);
fd.append('status', el.dataset.status);
fd.append(el.dataset.token, '1');
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){ if(d.success) location.reload(); else Joomla.renderMessages({error:[d.message]}); })
.finally(function(){ el.disabled = false; });
});
});
});
</script>
@@ -0,0 +1,290 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$tickets = $this->tickets;
$categories = $this->categories;
$counts = $this->statusCounts;
$overdue = $this->overdue;
$atsAvailable = $this->atsAvailable;
$token = Session::getFormToken();
$statusBadge = [
'open' => 'bg-primary',
'in_progress' => 'bg-info',
'waiting' => 'bg-warning text-dark',
'resolved' => 'bg-success',
'closed' => 'bg-secondary',
];
$priorityBadge = [
'low' => 'bg-secondary',
'normal' => 'bg-primary',
'high' => 'bg-warning text-dark',
'urgent' => 'bg-danger',
];
?>
<div id="mokowaas-tickets">
<!-- Status summary cards -->
<div class="row g-3 mb-4">
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo $counts->open; ?></span><small class="text-muted">Open</small></div></div>
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo $counts->in_progress; ?></span><small class="text-muted">In Progress</small></div></div>
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo $counts->waiting; ?></span><small class="text-muted">Waiting</small></div></div>
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo $counts->resolved; ?></span><small class="text-muted">Resolved</small></div></div>
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo $counts->closed; ?></span><small class="text-muted">Closed</small></div></div>
<?php if (\count($overdue) > 0): ?>
<div class="col"><div class="card text-center p-2 border-danger"><span class="fw-bold fs-4 text-danger"><?php echo \count($overdue); ?></span><small class="text-danger">SLA Overdue</small></div></div>
<?php endif; ?>
</div>
<!-- New ticket + filters -->
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3">
<div class="d-flex gap-2">
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newTicketModal">
<span class="icon-plus"></span> New Ticket
</button>
<?php if ($atsAvailable): ?>
<button type="button" class="btn btn-outline-info" id="btn-import-ats"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.importAts&format=json'); ?>"
data-token="<?php echo $token; ?>"
data-tickets="<?php echo $atsAvailable->tickets; ?>"
data-posts="<?php echo $atsAvailable->posts; ?>">
<span class="icon-upload"></span> Import from Akeeba (<?php echo $atsAvailable->tickets; ?> tickets, <?php echo $atsAvailable->posts; ?> posts)
</button>
<?php endif; ?>
</div>
<form method="get" class="d-flex gap-2">
<input type="hidden" name="option" value="com_mokowaas">
<input type="hidden" name="view" value="tickets">
<select name="filter_status" class="form-select form-select-sm" style="width:auto" onchange="this.form.submit()">
<option value="">All Statuses</option>
<?php foreach (['open','in_progress','waiting','resolved','closed'] as $s): ?>
<option value="<?php echo $s; ?>" <?php echo Factory::getApplication()->getInput()->getString('filter_status') === $s ? 'selected' : ''; ?>><?php echo ucwords(str_replace('_', ' ', $s)); ?></option>
<?php endforeach; ?>
</select>
<select name="filter_priority" class="form-select form-select-sm" style="width:auto" onchange="this.form.submit()">
<option value="">All Priorities</option>
<?php foreach (['low','normal','high','urgent'] as $p): ?>
<option value="<?php echo $p; ?>" <?php echo Factory::getApplication()->getInput()->getString('filter_priority') === $p ? 'selected' : ''; ?>><?php echo ucfirst($p); ?></option>
<?php endforeach; ?>
</select>
</form>
</div>
<!-- Ticket table -->
<div class="card">
<div class="table-responsive">
<table class="table table-striped table-hover mb-0">
<thead>
<tr>
<th>#</th>
<th>Subject</th>
<th>Status</th>
<th>Priority</th>
<th>Category</th>
<th>Created By</th>
<th>Assigned To</th>
<th>Created</th>
<th>SLA</th>
</tr>
</thead>
<tbody>
<?php if (empty($tickets)): ?>
<tr><td colspan="9" class="text-center text-muted py-4">No tickets found.</td></tr>
<?php else: ?>
<?php foreach ($tickets as $t): ?>
<?php
$slaClass = '';
$now = time();
if ($t->sla_response_due && !$t->sla_responded && strtotime($t->sla_response_due) < $now) $slaClass = 'table-danger';
elseif ($t->sla_resolution_due && strtotime($t->sla_resolution_due) < $now && !\in_array($t->status, ['resolved','closed'])) $slaClass = 'table-danger';
elseif ($t->sla_response_due && !$t->sla_responded && strtotime($t->sla_response_due) < $now + 3600) $slaClass = 'table-warning';
?>
<tr class="<?php echo $slaClass; ?>">
<td><a href="<?php echo Route::_('index.php?option=com_mokowaas&view=ticket&id=' . $t->id); ?>"><?php echo $t->id; ?></a></td>
<td><a href="<?php echo Route::_('index.php?option=com_mokowaas&view=ticket&id=' . $t->id); ?>"><?php echo $this->escape(mb_substr($t->subject, 0, 60)); ?></a></td>
<td><span class="badge <?php echo $statusBadge[$t->status] ?? 'bg-secondary'; ?>"><?php echo ucwords(str_replace('_', ' ', $t->status)); ?></span></td>
<td><span class="badge <?php echo $priorityBadge[$t->priority] ?? 'bg-secondary'; ?>"><?php echo ucfirst($t->priority); ?></span></td>
<td><?php echo $this->escape($t->category_title ?? '—'); ?></td>
<td><?php echo $this->escape($t->created_by_name ?? ''); ?></td>
<td><?php echo $this->escape($t->assigned_to_name ?? '<em>Unassigned</em>'); ?></td>
<td class="small"><?php echo HTMLHelper::_('date', $t->created, 'M d H:i'); ?></td>
<td class="small">
<?php if ($t->sla_response_due && !$t->sla_responded): ?>
<span title="Response due"><?php echo HTMLHelper::_('date', $t->sla_response_due, 'M d H:i'); ?></span>
<?php elseif ($t->sla_resolution_due): ?>
<span title="Resolution due"><?php echo HTMLHelper::_('date', $t->sla_resolution_due, 'M d H:i'); ?></span>
<?php else: ?>—<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<!-- New Ticket Modal -->
<div class="modal fade" id="newTicketModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header"><h5 class="modal-title">New Ticket</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body">
<!-- KB Search step -->
<div id="modal-kb-step">
<label class="form-label fw-bold">What's the issue?</label>
<div class="input-group mb-3">
<input type="text" id="modal-kb-search" class="form-control" placeholder="Describe your issue to search for existing answers...">
<button type="button" class="btn btn-outline-primary" id="modal-kb-btn"><span class="icon-search"></span></button>
</div>
<div id="modal-kb-results" class="list-group mb-3 d-none"></div>
<button type="button" class="btn btn-primary" id="modal-show-form">
<span class="icon-plus"></span> Create Ticket
</button>
</div>
<!-- Ticket form step (hidden initially) -->
<form id="modal-ticket-form" class="d-none" method="post" action="<?php echo Route::_('index.php?option=com_mokowaas&task=display.createTicket&format=json'); ?>">
<div class="mb-3">
<label class="form-label">Subject</label>
<input type="text" name="subject" id="modal-subject" class="form-control" required>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">Category</label>
<select name="category_id" class="form-select">
<option value="">— Select —</option>
<?php foreach ($categories as $cat): ?>
<option value="<?php echo $cat->id; ?>"><?php echo $this->escape($cat->title); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Priority</label>
<select name="priority" class="form-select">
<option value="normal">Normal</option>
<option value="low">Low</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea name="body" class="form-control" rows="6" required></textarea>
</div>
<input type="hidden" name="<?php echo $token; ?>" value="1">
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"><span class="icon-plus"></span> Create Ticket</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
// Modal KB search
var modalSearch = document.getElementById('modal-kb-search');
var modalSearchBtn = document.getElementById('modal-kb-btn');
var modalResults = document.getElementById('modal-kb-results');
var modalShowForm = document.getElementById('modal-show-form');
var modalKbStep = document.getElementById('modal-kb-step');
var modalForm = document.getElementById('modal-ticket-form');
var modalSubject = document.getElementById('modal-subject');
function modalDoSearch() {
var q = modalSearch.value.trim();
if (q.length < 3) return;
fetch('<?php echo Route::_('index.php?option=com_mokowaas&task=display.searchKb&format=json'); ?>&q=' + encodeURIComponent(q), {
headers: {'X-Requested-With': 'XMLHttpRequest'}
}).then(function(r){return r.json()}).then(function(d) {
modalResults.textContent = '';
if (d.results && d.results.length > 0) {
d.results.forEach(function(item) {
var a = document.createElement('a');
a.href = item.url;
a.target = '_blank';
a.className = 'list-group-item list-group-item-action';
var strong = document.createElement('strong');
strong.textContent = item.title;
a.appendChild(strong);
if (item.description) {
a.appendChild(document.createElement('br'));
var small = document.createElement('small');
small.className = 'text-muted';
small.textContent = item.description;
a.appendChild(small);
}
modalResults.appendChild(a);
});
modalResults.classList.remove('d-none');
} else {
modalResults.classList.add('d-none');
}
});
}
if (modalSearchBtn) modalSearchBtn.addEventListener('click', modalDoSearch);
if (modalSearch) modalSearch.addEventListener('keydown', function(e) { if (e.key === 'Enter') { e.preventDefault(); modalDoSearch(); } });
// Show ticket form
if (modalShowForm) {
modalShowForm.addEventListener('click', function() {
modalKbStep.classList.add('d-none');
modalForm.classList.remove('d-none');
if (modalSearch.value && !modalSubject.value) modalSubject.value = modalSearch.value;
modalSubject.focus();
});
}
// Submit ticket from modal
if (modalForm) {
modalForm.addEventListener('submit', function(e) {
e.preventDefault();
var form = this;
var fd = new FormData(form);
fetch(form.action, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) { location.href = 'index.php?option=com_mokowaas&view=ticket&id=' + d.id; }
else { Joomla.renderMessages({error:[d.message]}); }
});
});
}
// Reset modal on close
document.getElementById('newTicketModal').addEventListener('hidden.bs.modal', function() {
modalKbStep.classList.remove('d-none');
modalForm.classList.add('d-none');
modalResults.classList.add('d-none');
modalSearch.value = '';
modalForm.reset();
});
// ATS Import
var atsBtn = document.getElementById('btn-import-ats');
if (atsBtn) {
atsBtn.addEventListener('click', function() {
var el = this;
if (!confirm('Import ' + el.dataset.tickets + ' tickets and ' + el.dataset.posts + ' posts from Akeeba Ticket System? Duplicates will be skipped.')) return;
el.disabled = true;
el.textContent = ' Importing...';
var fd = new FormData();
fd.append(el.dataset.token, '1');
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) { Joomla.renderMessages({message:[d.message]}); location.reload(); }
else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; el.textContent = 'Import Failed - Retry'; }
})
.catch(function(){ Joomla.renderMessages({error:['Network error']}); el.disabled = false; });
});
}
</script>
@@ -109,4 +109,26 @@ document.addEventListener('DOMContentLoaded', function () {
});
});
}
// Akeeba import buttons
['btn-import-admintools', 'btn-import-ats-dash'].forEach(function(id) {
var btn = document.getElementById(id);
if (!btn) return;
btn.addEventListener('click', function() {
var el = this;
if (!confirm('Import Akeeba data into MokoWaaS? Akeeba extensions will be disabled after import.')) return;
el.disabled = true;
var origText = el.textContent;
el.textContent = ' Importing...';
var fd = new FormData();
fd.append(el.dataset.token, '1');
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) { Joomla.renderMessages({message:[d.message]}); setTimeout(function(){location.reload()}, 2000); }
else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; el.textContent = origText; }
})
.catch(function(){ Joomla.renderMessages({error:['Network error']}); el.disabled = false; el.textContent = origText; });
});
});
});
+24 -1
View File
@@ -20,21 +20,44 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.32.21</version>
<version>02.32.38</version>
<description>MokoWaaS admin dashboard and REST API. Provides a control panel for managing MokoWaaS feature plugins, site health monitoring, and remote management endpoints.</description>
<namespace path="src">Moko\Component\MokoWaaS</namespace>
<administration>
<menu img="class:cogs">MokoWaaS</menu>
<submenu>
<menu link="option=com_mokowaas" img="class:cogs">COM_MOKOWAAS_MENU_DASHBOARD</menu>
<menu link="option=com_mokowaas&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_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>
<folder>language</folder>
<folder>services</folder>
<folder>sql</folder>
<folder>src</folder>
<folder>tmpl</folder>
</files>
</administration>
<files folder="site">
<folder>language</folder>
<folder>services</folder>
<folder>src</folder>
<folder>tmpl</folder>
</files>
<install>
<sql><file driver="mysql" charset="utf8">admin/sql/install.mysql.sql</file></sql>
</install>
<api>
<files folder="api">
<folder>src</folder>
@@ -0,0 +1,11 @@
; MokoWaaS Customer Portal - Language Strings
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GPL-3.0-or-later
COM_MOKOWAAS_PORTAL_TITLE="Support Portal"
COM_MOKOWAAS_PORTAL_MY_TICKETS="My Support Tickets"
COM_MOKOWAAS_PORTAL_NEW_TICKET="New Ticket"
COM_MOKOWAAS_PORTAL_SUBMIT="Submit Ticket"
COM_MOKOWAAS_PORTAL_REPLY="Send Reply"
COM_MOKOWAAS_PORTAL_NO_TICKETS="You haven't submitted any support tickets yet."
COM_MOKOWAAS_PORTAL_LOGIN_REQUIRED="Please log in to access the support portal."
@@ -0,0 +1,38 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas.site
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Dispatcher\ComponentDispatcherFactoryInterface;
use Joomla\CMS\Extension\ComponentInterface;
use Joomla\CMS\Extension\Service\Provider\ComponentDispatcherFactory;
use Joomla\CMS\Extension\Service\Provider\MVCFactory;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
return new class implements ServiceProviderInterface
{
public function register(Container $container): void
{
$container->registerServiceProvider(new MVCFactory('\\Moko\\Component\\MokoWaaS'));
$container->registerServiceProvider(new ComponentDispatcherFactory('\\Moko\\Component\\MokoWaaS'));
$container->set(
ComponentInterface::class,
function (Container $container) {
$component = new \Joomla\CMS\Extension\MVCComponent(
$container->get(ComponentDispatcherFactoryInterface::class)
);
$component->setMVCFactory($container->get(MVCFactoryInterface::class));
return $component;
}
);
}
};
@@ -0,0 +1,239 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas.site
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Site\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
class DisplayController extends BaseController
{
protected $default_view = 'tickets';
public function display($cachable = false, $urlparams = [])
{
$user = Factory::getApplication()->getIdentity();
if ($user->guest)
{
Factory::getApplication()->enqueueMessage('Please log in to access the support portal.', 'warning');
Factory::getApplication()->redirect(Route::_(
'index.php?option=com_users&view=login&return=' . base64_encode('index.php?option=com_mokowaas&view=tickets'),
false
));
return;
}
return parent::display($cachable, $urlparams);
}
/**
* Submit a new ticket.
*/
public function submitTicket()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
$user = Factory::getApplication()->getIdentity();
if ($user->guest)
{
$this->jsonResponse(['success' => false, 'message' => 'Please log in.']);
}
$input = Factory::getApplication()->getInput();
// Use admin TicketsModel
$model = $this->getModel('Tickets', 'Administrator');
$this->jsonResponse($model->createTicket([
'subject' => $input->getString('subject', ''),
'body' => $input->getRaw('body', ''),
'priority' => $input->getString('priority', 'normal'),
'category_id' => $input->getInt('category_id', 0),
]));
}
/**
* Submit a reply.
*/
public function submitReply()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
$user = Factory::getApplication()->getIdentity();
$input = Factory::getApplication()->getInput();
if ($user->guest)
{
$this->jsonResponse(['success' => false, 'message' => 'Please log in.']);
}
$ticketId = $input->getInt('ticket_id', 0);
$model = $this->getModel('Tickets', 'Administrator');
$ticket = $model->getTicket($ticketId);
if (!$ticket)
{
$this->jsonResponse(['success' => false, 'message' => 'Ticket not found.']);
}
// Customers can only reply to their own tickets; staff can reply to any
if ((int) $ticket->created_by !== $user->id && !$this->isStaff($user))
{
$this->jsonResponse(['success' => false, 'message' => 'Access denied.']);
}
// Staff replies from frontend are not internal notes
$this->jsonResponse($model->addReply(
$ticketId,
$input->getRaw('body', ''),
false
));
}
/**
* Update ticket status (staff/manager only from frontend).
*/
public function updateStatus()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
$user = Factory::getApplication()->getIdentity();
if (!$this->isStaff($user))
{
$this->jsonResponse(['success' => false, 'message' => 'Access denied.']);
}
$input = Factory::getApplication()->getInput();
$model = $this->getModel('Tickets', 'Administrator');
$this->jsonResponse($model->updateStatus(
$input->getInt('ticket_id', 0),
$input->getString('status', '')
));
}
/**
* Assign a ticket (manager only from frontend).
*/
public function assignTicket()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
$user = Factory::getApplication()->getIdentity();
if (!$user->authorise('mokowaas.tickets.assign', 'com_mokowaas'))
{
$this->jsonResponse(['success' => false, 'message' => 'Access denied.']);
}
$input = Factory::getApplication()->getInput();
$ticketId = $input->getInt('ticket_id', 0);
$assignTo = $input->getInt('assigned_to', 0);
try
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__mokowaas_tickets'))
->set($db->quoteName('assigned_to') . ' = ' . ($assignTo ?: 'NULL'))
->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql()))
->where($db->quoteName('id') . ' = ' . $ticketId)
)->execute();
$this->jsonResponse(['success' => true, 'message' => 'Ticket assigned.']);
}
catch (\Throwable $e)
{
$this->jsonResponse(['success' => false, 'message' => $e->getMessage()]);
}
}
/**
* Check if user is support staff (can manage tickets beyond their own).
*/
private function isStaff($user): bool
{
if ($user->guest)
{
return false;
}
// Super admins always staff
if ($user->authorise('core.admin'))
{
return true;
}
// Anyone with mokowaas.tickets ACL on the component is staff
return $user->authorise('mokowaas.tickets', 'com_mokowaas');
}
/**
* Search KB articles via Smart Search (com_finder).
*/
public function searchKb()
{
$query = Factory::getApplication()->getInput()->getString('q', '');
if (strlen($query) < 3)
{
$this->jsonResponse(['results' => []]);
}
try
{
$db = Factory::getDbo();
$escaped = $db->quote('%' . $db->escape($query, true) . '%');
$results = $db->setQuery(
$db->getQuery(true)
->select([
$db->quoteName('l.link_id'),
$db->quoteName('l.title'),
$db->quoteName('l.url'),
$db->quoteName('l.description'),
])
->from($db->quoteName('#__finder_links', 'l'))
->where($db->quoteName('l.published') . ' = 1')
->where('(' . $db->quoteName('l.title') . ' LIKE ' . $escaped
. ' OR ' . $db->quoteName('l.description') . ' LIKE ' . $escaped . ')')
->order($db->quoteName('l.title') . ' ASC')
->setLimit(8)
)->loadObjectList() ?: [];
foreach ($results as $r)
{
$r->description = mb_substr(strip_tags($r->description ?? ''), 0, 150);
}
$this->jsonResponse(['results' => $results]);
}
catch (\Throwable $e)
{
$this->jsonResponse(['results' => []]);
}
}
private function jsonResponse(array $data): void
{
$app = Factory::getApplication();
$app->setHeader('Content-Type', 'application/json');
echo json_encode($data);
$app->close();
}
}
@@ -0,0 +1,84 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas.site
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Site\View\Ticket;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Router\Route;
class HtmlView extends BaseHtmlView
{
protected $ticket;
protected $isStaff = false;
protected $canAssign = false;
public function display($tpl = null)
{
$db = Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
$user = Factory::getApplication()->getIdentity();
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$this->isStaff = $user->authorise('core.admin') || $user->authorise('mokowaas.tickets', 'com_mokowaas');
$this->canAssign = $user->authorise('core.admin') || $user->authorise('mokowaas.tickets.assign', 'com_mokowaas');
// Get ticket — staff see any, customers see only their own
$query = $db->getQuery(true)
->select([
$db->quoteName('t') . '.*',
$db->quoteName('c.title', 'category_title'),
$db->quoteName('u.name', 'created_by_name'),
$db->quoteName('u.email', 'created_by_email'),
$db->quoteName('a.name', 'assigned_to_name'),
])
->from($db->quoteName('#__mokowaas_tickets', 't'))
->leftJoin($db->quoteName('#__mokowaas_ticket_categories', 'c') . ' ON c.id = t.category_id')
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to')
->where($db->quoteName('t.id') . ' = ' . $id);
if (!$this->isStaff)
{
$query->where($db->quoteName('t.created_by') . ' = ' . (int) $user->id);
}
$db->setQuery($query);
$this->ticket = $db->loadObject();
if (!$this->ticket)
{
Factory::getApplication()->enqueueMessage('Ticket not found.', 'error');
Factory::getApplication()->redirect(Route::_('index.php?option=com_mokowaas&view=tickets', false));
return;
}
// Load replies — staff see internal notes, customers don't
$query = $db->getQuery(true)
->select([
$db->quoteName('r') . '.*',
$db->quoteName('u.name', 'user_name'),
])
->from($db->quoteName('#__mokowaas_ticket_replies', 'r'))
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id')
->where($db->quoteName('r.ticket_id') . ' = ' . $id);
if (!$this->isStaff)
{
$query->where($db->quoteName('r.is_internal') . ' = 0');
}
$query->order($db->quoteName('r.created') . ' ASC');
$db->setQuery($query);
$this->ticket->replies = $db->loadObjectList() ?: [];
parent::display($tpl);
}
}
@@ -0,0 +1,75 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas.site
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Site\View\Tickets;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
class HtmlView extends BaseHtmlView
{
protected $tickets = [];
protected $categories = [];
protected $isStaff = false;
public function display($tpl = null)
{
$db = Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
$user = Factory::getApplication()->getIdentity();
$this->isStaff = $user->authorise('core.admin')
|| $user->authorise('mokowaas.tickets', 'com_mokowaas');
// Staff see all tickets, customers see their own
$query = $db->getQuery(true)
->select([
$db->quoteName('t.id'),
$db->quoteName('t.subject'),
$db->quoteName('t.status'),
$db->quoteName('t.priority'),
$db->quoteName('t.created'),
$db->quoteName('t.assigned_to'),
$db->quoteName('c.title', 'category_title'),
$db->quoteName('u.name', 'created_by_name'),
$db->quoteName('a.name', 'assigned_to_name'),
])
->from($db->quoteName('#__mokowaas_tickets', 't'))
->leftJoin($db->quoteName('#__mokowaas_ticket_categories', 'c') . ' ON c.id = t.category_id')
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to');
if (!$this->isStaff)
{
$query->where($db->quoteName('t.created_by') . ' = ' . (int) $user->id);
}
$filterStatus = Factory::getApplication()->getInput()->getString('filter_status', '');
if ($filterStatus)
{
$query->where($db->quoteName('t.status') . ' = ' . $db->quote($filterStatus));
}
$query->order($db->quoteName('t.created') . ' DESC')->setLimit(50);
$db->setQuery($query);
$this->tickets = $db->loadObjectList() ?: [];
// Categories for new ticket form
$query = $db->getQuery(true)
->select([$db->quoteName('id'), $db->quoteName('title')])
->from($db->quoteName('#__mokowaas_ticket_categories'))
->where($db->quoteName('published') . ' = 1')
->order($db->quoteName('ordering') . ' ASC');
$db->setQuery($query);
$this->categories = $db->loadObjectList() ?: [];
parent::display($tpl);
}
}
@@ -0,0 +1,241 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
use Joomla\CMS\Factory;
$t = $this->ticket;
$isStaff = $this->isStaff;
$canAssign = $this->canAssign;
$token = Session::getFormToken();
$userId = Factory::getApplication()->getIdentity()->id;
$statusLabel = [
'open' => 'Open', 'in_progress' => 'In Progress', 'waiting' => 'Awaiting Response',
'resolved' => 'Resolved', 'closed' => 'Closed',
];
$statusClass = [
'open' => 'primary', 'in_progress' => 'info', 'waiting' => 'warning',
'resolved' => 'success', 'closed' => 'secondary',
];
?>
<div class="mokowaas-portal-ticket">
<div class="mb-3">
<a href="<?php echo Route::_('index.php?option=com_mokowaas&view=tickets'); ?>" class="btn btn-sm btn-outline-secondary">
<span class="icon-arrow-left"></span> Back to Tickets
</a>
</div>
<div class="row">
<!-- Main column: conversation -->
<div class="col-12 <?php echo $isStaff ? 'col-lg-8' : ''; ?>">
<!-- Ticket header -->
<div class="card mb-3">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<h3 class="mb-1">#<?php echo $t->id; ?> — <?php echo htmlspecialchars($t->subject); ?></h3>
<small class="text-muted">
<?php echo htmlspecialchars($t->category_title ?? 'General'); ?>
&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>
@@ -0,0 +1,83 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$tickets = $this->tickets;
$categories = $this->categories;
$isStaff = $this->isStaff;
$token = Session::getFormToken();
$statusLabel = [
'open' => 'Open', 'in_progress' => 'In Progress', 'waiting' => 'Awaiting Response',
'resolved' => 'Resolved', 'closed' => 'Closed',
];
$statusClass = [
'open' => 'primary', 'in_progress' => 'info', 'waiting' => 'warning',
'resolved' => 'success', 'closed' => 'secondary',
];
?>
<div class="mokowaas-portal">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><?php echo $isStaff ? 'All Support Tickets' : 'My Support Tickets'; ?></h2>
<div class="d-flex gap-2">
<a href="<?php echo Route::_('index.php?option=com_mokowaas&view=tickets&layout=submit'); ?>" class="btn btn-primary">
<span class="icon-plus"></span> New Ticket
</a>
<?php if ($isStaff): ?>
<form method="get" class="d-inline">
<input type="hidden" name="option" value="com_mokowaas">
<input type="hidden" name="view" value="tickets">
<select name="filter_status" class="form-select form-select-sm" style="width:auto" onchange="this.form.submit()">
<option value="">All Statuses</option>
<?php foreach ($statusLabel as $k => $v): ?>
<option value="<?php echo $k; ?>" <?php echo Factory::getApplication()->getInput()->getString('filter_status') === $k ? 'selected' : ''; ?>><?php echo $v; ?></option>
<?php endforeach; ?>
</select>
</form>
<?php endif; ?>
</div>
</div>
<?php if (empty($tickets)): ?>
<div class="alert alert-info">
<span class="icon-info-circle"></span>
<?php echo $isStaff ? 'No tickets found.' : 'You haven\'t submitted any support tickets yet.'; ?>
</div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>#</th>
<th>Subject</th>
<th>Status</th>
<th>Priority</th>
<th>Category</th>
<?php if ($isStaff): ?><th>Submitted By</th><th>Assigned To</th><?php endif; ?>
<th>Date</th>
</tr>
</thead>
<tbody>
<?php foreach ($tickets as $t): ?>
<tr>
<td><a href="<?php echo Route::_('index.php?option=com_mokowaas&view=ticket&id=' . $t->id); ?>"><?php echo $t->id; ?></a></td>
<td><a href="<?php echo Route::_('index.php?option=com_mokowaas&view=ticket&id=' . $t->id); ?>"><?php echo htmlspecialchars(mb_substr($t->subject, 0, 60)); ?></a></td>
<td><span class="badge bg-<?php echo $statusClass[$t->status] ?? 'secondary'; ?>"><?php echo $statusLabel[$t->status] ?? $t->status; ?></span></td>
<td><?php echo ucfirst($t->priority); ?></td>
<td><?php echo htmlspecialchars($t->category_title ?? '—'); ?></td>
<?php if ($isStaff): ?>
<td><?php echo htmlspecialchars($t->created_by_name ?? ''); ?></td>
<td><?php echo htmlspecialchars($t->assigned_to_name ?? '<em>Unassigned</em>'); ?></td>
<?php endif; ?>
<td class="text-nowrap"><?php echo HTMLHelper::_('date', $t->created, 'M d, Y'); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
@@ -0,0 +1,204 @@
<?php
/**
* Submit a Ticket layout — search KB first, then submit form.
*/
defined('_JEXEC') or die;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$categories = $this->categories;
$token = Session::getFormToken();
$searchUrl = Route::_('index.php?option=com_mokowaas&task=display.searchKb&format=json');
$submitUrl = Route::_('index.php?option=com_mokowaas&task=display.submitTicket&format=json');
$ticketUrl = Route::_('index.php?option=com_mokowaas&view=ticket&id=');
$ticketsUrl = Route::_('index.php?option=com_mokowaas&view=tickets');
// Check if Smart Search has indexed content
$finderEnabled = false;
try {
$db = \Joomla\CMS\Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
$db->setQuery('SELECT COUNT(*) FROM #__finder_links WHERE published = 1');
$finderEnabled = (int) $db->loadResult() > 0;
} catch (\Throwable $e) {}
?>
<div class="mokowaas-portal">
<h2>Submit a Support Request</h2>
<?php if ($finderEnabled): ?>
<!-- Step 1: Search -->
<div id="step-search" class="mb-4">
<p class="text-muted">Before submitting, let's see if we already have an answer for you.</p>
<div class="card">
<div class="card-body">
<label class="form-label fw-bold" for="kb-search">Describe your issue</label>
<div class="input-group input-group-lg">
<input type="text" id="kb-search" class="form-control" placeholder="e.g. how do I reset my password?" autofocus>
<button type="button" class="btn btn-primary" id="kb-search-btn">
<span class="icon-search"></span> Search
</button>
</div>
</div>
</div>
<!-- Search results -->
<div id="kb-results" class="mt-3 d-none">
<h5>Related Articles</h5>
<div id="kb-results-list" class="list-group mb-3"></div>
<p class="text-muted">Didn't find what you need?</p>
</div>
<div class="mt-3">
<button type="button" class="btn btn-outline-primary" id="btn-show-form">
<span class="icon-plus"></span> Submit a Ticket Anyway
</button>
</div>
</div>
<?php endif; ?>
<!-- Step 2: Ticket Form -->
<div id="step-form" class="<?php echo $finderEnabled ? 'd-none' : ''; ?>">
<div class="card">
<div class="card-body">
<h5 class="card-title mb-3">Ticket Details</h5>
<form id="submitTicketForm">
<div class="mb-3">
<label class="form-label" for="ticket-subject">Subject <span class="text-danger">*</span></label>
<input type="text" id="ticket-subject" name="subject" class="form-control" required placeholder="Brief description of your issue">
</div>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label" for="ticket-category">Category</label>
<select id="ticket-category" name="category_id" class="form-select">
<option value="">Select a category</option>
<?php foreach ($categories as $cat): ?>
<option value="<?php echo $cat->id; ?>"><?php echo htmlspecialchars($cat->title); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-6">
<label class="form-label" for="ticket-priority">Priority</label>
<select id="ticket-priority" name="priority" class="form-select">
<option value="normal">Normal</option>
<option value="low">Low</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label" for="ticket-body">Description <span class="text-danger">*</span></label>
<textarea id="ticket-body" name="body" class="form-control" rows="8" required placeholder="Please describe your issue in detail."></textarea>
</div>
<input type="hidden" name="<?php echo $token; ?>" value="1">
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary btn-lg">
<span class="icon-paper-plane"></span> Submit Ticket
</button>
<a href="<?php echo $ticketsUrl; ?>" class="btn btn-outline-secondary btn-lg">
My Tickets
</a>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var searchInput = document.getElementById('kb-search');
var searchBtn = document.getElementById('kb-search-btn');
var resultBox = document.getElementById('kb-results');
var resultList = document.getElementById('kb-results-list');
var showFormBtn = document.getElementById('btn-show-form');
var stepSearch = document.getElementById('step-search');
var stepForm = document.getElementById('step-form');
var subjectField = document.getElementById('ticket-subject');
// Search
function doSearch() {
var q = (searchInput ? searchInput.value.trim() : '');
if (q.length < 3) return;
fetch('<?php echo $searchUrl; ?>&q=' + encodeURIComponent(q), {
headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(function(r) { return r.json(); })
.then(function(d) {
resultList.textContent = '';
if (d.results && d.results.length > 0) {
d.results.forEach(function(item) {
var a = document.createElement('a');
a.href = item.url;
a.target = '_blank';
a.className = 'list-group-item list-group-item-action';
var strong = document.createElement('strong');
strong.textContent = item.title;
a.appendChild(strong);
if (item.description) {
a.appendChild(document.createElement('br'));
var small = document.createElement('small');
small.className = 'text-muted';
small.textContent = item.description;
a.appendChild(small);
}
resultList.appendChild(a);
});
resultBox.classList.remove('d-none');
} else {
resultBox.classList.add('d-none');
}
// Always show the "submit anyway" button after search
if (showFormBtn) showFormBtn.classList.remove('d-none');
});
}
if (searchBtn) searchBtn.addEventListener('click', doSearch);
if (searchInput) {
searchInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); doSearch(); }
});
}
// Show form and prefill subject from search query
if (showFormBtn) {
showFormBtn.addEventListener('click', function() {
if (stepSearch) stepSearch.classList.add('d-none');
if (stepForm) stepForm.classList.remove('d-none');
if (searchInput && subjectField && !subjectField.value) {
subjectField.value = searchInput.value;
}
subjectField.focus();
});
}
// Submit ticket
var form = document.getElementById('submitTicketForm');
if (form) {
form.addEventListener('submit', function(e) {
e.preventDefault();
var btn = form.querySelector('button[type=submit]');
btn.disabled = true;
btn.textContent = ' Submitting...';
var fd = new FormData(form);
fetch('<?php echo $submitUrl; ?>', {
method: 'POST', body: fd, headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success && d.id) {
window.location.href = '<?php echo $ticketUrl; ?>' + d.id;
} else {
alert(d.message || 'Failed.');
btn.disabled = false;
btn.textContent = ' Submit Ticket';
}
})
.catch(function() { alert('Network error.'); btn.disabled = false; });
});
}
});
</script>
@@ -7,7 +7,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.32.21</version>
<version>02.32.38</version>
<description>MOD_MOKOWAAS_CPANEL_DESC</description>
<namespace path="src">Moko\Module\MokoWaaSCpanel</namespace>
@@ -22,7 +22,7 @@
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* REPO: https://github.com/mokoconsulting-tech/mokowaas
* VERSION: 02.32.21
* VERSION: 02.32.38
* PATH: /src/Extension/MokoWaaS.php
* NOTE: Handles Joomla system events for rebranding functionality
*/
@@ -161,20 +161,7 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
*/
public function boot(ContainerInterface $container): void
{
$timeout = (int) $this->params->get('admin_session_timeout', 0);
if ($timeout <= 0)
{
return;
}
if ($this->ipIsTrusted())
{
// Set both PHP and Joomla session lifetimes before the
// session handler runs its expiry check.
ini_set('session.gc_maxlifetime', 315360000);
Factory::getConfig()->set('lifetime', 525600);
}
// Session lifetime for trusted IPs is now handled by the firewall plugin
}
/**
@@ -189,12 +176,7 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
*/
public function onAfterInitialise()
{
// Security: HTTPS redirect (runs for all clients)
$this->enforceHttps();
// Site alias handling: offline page and backend redirect.
// Must run in onAfterInitialise (not onAfterRoute) so that
// Joomla's offline check in doExecute() sees the updated config.
// Site alias handling
$this->handleSiteAlias();
// MokoWaaS API endpoints (run before routing)
@@ -205,18 +187,15 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
$this->handleMokoApi($mokoAction);
}
// Dev mode: disable caching
$this->enforceDevMode();
// Admin-only WaaS controls
// Admin-only core controls (branding, emergency access, master user)
// NOTE: enforceHttps, enforceDevMode, enforceAdminSessionTimeout,
// enforceUploadRestrictions are now in feature plugins
if ($this->app->isClient('administrator'))
{
$this->handleEmergencyAccess();
$this->enforceMasterUser();
$this->enforceLoginSupportUrls();
$this->enforceAtumBranding();
$this->enforceAdminSessionTimeout();
$this->enforceUploadRestrictions();
}
$this->loadLanguageOverrides();
@@ -815,7 +794,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
return $strings;
}
/**
* Event triggered after an extension's config is saved.
*
@@ -883,41 +861,7 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
// Grafana auto-provisioning
$this->handleGrafanaProvisioning($params, $app);
if ((int) $params->get('reset_hits', 0) === 1)
{
$count = $this->resetAllHits();
$params->set('reset_hits', '0');
$changed = true;
$app->enqueueMessage(
sprintf('Reset hit counters on %d articles.', $count),
'message'
);
Log::add(
sprintf('All article hits reset (%d rows) by MokoWaaS', $count),
Log::WARNING,
'mokowaas'
);
}
if ((int) $params->get('delete_versions', 0) === 1)
{
$count = $this->deleteAllVersions();
$params->set('delete_versions', '0');
$changed = true;
$app->enqueueMessage(
sprintf('Deleted %d version history records.', $count),
'message'
);
Log::add(
sprintf('All content versions purged (%d rows) by MokoWaaS', $count),
Log::WARNING,
'mokowaas'
);
}
// NOTE: reset_hits and delete_versions now handled by devtools plugin
// Content Sync: Push Now
if ((int) $params->get('sync_push_now', 0) === 1)
@@ -977,48 +921,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
}
}
/**
* Reset all article hit counters to zero.
*
* @return int Number of rows affected
*
* @since 02.01.08
*/
protected function resetAllHits()
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__content'))
->set($db->quoteName('hits') . ' = 0')
->where($db->quoteName('hits') . ' > 0')
);
$db->execute();
return $db->getAffectedRows();
}
/**
* Delete all content version history records.
*
* @return int Number of rows deleted
*
* @since 02.01.08
*/
protected function deleteAllVersions()
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__history'))
);
$db->execute();
return $db->getAffectedRows();
}
/**
* Event triggered after the route has been determined.
*
@@ -1036,8 +938,8 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
return;
}
$this->warnMissingLicenseKey();
$this->enforceAdminRestrictions();
// NOTE: warnMissingLicenseKey and enforceAdminRestrictions
// are now handled by feature plugins (deferred / tenant)
$this->protectPlugin();
}
@@ -1509,7 +1411,7 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
$db->quote('mokowaas_firewall'),
$db->quote('mokowaas_tenant'),
$db->quote('mokowaas_devtools'),
$db->quote('mokowaas_monitor'),
$db->quote('mokowaas_offline'),
$db->quote('mod_mokowaas_cpanel'),
];
@@ -1553,107 +1455,8 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
}
}
/**
* Filter admin menu items for non-master users.
*
* @param string $context Menu context
* @param array &$items Menu items (by reference)
* @param mixed $params Module params
* @param mixed $enabled Whether module is enabled
*
* @return void
*
* @since 02.01.08
*/
public function onPreprocessMenuItems($context, &$items, $params, $enabled)
{
if (!$this->app->isClient('administrator'))
{
return;
}
if ($this->isMasterUser())
{
return;
}
$hidden = $this->getHiddenMenuComponents();
if (empty($hidden))
{
return;
}
foreach ($items as $key => $item)
{
foreach ($hidden as $component)
{
if (isset($item->link)
&& strpos($item->link, 'option=' . $component) !== false)
{
unset($items[$key]);
break;
}
}
}
}
/**
* Enforce password policy before user save.
*
* @param array $oldUser Existing user data
* @param boolean $isNew Whether this is a new user
* @param array $newUser New user data being saved
*
* @return boolean True to allow save
*
* @since 02.01.08
*/
public function onUserBeforeSave($oldUser, $isNew, $newUser)
{
if (empty($newUser['password_clear']))
{
return true;
}
$password = $newUser['password_clear'];
$errors = [];
$minLen = (int) $this->params->get('password_min_length', 12);
if (strlen($password) < $minLen)
{
$errors[] = sprintf(
'Password must be at least %d characters.', $minLen
);
}
if ($this->params->get('password_require_uppercase', 1)
&& !preg_match('/[A-Z]/', $password))
{
$errors[] = 'Password must contain an uppercase letter.';
}
if ($this->params->get('password_require_number', 1)
&& !preg_match('/\d/', $password))
{
$errors[] = 'Password must contain a number.';
}
if ($this->params->get('password_require_special', 1)
&& !preg_match('/[^A-Za-z0-9]/', $password))
{
$errors[] = 'Password must contain a special character.';
}
if (!empty($errors))
{
throw new \RuntimeException(implode(' ', $errors));
}
return true;
}
// onPreprocessMenuItems — REMOVED, now in plg_system_mokowaas_tenant
// onUserBeforeSave — REMOVED, now in plg_system_mokowaas_firewall
// ------------------------------------------------------------------
// Diagnostics / Health Endpoint (called from onAfterInitialise)
@@ -4420,130 +4223,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
// License key check (called from onAfterRoute)
// ------------------------------------------------------------------
/**
* Show a persistent admin warning if no license key is set on the
* MokoWaaS update site.
*
* Checks the extra_query column in #__update_sites for a dlid value.
* Also validates the key against MokoGitea on a heartbeat interval
* (once per day) and warns if the key is invalid or expired.
*
* @return void
*
* @since 02.31.00
*/
protected function warnMissingLicenseKey(): void
{
// Only show to master users
if (!$this->isMasterUser())
{
return;
}
// Only warn once per session
$session = Factory::getSession();
if ($session->get('mokowaas.license_warned', false))
{
return;
}
$session->set('mokowaas.license_warned', true);
try
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('extra_query'))
->from($db->quoteName('#__update_sites'))
->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoWaaS%')
. ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoWaaS%') . ')')
->setLimit(1);
$db->setQuery($query);
$extraQuery = (string) $db->loadResult();
if (empty($extraQuery) || strpos($extraQuery, 'dlid=') === false)
{
$this->app->enqueueMessage(
'<strong>Moko Consulting License Key Required</strong> — '
. 'No download key is configured. Updates will not be available until a valid license key is entered. '
. 'Go to <a href="index.php?option=com_installer&view=updatesites">System → Update Sites</a> '
. 'and enter your license key in the Download Key field for the MokoWaaS update site.',
'warning'
);
return;
}
// Extract the key value from extra_query
parse_str($extraQuery, $parsed);
$licenseKey = $parsed['dlid'] ?? '';
if (empty($licenseKey))
{
return;
}
// Heartbeat validation — check once per day
$session = Factory::getSession();
$lastCheck = (int) $session->get('mokowaas.license_check', 0);
$now = time();
if (($now - $lastCheck) < 86400)
{
// Show cached warning if key was invalid last check
if ($session->get('mokowaas.license_invalid', false))
{
$this->app->enqueueMessage(
'<strong>Moko Consulting License Key Invalid</strong> — '
. 'Your license key could not be validated. Please verify your key in '
. '<a href="index.php?option=com_installer&view=updatesites">System → Update Sites</a>.',
'error'
);
}
return;
}
// Validate against MokoGitea
$session->set('mokowaas.license_check', $now);
$validateUrl = 'https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/updates.xml'
. '?dlid=' . urlencode($licenseKey)
. '&domain=' . urlencode(Uri::root());
$ch = curl_init($validateUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$response = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// Empty <updates></updates> or non-200 means invalid key
$isValid = ($httpCode === 200 && $response && strpos($response, '<update>') !== false);
$session->set('mokowaas.license_invalid', !$isValid);
if (!$isValid)
{
$this->app->enqueueMessage(
'<strong>Moko Consulting License Key Invalid</strong> — '
. 'Your license key could not be validated. Updates will not be available. '
. 'Please verify your key in '
. '<a href="index.php?option=com_installer&view=updatesites">System → Update Sites</a>.',
'error'
);
}
}
catch (\Throwable $e)
{
// Silent — license check is non-critical
}
}
// ------------------------------------------------------------------
/**
@@ -4679,110 +4358,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
*
* @since 02.01.08
*/
/**
* Enforce development mode settings.
*
* When dev mode is ON:
* - Disable Joomla caching
* - Enable Joomla debug mode (Global Config)
* - Enable MokoOnyx template debug
* - Disable article hit recording
*
* When dev mode is OFF (and was previously on):
* - Reset all content version history
* - Reset article published dates to now
*
* @return void
*
* @since 02.01.15
*/
protected function enforceDevMode()
{
if (!$this->params->get('dev_mode', 0))
{
return;
}
// Disable caching
$config = Factory::getConfig();
$config->set('caching', 0);
// Enable Joomla debug
$config->set('debug', 1);
// Enable MokoOnyx template debug
$this->setTemplateParam('mokoonyx', 'debug', 1);
// Show offline page on primary domain only — site aliases
// and dev.* subdomains bypass offline mode for development
$currentHost = $_SERVER['HTTP_HOST'] ?? '';
$primaryDomain = $this->params->get('primary_domain', '');
if (!empty($primaryDomain) && $currentHost === $primaryDomain)
{
$config->set('offline', 1);
}
// Suppress hit recording
try
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__content'))
->set($db->quoteName('hits') . ' = 0')
->where($db->quoteName('hits') . ' > 0')
)->execute();
}
catch (\Throwable $e)
{
// Silent
}
}
/**
* Actions to run when dev mode is turned off.
*
* Resets content versions and hits, disables debug.
*
* @return void
*
* @since 02.31.00
*/
protected function onDevModeDisabled(): void
{
try
{
$db = Factory::getDbo();
// Delete all content version history
$db->setQuery(
$db->getQuery(true)->delete($db->quoteName('#__history'))
)->execute();
// Reset hits
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__content'))
->set($db->quoteName('hits') . ' = 0')
)->execute();
// Disable debug
$this->setTemplateParam('mokoonyx', 'debug', 0);
// Take site back online
Factory::getConfig()->set('offline', 0);
$this->app->enqueueMessage(
'Development mode disabled — versions cleared, hits reset, debug off, site online.',
'message'
);
}
catch (\Throwable $e)
{
Log::add('Dev mode cleanup failed: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
}
}
/**
* Set a parameter on a template style.
@@ -4830,194 +4405,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
}
}
protected function enforceHttps()
{
if (!$this->params->get('force_https', 0))
{
return;
}
if ($this->app->isClient('cli'))
{
return;
}
$isHttps = (!empty($_SERVER['HTTPS'])
&& $_SERVER['HTTPS'] !== 'off')
|| ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
if (!$isHttps)
{
$this->app->redirect(
'https://' . $_SERVER['HTTP_HOST']
. $_SERVER['REQUEST_URI'], 301
);
}
}
/**
* Enforce admin session idle timeout.
*
* @return void
*
* @since 02.01.08
*/
protected function enforceAdminSessionTimeout()
{
$timeout = (int) $this->params->get('admin_session_timeout', 0);
if ($timeout <= 0)
{
return;
}
// Don't timeout the master user
if ($this->isMasterUser())
{
return;
}
// Trusted IPs — session lifetime already extended in boot()
if ($this->ipIsTrusted())
{
return;
}
$session = Factory::getSession();
$lastHit = $session->get('mokowaas.last_activity', 0);
$now = time();
if ($lastHit > 0 && ($now - $lastHit) > ($timeout * 60))
{
$this->app->logout();
$this->app->redirect(
Route::_('index.php', false)
);
return;
}
$session->set('mokowaas.last_activity', $now);
}
/**
* Check whether the current request IP matches any trusted IP entry.
*
* Supports exact IPs, CIDR notation (e.g. 10.0.0.0/8), and
* wildcard patterns (e.g. 192.168.1.*).
*
* @return bool True if the current IP is in the trusted list.
*
* @since 02.11.00
*/
protected function ipIsTrusted(): bool
{
$entries = $this->params->get('trusted_ips', '');
if (empty($entries))
{
return false;
}
// Subform stores as JSON string or array
if (\is_string($entries))
{
$entries = json_decode($entries, true);
}
if (!\is_array($entries))
{
return false;
}
$ip = $this->app
? $this->app->input->server->getString('REMOTE_ADDR', '')
: ($_SERVER['REMOTE_ADDR'] ?? '');
$ipLong = ip2long($ip);
if ($ipLong === false)
{
return false;
}
foreach ($entries as $entry)
{
if (empty($entry['enabled']) || empty($entry['ip']))
{
continue;
}
$range = trim($entry['ip']);
// Wildcard: 192.168.1.*
if (str_contains($range, '*'))
{
$pattern = '/^' . str_replace(['.', '*'], ['\\.', '\\d+'], $range) . '$/';
if (preg_match($pattern, $ip))
{
return true;
}
continue;
}
// CIDR: 10.0.0.0/8
if (str_contains($range, '/'))
{
[$subnet, $bits] = explode('/', $range, 2);
$subnetLong = ip2long($subnet);
$mask = -1 << (32 - (int) $bits);
if ($subnetLong !== false && ($ipLong & $mask) === ($subnetLong & $mask))
{
return true;
}
continue;
}
// Exact match
if ($ip === $range)
{
return true;
}
}
return false;
}
/**
* Override Joomla upload restrictions at runtime.
*
* @return void
*
* @since 02.01.08
*/
protected function enforceUploadRestrictions()
{
$types = $this->params->get('upload_allowed_types', '');
$maxMb = (int) $this->params->get('upload_max_size_mb', 0);
if (empty($types) && $maxMb <= 0)
{
return;
}
$config = $this->app->getConfig();
if (!empty($types))
{
$config->set('upload_extensions', $types);
}
if ($maxMb > 0)
{
$config->set('upload_maxsize', $maxMb);
}
}
/**
* Enforce login support module URLs on admin requests.
*
@@ -5086,121 +4473,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
// Tenant Restrictions (called from onAfterRoute)
// ------------------------------------------------------------------
/**
* Check admin routes against restriction rules and redirect if blocked.
*
* @return void
*
* @since 02.01.08
*/
protected function enforceAdminRestrictions()
{
// Master user bypasses ALL restrictions
if ($this->isMasterUser())
{
return;
}
$input = $this->app->input;
$option = $input->get('option', '');
$view = $input->get('view', '');
$task = $input->get('task', '');
// Disable install-from-URL for non-master users
if ($this->params->get('disable_install_url', 1)
&& $option === 'com_installer'
&& stripos($task, 'install') !== false
&& $input->get('installtype') === 'url')
{
$this->blockAccess('Install from URL is disabled.');
return;
}
$blocked = [];
if ($this->params->get('restrict_installer', 1))
{
// Allow the update view by default so tenants can update extensions
$allowUpdates = (int) $this->params->get('allow_extension_updates', 1);
if ($allowUpdates && $option === 'com_installer'
&& \in_array($view, ['update', 'updatesites'], true))
{
// Do not block — update views are permitted
}
elseif ($option === 'com_installer')
{
$this->blockAccess('Access restricted.');
return;
}
}
if ($this->params->get('hide_sysinfo', 1))
{
$blocked[] = [
'option' => 'com_admin',
'view' => 'sysinfo',
];
}
if ($this->params->get('restrict_global_config', 1))
{
$blocked[] = [
'option' => 'com_config',
'view' => 'application',
];
// Also block empty view (default landing = global config)
if ($option === 'com_config' && $view === '')
{
$this->blockAccess('Access restricted.');
return;
}
}
if ($this->params->get('restrict_template_editing', 1))
{
$blocked[] = [
'option' => 'com_templates',
'view' => 'template',
];
}
foreach ($blocked as $rule)
{
if ($option !== $rule['option'])
{
continue;
}
if (isset($rule['view']) && $view !== $rule['view'])
{
continue;
}
$this->blockAccess('Access restricted.');
return;
}
}
/**
* Redirect to admin dashboard with an error message.
*
* @param string $message Error message to display
*
* @return void
*
* @since 02.01.08
*/
protected function blockAccess($message)
{
$this->app->enqueueMessage($message, 'error');
$this->app->redirect(Route::_('index.php', false));
}
/**
* Check whether the current user is the master WaaS user.
*
@@ -5252,38 +4524,6 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
return $this->masterNames;
}
/**
* Build the list of components to hide from admin menu.
*
* Combines explicit hidden_menu_items config with components that
* are implicitly blocked by other restriction toggles.
*
* @return array Component option strings
*
* @since 02.01.08
*/
protected function getHiddenMenuComponents()
{
$hidden = array_filter(array_map(
'trim',
explode("\n", $this->params->get('hidden_menu_items', ''))
));
// Auto-hide components that are restricted (keep visible when updates are allowed)
if ($this->params->get('restrict_installer', 1)
&& !$this->params->get('allow_extension_updates', 1))
{
$hidden[] = 'com_installer';
}
if ($this->params->get('hide_sysinfo', 1))
{
$hidden[] = 'com_admin';
}
return array_unique($hidden);
}
// ------------------------------------------------------------------
// Atum Template Branding (called from onAfterInitialise)
// ------------------------------------------------------------------
@@ -7,7 +7,7 @@
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* VERSION: 02.32.21
* VERSION: 02.32.38
* PATH: /src/Field/AllowedIpsField.php
* BRIEF: Custom form field that displays the current IP whitelist
*/
@@ -8,7 +8,7 @@
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* VERSION: 02.32.21
* VERSION: 02.32.38
* PATH: /src/Field/CopyableTokenField.php
* BRIEF: Read-only token field with a copy-to-clipboard button
*/
@@ -7,7 +7,7 @@
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* VERSION: 02.32.21
* VERSION: 02.32.38
* PATH: /src/Field/CurrentIpField.php
* BRIEF: Read-only field that displays the current user's IP address
*/
@@ -8,7 +8,7 @@
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* VERSION: 02.32.21
* VERSION: 02.32.38
* PATH: /src/Field/DemoTaskInfoField.php
* BRIEF: Read-only field showing scheduled task info with link to manage it
*/
@@ -8,7 +8,7 @@
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* VERSION: 02.32.21
* VERSION: 02.32.38
* PATH: /src/Field/NextResetField.php
* BRIEF: Read-only field showing next reset time from Joomla scheduled task
*/
@@ -8,7 +8,7 @@
* FILE INFORMATION
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* VERSION: 02.32.21
* VERSION: 02.32.38
* PATH: /src/Field/SnapshotTablesField.php
* BRIEF: Multi-select list field that loads DB tables with sensible defaults
*/
@@ -10,7 +10,7 @@
* INGROUP: MokoWaaS
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
* PATH: /src/packages/plg_system_mokowaas/Service/ContentSyncReceiver.php
* VERSION: 02.32.21
* VERSION: 02.32.38
* BRIEF: Receiver-side content sync — applies incoming payload to local DB
*/
@@ -10,7 +10,7 @@
* INGROUP: MokoWaaS
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
* PATH: /src/packages/plg_system_mokowaas/Service/ContentSyncService.php
* VERSION: 02.32.21
* VERSION: 02.32.38
* BRIEF: Sender-side content sync — builds payload and pushes to remote sites
*/
@@ -10,7 +10,7 @@
* INGROUP: MokoWaaS
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
* PATH: /src/packages/plg_system_mokowaas/Service/DemoResetService.php
* VERSION: 02.32.21
* VERSION: 02.32.38
* BRIEF: Content-only snapshot/restore for demo site reset
*/
+4 -134
View File
@@ -30,7 +30,7 @@
<license>GNU General Public License version 3 or later; see LICENSE.md</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.32.21</version>
<version>02.32.38</version>
<description>This plugin rebrands the Joomla system interface with MokoWaaS identity. It applies language overrides and ensures consistent branding across the platform.</description>
<namespace path=".">Moko\Plugin\System\MokoWaaS</namespace>
<scriptfile>script.php</scriptfile>
@@ -76,7 +76,9 @@
<fields name="params"
addfieldprefix="Moko\Plugin\System\MokoWaaS\Field"
>
<fieldset name="basic">
<fieldset name="basic"
label="PLG_SYSTEM_MOKOWAAS_FIELDSET_CORE_LABEL"
description="PLG_SYSTEM_MOKOWAAS_FIELDSET_CORE_DESC">
<field
name="health_api_token"
type="CopyableToken"
@@ -86,87 +88,6 @@
filter="raw"
readonly="true"
/>
<field name="dev_mode" type="radio" default="0"
label="PLG_SYSTEM_MOKOWAAS_DEV_MODE_LABEL"
description="PLG_SYSTEM_MOKOWAAS_DEV_MODE_DESC"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="reset_hits"
type="radio"
label="PLG_SYSTEM_MOKOWAAS_RESET_HITS_LABEL"
description="PLG_SYSTEM_MOKOWAAS_RESET_HITS_DESC"
default="0"
class="btn-group btn-group-yesno"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="delete_versions"
type="radio"
label="PLG_SYSTEM_MOKOWAAS_DELETE_VERSIONS_LABEL"
description="PLG_SYSTEM_MOKOWAAS_DELETE_VERSIONS_DESC"
default="0"
class="btn-group btn-group-yesno"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
<fieldset name="tenant_restrictions"
label="PLG_SYSTEM_MOKOWAAS_FIELDSET_TENANT_LABEL"
description="PLG_SYSTEM_MOKOWAAS_FIELDSET_TENANT_DESC"
>
<field name="restrict_installer" type="radio" default="1"
label="PLG_SYSTEM_MOKOWAAS_RESTRICT_INSTALLER_LABEL"
description="PLG_SYSTEM_MOKOWAAS_RESTRICT_INSTALLER_DESC"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="allow_extension_updates" type="radio" default="1"
label="PLG_SYSTEM_MOKOWAAS_ALLOW_UPDATES_LABEL"
description="PLG_SYSTEM_MOKOWAAS_ALLOW_UPDATES_DESC"
class="btn-group btn-group-yesno"
showon="restrict_installer:1">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="hide_sysinfo" type="radio" default="1"
label="PLG_SYSTEM_MOKOWAAS_HIDE_SYSINFO_LABEL"
description="PLG_SYSTEM_MOKOWAAS_HIDE_SYSINFO_DESC"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="restrict_global_config" type="radio" default="1"
label="PLG_SYSTEM_MOKOWAAS_RESTRICT_CONFIG_LABEL"
description="PLG_SYSTEM_MOKOWAAS_RESTRICT_CONFIG_DESC"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="restrict_template_editing" type="radio" default="1"
label="PLG_SYSTEM_MOKOWAAS_RESTRICT_TEMPLATE_LABEL"
description="PLG_SYSTEM_MOKOWAAS_RESTRICT_TEMPLATE_DESC"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="disable_install_url" type="radio" default="1"
label="PLG_SYSTEM_MOKOWAAS_DISABLE_INSTALL_URL_LABEL"
description="PLG_SYSTEM_MOKOWAAS_DISABLE_INSTALL_URL_DESC"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="hidden_menu_items" type="textarea"
label="PLG_SYSTEM_MOKOWAAS_HIDDEN_MENUS_LABEL"
description="PLG_SYSTEM_MOKOWAAS_HIDDEN_MENUS_DESC"
rows="5" filter="raw" />
</fieldset>
<fieldset name="demo_mode"
label="PLG_SYSTEM_MOKOWAAS_FIELDSET_DEMO_LABEL"
@@ -198,62 +119,11 @@
type="AllowedIps"
label=""
/>
<field name="force_https" type="radio" default="1"
label="PLG_SYSTEM_MOKOWAAS_FORCE_HTTPS_LABEL"
description="PLG_SYSTEM_MOKOWAAS_FORCE_HTTPS_DESC"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="admin_session_timeout" type="number"
label="PLG_SYSTEM_MOKOWAAS_SESSION_TIMEOUT_LABEL"
description="PLG_SYSTEM_MOKOWAAS_SESSION_TIMEOUT_DESC"
default="60" hint="Minutes (0 = Joomla default)" />
<field
name="current_ip_display"
type="CurrentIp"
label=""
/>
<field
name="trusted_ips"
type="subform"
label="PLG_SYSTEM_MOKOWAAS_TRUSTED_IPS_LABEL"
description="PLG_SYSTEM_MOKOWAAS_TRUSTED_IPS_DESC"
formsource="plugins/system/mokowaas/forms/trusted_ip_entry.xml"
multiple="true"
layout="joomla.form.field.subform.repeatable-table"
groupByFieldset="false"
buttons="add,remove,move"
/>
<field name="password_min_length" type="number" default="12"
label="PLG_SYSTEM_MOKOWAAS_PASSWORD_LENGTH_LABEL"
description="PLG_SYSTEM_MOKOWAAS_PASSWORD_LENGTH_DESC" />
<field name="password_require_uppercase" type="radio" default="1"
label="PLG_SYSTEM_MOKOWAAS_PASSWORD_UPPER_LABEL"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="password_require_number" type="radio" default="1"
label="PLG_SYSTEM_MOKOWAAS_PASSWORD_NUMBER_LABEL"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="password_require_special" type="radio" default="1"
label="PLG_SYSTEM_MOKOWAAS_PASSWORD_SPECIAL_LABEL"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="upload_allowed_types" type="text"
label="PLG_SYSTEM_MOKOWAAS_UPLOAD_TYPES_LABEL"
description="PLG_SYSTEM_MOKOWAAS_UPLOAD_TYPES_DESC"
default="jpg,jpeg,png,gif,webp,svg,pdf,doc,docx,xls,xlsx" />
<field name="upload_max_size_mb" type="number"
label="PLG_SYSTEM_MOKOWAAS_UPLOAD_SIZE_LABEL"
description="PLG_SYSTEM_MOKOWAAS_UPLOAD_SIZE_DESC"
default="100" />
</fieldset>
</fields>
</config>
+1 -1
View File
@@ -22,7 +22,7 @@
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* REPO: https://github.com/mokoconsulting-tech/mokowaas
* VERSION: 02.32.21
* VERSION: 02.32.38
* PATH: /src/script.php
* BRIEF: Installation script for MokoWaaS plugin
* NOTE: Handles installation, update, and uninstallation tasks including language override deployment
@@ -22,7 +22,7 @@
* DEFGROUP: Joomla.Plugin
* INGROUP: MokoWaaS
* REPO: https://github.com/mokoconsulting-tech/mokowaas
* VERSION: 02.32.21
* VERSION: 02.32.38
* PATH: /src/services/provider.php
* BRIEF: Service provider for dependency injection in Joomla 5.x
* NOTE: Registers the plugin with Joomla's DI container
@@ -8,7 +8,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.32.21</version>
<version>02.32.38</version>
<description>PLG_SYSTEM_MOKOWAAS_DEVTOOLS_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoWaaSDevTools</namespace>
@@ -8,7 +8,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.32.21</version>
<version>02.32.38</version>
<description>PLG_SYSTEM_MOKOWAAS_FIREWALL_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoWaaSFirewall</namespace>
@@ -10,12 +10,14 @@ namespace Moko\Plugin\System\MokoWaaSFirewall\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Extension\BootableExtensionInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Router\Route;
use Joomla\Event\SubscriberInterface;
use Moko\Plugin\System\MokoWaaS\Helper\MokoWaaSHelper;
use Psr\Container\ContainerInterface;
/**
* MokoWaaS Firewall Plugin
@@ -25,10 +27,29 @@ use Moko\Plugin\System\MokoWaaS\Helper\MokoWaaSHelper;
*
* @since 02.32.00
*/
class Firewall extends CMSPlugin implements SubscriberInterface
class Firewall extends CMSPlugin implements SubscriberInterface, BootableExtensionInterface
{
protected $autoloadLanguage = true;
/**
* Extend session lifetime for trusted IPs before Joomla creates the session.
*/
public function boot(ContainerInterface $container): void
{
$timeout = (int) $this->params->get('admin_session_timeout', 0);
if ($timeout <= 0)
{
return;
}
if ($this->ipIsTrusted())
{
ini_set('session.gc_maxlifetime', 315360000);
Factory::getConfig()->set('lifetime', 525600);
}
}
private const BLOCKED_FILES = [
'htaccess.txt', 'web.config.txt', 'configuration.php-dist',
'README.txt', 'LICENSE.txt', 'joomla.xml', 'robots.txt.dist',
@@ -8,7 +8,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.32.21</version>
<version>02.32.38</version>
<description>PLG_SYSTEM_MOKOWAAS_MONITOR_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoWaaSMonitor</namespace>
@@ -0,0 +1,13 @@
; MokoWaaS Terms of Service Plugin
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GPL-3.0-or-later
PLG_SYSTEM_MOKOWAAS_OFFLINE="System - MokoWaaS Offline Bypass"
PLG_SYSTEM_MOKOWAAS_OFFLINE_DESC="Keep selected pages (Terms of Service, Privacy Policy, etc.) accessible when the site is in offline mode."
PLG_SYSTEM_MOKOWAAS_OFFLINE_FIELDSET_BASIC="Offline-Accessible Pages"
PLG_SYSTEM_MOKOWAAS_OFFLINE_SLUG_LABEL="Menu Items to Keep Online"
PLG_SYSTEM_MOKOWAAS_OFFLINE_SLUG_DESC="Select menu items that remain accessible during offline mode. Hold Ctrl/Cmd for multiple."
PLG_SYSTEM_MOKOWAAS_OFFLINE_CHILDREN_LABEL="Include Child Menu Items"
PLG_SYSTEM_MOKOWAAS_OFFLINE_CHILDREN_DESC="Also allow access to child pages under the selected items."
PLG_SYSTEM_MOKOWAAS_OFFLINE_SEF_WARNING="SEF URLs are disabled - path matching requires SEF. Itemid fallback is active."
@@ -0,0 +1,3 @@
; MokoWaaS Terms of Service Plugin - System strings
PLG_SYSTEM_MOKOWAAS_OFFLINE="System - MokoWaaS Offline Bypass"
PLG_SYSTEM_MOKOWAAS_OFFLINE_DESC="Keep selected pages (Terms of Service, Privacy Policy, etc.) accessible when the site is in offline mode."
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoWaaS Offline Bypass</name>
<element>mokowaas_offline</element>
<author>Moko Consulting</author>
<creationDate>2026-06-02</creationDate>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.32.38</version>
<description>PLG_SYSTEM_MOKOWAAS_OFFLINE_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoWaaSOffline</namespace>
<files>
<folder>src</folder>
<folder>services</folder>
<folder>language</folder>
</files>
<languages folder="language">
<language tag="en-GB">en-GB/plg_system_mokowaas_offline.ini</language>
<language tag="en-GB">en-GB/plg_system_mokowaas_offline.sys.ini</language>
</languages>
<config>
<fields name="params" addfieldprefix="Moko\Plugin\System\MokoWaaSOffline\Field">
<fieldset name="basic" label="PLG_SYSTEM_MOKOWAAS_OFFLINE_FIELDSET_BASIC">
<field name="tos_slug" type="menuslug"
label="PLG_SYSTEM_MOKOWAAS_OFFLINE_SLUG_LABEL"
description="PLG_SYSTEM_MOKOWAAS_OFFLINE_SLUG_DESC"
multiple="true" />
<field name="include_children" type="radio" default="1"
label="PLG_SYSTEM_MOKOWAAS_OFFLINE_CHILDREN_LABEL"
description="PLG_SYSTEM_MOKOWAAS_OFFLINE_CHILDREN_DESC"
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
</fields>
</config>
</extension>
@@ -0,0 +1,34 @@
<?php
/**
* @package MokoWaaS
* @subpackage plg_system_mokowaas_offline
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Moko\Plugin\System\MokoWaaSOffline\Extension\Tos;
return new class implements ServiceProviderInterface
{
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
function (Container $container) {
$dispatcher = $container->get(DispatcherInterface::class);
$plugin = new Tos($dispatcher, (array) PluginHelper::getPlugin('system', 'mokowaas_offline'));
$plugin->setApplication(Factory::getApplication());
return $plugin;
}
);
}
};
@@ -0,0 +1,172 @@
<?php
/**
* @package MokoWaaS
* @subpackage plg_system_mokowaas_offline
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Plugin\System\MokoWaaSOffline\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Uri\Uri;
use Joomla\Event\SubscriberInterface;
/**
* MokoWaaS Terms of Service Plugin
*
* Allows configured menu items (Terms of Service, Privacy Policy, etc.)
* to remain accessible when the site is in offline mode.
*
* @since 02.32.00
*/
final class Tos extends CMSPlugin implements SubscriberInterface
{
protected $autoloadLanguage = true;
public static function getSubscribedEvents(): array
{
return [
'onAfterRoute' => 'onAfterRoute',
];
}
public function onAfterRoute(): void
{
$app = $this->getApplication();
if (!$app->isClient('site'))
{
return;
}
$config = $app->getConfig();
if (!$config->get('offline'))
{
return;
}
$slugs = $this->params->get('tos_slug', []);
if (\is_string($slugs))
{
$slugs = array_filter([trim($slugs)]);
}
else
{
$slugs = (array) $slugs;
}
if (empty($slugs))
{
return;
}
$includeChildren = (int) $this->params->get('include_children', 1);
if ($this->matchByPath($slugs, $config, $app, $includeChildren))
{
return;
}
$this->matchByItemId($slugs, $config, $app, $includeChildren);
}
private function matchByPath(array $slugs, $config, $app, int $includeChildren = 1): bool
{
$uri = Uri::getInstance();
$path = urldecode(trim($uri->getPath(), '/'));
$base = trim(Uri::base(true), '/');
if (!empty($base) && strpos($path, $base) === 0)
{
$path = trim(substr($path, \strlen($base)), '/');
}
if (empty($path) || $path === 'index.php')
{
return false;
}
foreach ($slugs as $slug)
{
$slug = trim((string) $slug);
if (empty($slug))
{
continue;
}
if ($path === $slug || ($includeChildren && strpos($path, $slug . '/') === 0))
{
$this->bypassOffline($config, $app);
return true;
}
}
return false;
}
private function matchByItemId(array $slugs, $config, $app, int $includeChildren = 1): bool
{
$itemId = (int) $app->getInput()->getInt('Itemid', 0);
if (!$itemId)
{
return false;
}
try
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('path'))
->from($db->quoteName('#__menu'))
->where($db->quoteName('id') . ' = ' . $itemId)
->where($db->quoteName('published') . ' = 1')
->where($db->quoteName('client_id') . ' = 0');
$db->setQuery($query);
$menuPath = trim((string) $db->loadResult(), '/');
if (empty($menuPath))
{
return false;
}
foreach ($slugs as $slug)
{
$slug = trim((string) $slug);
if (empty($slug))
{
continue;
}
if ($menuPath === $slug || ($includeChildren && strpos($menuPath, $slug . '/') === 0))
{
$this->bypassOffline($config, $app);
return true;
}
}
}
catch (\Throwable $e)
{
// Silent
}
return false;
}
private function bypassOffline($config, $app): void
{
$config->set('offline', 0);
$app->getInput()->set('tmpl', 'component');
}
}
@@ -0,0 +1,81 @@
<?php
/**
* @package MokoWaaS
* @subpackage plg_system_mokowaas_offline
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Plugin\System\MokoWaaSOffline\Field;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Form\Field\ListField;
use Joomla\CMS\Language\Text;
class MenuslugField extends ListField
{
protected $type = 'Menuslug';
protected function getOptions()
{
$options = parent::getOptions();
try
{
$sef = Factory::getApplication()->get('sef', true);
if (!$sef)
{
$options[] = (object) [
'value' => '',
'text' => Text::_('PLG_SYSTEM_MOKOWAAS_OFFLINE_SEF_WARNING'),
'disabled' => true,
];
}
}
catch (\Throwable $e)
{
// Ignore
}
try
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName(['path', 'alias', 'title', 'menutype']))
->from($db->quoteName('#__menu'))
->where($db->quoteName('published') . ' = 1')
->where($db->quoteName('client_id') . ' = 0')
->where($db->quoteName('alias') . ' != ' . $db->quote(''))
->order($db->quoteName('menutype') . ', ' . $db->quoteName('title'));
$db->setQuery($query);
$menuItems = $db->loadObjectList();
$lastMenuType = '';
foreach ($menuItems ?: [] as $item)
{
if ($item->menutype !== $lastMenuType)
{
if ($lastMenuType !== '')
{
$options[] = (object) ['value' => '', 'text' => '──────────────', 'disabled' => true];
}
$lastMenuType = $item->menutype;
}
$label = $item->title !== '' ? $item->title : ucwords(str_replace(['-', '_'], ' ', $item->alias));
$options[] = (object) ['value' => $item->path, 'text' => $label . ' (/' . $item->path . ')'];
}
}
catch (\Throwable $e)
{
// Silent
}
return $options;
}
}
@@ -8,7 +8,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.32.21</version>
<version>02.32.38</version>
<description>PLG_SYSTEM_MOKOWAAS_TENANT_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoWaaSTenant</namespace>
@@ -0,0 +1,4 @@
PLG_TASK_MOKOWAAS_TICKETS="Task - MokoWaaS Ticket Automation"
PLG_TASK_MOKOWAAS_TICKETS_DESC="Runs scheduled helpdesk automation rules."
PLG_TASK_MOKOWAAS_TICKETS_AUTOMATION_TITLE="MokoWaaS: Ticket Automation"
PLG_TASK_MOKOWAAS_TICKETS_AUTOMATION_DESC="Runs time-based automation rules against open tickets (auto-close, SLA escalation, etc.)."
@@ -0,0 +1,2 @@
PLG_TASK_MOKOWAAS_TICKETS="Task - MokoWaaS Ticket Automation"
PLG_TASK_MOKOWAAS_TICKETS_DESC="Runs scheduled helpdesk automation rules — auto-close, SLA escalation, and time-based actions."
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<extension type="plugin" group="task" method="upgrade">
<name>Task - MokoWaaS Ticket Automation</name>
<element>mokowaas_tickets</element>
<author>Moko Consulting</author>
<creationDate>2026-06-02</creationDate>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.32.38</version>
<description>Runs scheduled helpdesk automation rules — auto-close resolved tickets, SLA breach escalation, and time-based actions.</description>
<namespace path="src">Moko\Plugin\Task\MokoWaaSTickets</namespace>
<files>
<folder>src</folder>
<folder>services</folder>
<folder>language</folder>
</files>
<languages folder="language">
<language tag="en-GB">en-GB/plg_task_mokowaas_tickets.ini</language>
<language tag="en-GB">en-GB/plg_task_mokowaas_tickets.sys.ini</language>
</languages>
</extension>
@@ -0,0 +1,27 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Moko\Plugin\Task\MokoWaaSTickets\Extension\TicketAutomation;
return new class implements ServiceProviderInterface
{
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
function (Container $container) {
$dispatcher = $container->get(DispatcherInterface::class);
$plugin = new TicketAutomation($dispatcher, (array) PluginHelper::getPlugin('task', 'mokowaas_tickets'));
$plugin->setApplication(Factory::getApplication());
return $plugin;
}
);
}
};
@@ -0,0 +1,65 @@
<?php
/**
* @package MokoWaaS
* @subpackage plg_task_mokowaas_tickets
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Plugin\Task\MokoWaaSTickets\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent;
use Joomla\Component\Scheduler\Administrator\Task\Status;
use Joomla\Component\Scheduler\Administrator\Traits\TaskPluginTrait;
use Joomla\Event\SubscriberInterface;
use Moko\Component\MokoWaaS\Administrator\Model\TicketsModel;
class TicketAutomation extends CMSPlugin implements SubscriberInterface
{
use TaskPluginTrait;
protected const TASKS_MAP = [
'mokowaas.ticket.automation' => [
'langConstPrefix' => 'PLG_TASK_MOKOWAAS_TICKETS_AUTOMATION',
'method' => 'runAutomation',
],
];
protected $autoloadLanguage = true;
public static function getSubscribedEvents(): array
{
return [
'onTaskOptionsList' => 'advertiseRoutines',
'onExecuteTask' => 'standardRoutineHandler',
'onContentPrepareForm' => 'enhanceTaskItemForm',
];
}
/**
* Run all scheduled automation rules against open tickets.
*/
private function runAutomation(ExecuteTaskEvent $event): int
{
try
{
$model = new TicketsModel();
$results = $model->runScheduledAutomation();
$this->logTask(
\sprintf('Ticket automation: evaluated %d tickets, acted on %d', $results['evaluated'], $results['acted'])
);
return Status::OK;
}
catch (\Throwable $e)
{
$this->logTask('Ticket automation failed: ' . $e->getMessage(), 'error');
return Status::KNOCKOUT;
}
}
}
@@ -12,8 +12,8 @@
<license>GNU General Public License version 3 or later; see LICENSE</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.32.21</version>
<version>02.32.21</version>
<version>02.32.38</version>
<version>02.32.38</version>
<description>PLG_TASK_MOKOWAASDEMO_DESC</description>
<namespace path="src">Moko\Plugin\Task\MokoWaaSDemo</namespace>
@@ -12,7 +12,7 @@
<license>GNU General Public License version 3 or later; see LICENSE</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.32.21</version>
<version>02.32.38</version>
<description>PLG_TASK_MOKOWAASSYNC_DESC</description>
<namespace path="src">Moko\Plugin\Task\MokoWaaSSync</namespace>
@@ -7,8 +7,8 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.32.21</version>
<version>02.32.21</version>
<version>02.32.38</version>
<version>02.32.38</version>
<description>Joomla Web Services API routes for MokoWaaS site management — health checks, cache, updates, backups, and site info.</description>
<namespace path="src">Moko\Plugin\WebServices\MokoWaaS</namespace>
<files>
@@ -7,8 +7,8 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.32.21</version>
<version>02.32.21</version>
<version>02.32.38</version>
<version>02.32.38</version>
<description>Joomla Web Services API routes for Perfect Publisher (com_autotweet) — channels, posts, requests, rules, and feeds.</description>
<namespace path="src">Moko\Plugin\WebServices\PerfectPublisher</namespace>
<files>
@@ -8,7 +8,7 @@
* INGROUP: MokoWaaS
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
* PATH: /src/packages/plg_webservices_perfectpublisher/services/provider.php
* VERSION: 02.32.21
* VERSION: 02.32.38
* BRIEF: DI service provider for Perfect Publisher Web Services plugin
*/
@@ -8,7 +8,7 @@
* INGROUP: MokoWaaS
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
* PATH: /src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php
* VERSION: 02.32.21
* VERSION: 02.32.38
* BRIEF: Web Services API plugin for Perfect Publisher (com_autotweet)
*/
+3 -2
View File
@@ -2,7 +2,7 @@
<extension type="package" method="upgrade">
<name>Package - MokoWaaS</name>
<packagename>mokowaas</packagename>
<version>02.32.21</version>
<version>02.32.38</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -17,13 +17,14 @@
<file type="plugin" id="plg_system_mokowaas_firewall" group="system">plg_system_mokowaas_firewall.zip</file>
<file type="plugin" id="plg_system_mokowaas_tenant" group="system">plg_system_mokowaas_tenant.zip</file>
<file type="plugin" id="plg_system_mokowaas_devtools" group="system">plg_system_mokowaas_devtools.zip</file>
<file type="plugin" id="plg_system_mokowaas_monitor" group="system">plg_system_mokowaas_monitor.zip</file>
<file type="plugin" id="plg_system_mokowaas_offline" group="system">plg_system_mokowaas_offline.zip</file>
<file type="component" id="com_mokowaas">com_mokowaas.zip</file>
<file type="module" id="mod_mokowaas_cpanel" client="administrator">mod_mokowaas_cpanel.zip</file>
<file type="plugin" id="plg_webservices_mokowaas" group="webservices">plg_webservices_mokowaas.zip</file>
<file type="plugin" id="plg_webservices_perfectpublisher" group="webservices">plg_webservices_perfectpublisher.zip</file>
<file type="plugin" id="plg_task_mokowaasdemo" group="task">plg_task_mokowaasdemo.zip</file>
<file type="plugin" id="plg_task_mokowaassync" group="task">plg_task_mokowaassync.zip</file>
<file type="plugin" id="plg_task_mokowaas_tickets" group="task">plg_task_mokowaas_tickets.zip</file>
<file type="template" id="mokoonyx" client="site">tpl_mokoonyx.zip</file>
</files>
+312 -3
View File
@@ -34,17 +34,20 @@ class Pkg_MokowaasInstallerScript
*/
public function postflight($type, $parent)
{
// Remove legacy extensions from before the package rewrite
// Remove legacy and retired extensions
$this->cleanupLegacyExtensions();
$this->removeRetiredExtensions();
$this->migrateStandalonePlugins();
$this->enablePlugin('system', 'mokowaas');
$this->enablePlugin('system', 'mokowaas_firewall');
$this->enablePlugin('system', 'mokowaas_tenant');
$this->enablePlugin('system', 'mokowaas_devtools');
$this->enablePlugin('system', 'mokowaas_monitor');
$this->enablePlugin('system', 'mokowaas_offline');
$this->enablePlugin('webservices', 'mokowaas');
$this->enablePlugin('task', 'mokowaasdemo');
$this->enablePlugin('task', 'mokowaassync');
$this->enablePlugin('task', 'mokowaas_tickets');
// Migrate params from core plugin to feature plugins (one-time)
$this->migrateFeatureParams();
@@ -52,6 +55,9 @@ class Pkg_MokowaasInstallerScript
// Set up cpanel module on the admin dashboard
$this->setupCpanelModule();
// Create Support portal menu item on frontend
$this->setupSupportMenuItem();
// Mark MokoWaaS extensions as protected (prevents disable/uninstall at framework level)
$this->protectExtensions();
@@ -126,6 +132,196 @@ class Pkg_MokowaasInstallerScript
}
}
/**
* Remove extensions that have been retired and merged into core.
*
* plg_system_mokowaas_monitor was merged into the core plugin in 02.32.00.
* Health monitoring is now built into plg_system_mokowaas directly.
*
* @return void
*
* @since 02.32.00
*/
private function migrateStandalonePlugins(): void
{
// Migrate standalone MokoJoomTOS plugin to MokoWaaS Offline Bypass
$migrations = [
['old_element' => 'mokojoomtos', 'old_folder' => 'system', 'new_element' => 'mokowaas_offline', 'new_folder' => 'system'],
];
try
{
$db = Factory::getDbo();
foreach ($migrations as $m)
{
// Check if old plugin exists
$query = $db->getQuery(true)
->select([$db->quoteName('extension_id'), $db->quoteName('params')])
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote($m['old_element']))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote($m['old_folder']));
$db->setQuery($query);
$old = $db->loadObject();
if (!$old)
{
continue;
}
$oldParams = $old->params ?? '{}';
// Copy params to new plugin (only if new plugin has empty params)
$query = $db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote($m['new_element']))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote($m['new_folder']));
$db->setQuery($query);
$newParams = (string) $db->loadResult();
if (empty($newParams) || $newParams === '{}' || $newParams === '[]')
{
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('params') . ' = ' . $db->quote($oldParams))
->where($db->quoteName('element') . ' = ' . $db->quote($m['new_element']))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote($m['new_folder']))
)->execute();
Factory::getApplication()->enqueueMessage(
sprintf('Migrated settings from %s to %s.', $m['old_element'], $m['new_element']),
'message'
);
}
// Unprotect old plugin
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('protected') . ' = 0')
->where($db->quoteName('extension_id') . ' = ' . (int) $old->extension_id)
)->execute();
// Remove old extension record
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__extensions'))
->where($db->quoteName('extension_id') . ' = ' . (int) $old->extension_id)
)->execute();
// Remove old update site entries
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__update_sites_extensions'))
->where($db->quoteName('extension_id') . ' = ' . (int) $old->extension_id)
)->execute();
// Remove old files
$dir = JPATH_PLUGINS . '/' . $m['old_folder'] . '/' . $m['old_element'];
if (is_dir($dir))
{
$this->rmdirRecursive($dir);
}
Factory::getApplication()->enqueueMessage(
sprintf('Removed standalone %s plugin (replaced by %s).', $m['old_element'], $m['new_element']),
'message'
);
Log::add(
sprintf('Migrated %s → %s and removed old plugin', $m['old_element'], $m['new_element']),
Log::INFO,
'mokowaas'
);
}
}
catch (\Throwable $e)
{
Log::add('Standalone plugin migration error: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
}
}
/**
* Remove extensions that have been retired and merged into core.
*
* @return void
*
* @since 02.32.00
*/
private function removeRetiredExtensions(): void
{
$retired = [
['type' => 'plugin', 'folder' => 'system', 'element' => 'mokowaas_monitor'],
];
try
{
$db = Factory::getDbo();
foreach ($retired as $ext)
{
// Check if installed
$query = $db->getQuery(true)
->select($db->quoteName('extension_id'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('type') . ' = ' . $db->quote($ext['type']))
->where($db->quoteName('folder') . ' = ' . $db->quote($ext['folder']))
->where($db->quoteName('element') . ' = ' . $db->quote($ext['element']));
$db->setQuery($query);
$extId = (int) $db->loadResult();
if (!$extId)
{
continue;
}
// Unprotect so Joomla allows removal
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('protected') . ' = 0')
->where($db->quoteName('extension_id') . ' = ' . $extId)
)->execute();
// Remove extension record
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__extensions'))
->where($db->quoteName('extension_id') . ' = ' . $extId)
)->execute();
// Remove files
$dir = JPATH_PLUGINS . '/' . $ext['folder'] . '/' . $ext['element'];
if (is_dir($dir))
{
$this->rmdirRecursive($dir);
}
Factory::getApplication()->enqueueMessage(
sprintf('Removed retired extension: %s/%s', $ext['folder'], $ext['element']),
'message'
);
Log::add(
sprintf('Removed retired extension %s/%s (ID %d)', $ext['folder'], $ext['element'], $extId),
Log::INFO,
'mokowaas'
);
}
}
catch (\Throwable $e)
{
Log::add('Retired extension cleanup error: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
}
}
/**
* Recursively remove a directory.
*
@@ -211,11 +407,12 @@ class Pkg_MokowaasInstallerScript
$db->quote('mokowaas_firewall'),
$db->quote('mokowaas_tenant'),
$db->quote('mokowaas_devtools'),
$db->quote('mokowaas_monitor'),
$db->quote('mokowaas_offline'),
$db->quote('com_mokowaas'),
$db->quote('mod_mokowaas_cpanel'),
$db->quote('mokowaasdemo'),
$db->quote('mokowaassync'),
$db->quote('mokowaas_tickets'),
$db->quote('perfectpublisher'),
$db->quote('mokoonyx'),
];
@@ -507,6 +704,118 @@ class Pkg_MokowaasInstallerScript
}
}
/**
* Create a "Support" menu item on the frontend main menu.
*/
private function setupSupportMenuItem(): void
{
try
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__menu'))
->where($db->quoteName('link') . ' LIKE ' . $db->quote('%com_mokowaas&view=tickets%'))
->where($db->quoteName('client_id') . ' = 0')
);
if ((int) $db->loadResult() > 0)
{
return;
}
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('extension_id'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
);
$componentId = (int) $db->loadResult();
if (!$componentId)
{
return;
}
$db->setQuery("SELECT id FROM #__menu WHERE menutype = '' AND level = 0 AND client_id = 0 LIMIT 1");
$rootId = (int) $db->loadResult() ?: 1;
$db->setQuery('SELECT MAX(rgt) FROM #__menu WHERE client_id = 0');
$maxRgt = (int) $db->loadResult();
$item = (object) [
'menutype' => 'mainmenu',
'title' => 'Support',
'alias' => 'support',
'note' => '',
'path' => 'support',
'link' => 'index.php?option=com_mokowaas&view=tickets',
'type' => 'component',
'published' => 1,
'parent_id' => $rootId,
'level' => 1,
'component_id' => $componentId,
'checked_out' => null,
'checked_out_time' => null,
'browserNav' => 0,
'access' => 2,
'img' => '',
'template_style_id' => 0,
'params' => '{}',
'lft' => $maxRgt + 1,
'rgt' => $maxRgt + 2,
'home' => 0,
'language' => '*',
'client_id' => 0,
];
$db->insertObject('#__menu', $item, 'id');
$supportId = (int) $item->id;
// Create "Submit a Ticket" child menu item
if ($supportId)
{
$db->setQuery('SELECT MAX(rgt) FROM #__menu WHERE client_id = 0');
$maxRgt2 = (int) $db->loadResult();
$child = (object) [
'menutype' => 'mainmenu',
'title' => 'Submit a Ticket',
'alias' => 'submit-ticket',
'note' => '',
'path' => 'support/submit-ticket',
'link' => 'index.php?option=com_mokowaas&view=tickets&layout=submit',
'type' => 'component',
'published' => 1,
'parent_id' => $supportId,
'level' => 2,
'component_id' => $componentId,
'checked_out' => null,
'checked_out_time' => null,
'browserNav' => 0,
'access' => 2,
'img' => '',
'template_style_id' => 0,
'params' => '{}',
'lft' => $maxRgt2 + 1,
'rgt' => $maxRgt2 + 2,
'home' => 0,
'language' => '*',
'client_id' => 0,
];
$db->insertObject('#__menu', $child, 'id');
}
}
catch (\Throwable $e)
{
Log::add('Support menu setup error: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
}
}
/**
* One-time migration of params from the monolithic core plugin to
* the new feature plugins. Copies security, tenant, and dev params.
+23 -22
View File
@@ -1,7 +1,7 @@
<?xml version='1.0' encoding='UTF-8'?>
<!-- Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
VERSION: 02.32.21-dev
VERSION: 02.32.38-dev
-->
<updates>
@@ -11,13 +11,13 @@
<element>pkg_mokowaas</element>
<type>package</type>
<client>site</client>
<version>02.32.21-dev</version>
<creationDate>2026-06-02</creationDate>
<version>02.32.38-dev</version>
<creationDate>2026-06-03</creationDate>
<infourl title='Package - MokoWaaS'>https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/releases/tag/development</infourl>
<downloads>
<downloadurl type='full' format='zip'>https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/releases/download/development/pkg_mokowaas-02.32.21-dev.zip</downloadurl>
<downloadurl type='full' format='zip'>https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/releases/download/development/pkg_mokowaas-02.32.38-dev.zip</downloadurl>
</downloads>
<sha256>2330a2b3278587c66a4b43e4c6d5a330a7fb2d161c0df09cd266850aabb82d82</sha256>
<sha256>86a9c67320ac162c60004706d3aef824cc91a5409ba4576a8a87e9fcddea86dd</sha256>
<tags><tag>dev</tag></tags>
<changelogurl>https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/raw/branch/main/CHANGELOG.md</changelogurl>
<maintainer>Moko Consulting</maintainer>
@@ -25,21 +25,22 @@
<targetplatform name="joomla" version="(5|6)\..*" />
</update>
<update>
<name>Package - MokoWaaS</name>
<description>MokoWaaS site management suite</description>
<element>pkg_mokowaas</element>
<type>package</type>
<version>02.32.00</version>
<infourl title="MokoWaaS">https://mokoconsulting.tech</infourl>
<downloads>
<downloadurl type="full" format="zip">https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/releases/download/v02.32.00/pkg_mokowaas-02.32.00.zip</downloadurl>
</downloads>
<tags>
<tag>stable</tag>
</tags>
<maintainer>Moko Consulting</maintainer>
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
<targetplatform name="joomla" version="5\.[0-9]"/>
<php_minimum>8.1</php_minimum>
</update>
<name>Package - MokoWaaS</name>
<description>Package - MokoWaaS stable build.</description>
<element>pkg_mokowaas</element>
<type>package</type>
<client>site</client>
<version>02.33.00</version>
<creationDate>2026-06-02</creationDate>
<infourl title="Package - MokoWaaS">https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/releases/tag/stable</infourl>
<downloads>
<downloadurl type="full" format="zip">https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/releases/download/stable/pkg_mokowaas-02.33.00.zip</downloadurl>
</downloads>
<sha256>30e18531d9e453173c8945fdfaf690dacdcc3d584d827d146f0c86c851f49bb0</sha256>
<tags><tag>stable</tag></tags>
<changelogurl>https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/raw/branch/main/CHANGELOG.md</changelogurl>
<maintainer>Moko Consulting</maintainer>
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
<targetplatform name="joomla" version="(5|6)\..*"/>
</update>
</updates>