Compare commits

..

372 Commits

Author SHA1 Message Date
gitea-actions[bot] 36658fa8ca chore(version): pre-release bump to 02.34.08-dev [skip ci] 2026-06-05 01:42:18 +00:00
Jonathan Miller 5645516845 fix: custom CSS classes for admin menu indent control
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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) Successful in 6s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 22s
- .sidebar-wrapper .item-level-1 > a: padding-inline-start 1.5rem
- .mokowaas-menu-item > a: 1rem (replaces item-level-2)
- .mokowaas-menu-child > a: 1.5rem (replaces item-level-3)
Decouples from Atum's deep indentation cascade.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 20:40:32 -05:00
Jonathan Miller 4ce8c6b4ea fix: reduce indent on admin menu module level 2 and 3 items
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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) Successful in 7s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 22s
Override Atum's deep padding-left with tighter padding-inline-start
(0.5rem for level 2, 0.75rem for level 3 sub-components).

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 20:38:51 -05:00
Jonathan Miller 01056afe74 fix: menu icons use FA6 for unmapped classes, query all MokoWaaS submenu items
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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) Successful in 8s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 23s
- Helpdesk: fa-solid fa-handshake-angle (was icon-headphones, unmapped)
- .htaccess: fa-solid fa-file-code (was icon-file-code, unmapped)
- Query now finds ALL submenu items under the MokoWaaS parent menu,
  including those linking to com_plugins, com_installer, com_checkin, com_cache

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 20:33:56 -05:00
Jonathan Miller 3cc39cfa8f Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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) Successful in 6s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 24s
2026-06-04 20:32:52 -05:00
Jonathan Miller 0956757445 refactor: move security hardening to firewall plugin (#155)
Move protectPlugin(), ensureProtectedFlag(), isOurExtension() from
core to firewall. Core no longer handles extension protection —
the firewall's onAfterRoute does it. Uses MokoWaaSHelper::isMasterUser().

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 20:32:40 -05:00
gitea-actions[bot] 9c75d0254e chore(version): pre-release bump to 02.34.07-dev [skip ci] 2026-06-05 01:32:22 +00:00
Jonathan Miller c847b4a274 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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) Successful in 7s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 22s
2026-06-04 20:31:38 -05:00
Jonathan Miller c93ae27b64 fix: revert /opt/moko-platform check — DinD inner containers can't see host volumes
The act_runner DinD architecture means the job container (inner Docker)
doesn't share the outer container's volume mounts. Always clone fresh.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 20:31:28 -05:00
gitea-actions[bot] 0e28958ede chore(version): pre-release bump to 02.34.06-dev [skip ci] 2026-06-05 01:27:55 +00:00
Jonathan Miller 46bb7c31c2 refactor: rename core plugin, remove branding from support files, rename Heartbeat Token
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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 3s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 23s
- Plugin name: System - MokoWaaS Core (was System - MokoWaaS)
- Description updated to reflect admin tools suite coordinator role
- Removed updateAtumBranding() from plugin install script
- Removed brand_name placeholder from language override deployment
- Renamed "Health API Token" label to "Heartbeat Token" in language files
- Updated all .ini and .sys.ini descriptions (en-GB + en-US)

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 20:22:11 -05:00
Jonathan Miller 04af4a93a8 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 25s
2026-06-04 20:15:43 -05:00
Jonathan Miller 3b99c5b6bc fix: offline bypass redirect loop, add default bypassed pages
- Move from onAfterRoute to onAfterInitialise (earlier in lifecycle)
- Remove forced tmpl=component which broke rendering
- Default bypassed pages: terms-of-service, privacy-policy,
  community-guidelines, support, tickets, submit-a-ticket

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 20:15:12 -05:00
gitea-actions[bot] 1b47876a6c chore(version): pre-release bump to 02.34.05-dev [skip ci] 2026-06-05 01:09:11 +00:00
gitea-actions[bot] 48ff2b2109 chore(version): pre-release bump to 02.34.04-dev [skip ci] 2026-06-05 01:01:18 +00:00
Jonathan Miller 0c4857d6e0 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 23s
2026-06-04 19:57:33 -05:00
Jonathan Miller 9f3e4b9d31 fix: cpanel collapse toggle as caret button on far left
Replaced clickable header with a dedicated caret button (fa-caret-right/down)
on the far left that toggles the collapse. Dashboard button pushed to far right.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 19:57:22 -05:00
gitea-actions[bot] 3834ba4c1c chore(version): pre-release bump to 02.34.03-dev [skip ci] 2026-06-05 00:56:40 +00:00
Jonathan Miller a8a41e9bad fix: replace smart quotes with ASCII in workflow files
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 23s
Unicode left/right quotes caused git clone to fail with
"protocol 'https' is not supported" inside Docker runner.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 19:56:20 -05:00
Jonathan Miller 8c927b0a1b fix: remove stray chevron icon from cpanel module header
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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 3s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 24s
The icon-chevron-down appeared as random noise when the card was
collapsed. The entire header row is already clickable as a collapse
toggle — no separate indicator needed.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 19:05:49 -05:00
Jonathan Miller 21e57eaadc fix: override admin Help sidebar link to mokoconsulting.tech/support
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 30s
Targets a[href*="dashboard=help"] in addition to existing
help.joomla.org/docs.joomla.org overrides. Opens in new window.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 18:51:42 -05:00
Jonathan Miller fadd3a01cd fix: load component sys.ini language files for admin menu translation
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 23s
Text::_() returned raw keys (COM_MOKOBACKUP, COM_MOKOJOOMCOMMUNITY)
because Joomla hadn't loaded those components' language files yet.
Now loads both .sys.ini and .ini for each discovered component.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 18:48:00 -05:00
Jonathan Miller 95097c4d3f ci: use pre-installed /opt/moko-platform on runner, fallback to clone
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 23s
All workflows check for /opt/moko-platform first (updated by cron
every 6h). Falls back to fresh clone if not available. Eliminates
composer install timeouts that were causing build failures.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 18:42:01 -05:00
Jonathan Miller e71b075d94 ci: remove updates.xml steps from pre-release (MokoGitea handles it)
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 22s
MokoGitea license server generates updates.xml dynamically.
Removed: updates_xml_build.php call, updates.xml commit/push,
updates.xml branch sync. These are no longer needed.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 17:56:07 -05:00
Jonathan Miller 1ecc8be8d1 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 21s
2026-06-04 17:51:06 -05:00
Jonathan Miller 361a58f8cd fix: self-heal orphaned update records (extension_id=0) in postflight
Joomla's update finder sometimes fails to link #__updates records
to the installed extension. fixUpdateRecords() joins on element+type
to set the correct extension_id.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 17:50:52 -05:00
gitea-actions[bot] 51ac178281 chore: update development channel 02.34.02-dev [skip ci] 2026-06-04 22:35:57 +00:00
gitea-actions[bot] b46da78e6c chore(version): pre-release bump to 02.34.02-dev [skip ci] 2026-06-04 22:35:54 +00:00
Jonathan Miller 57a54e8959 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 25s
2026-06-04 17:34:59 -05:00
Jonathan Miller 1c8625f828 feat: admin menu auto-discovers Moko component submenus from #__menu
Queries #__menu for all com_moko* components (except com_mokowaas).
Renders each as a collapsible parent with its submenu items nested
at level 3. Icons and titles loaded from the DB menu records.
Uses Text::_() for language key translation.

MokoJoomBackup, MokoJoomCommunity, MokoJoomCross, etc. automatically
appear with their full submenu when installed — zero config needed.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 17:34:39 -05:00
gitea-actions[bot] 66b19f184c chore: update development channel 02.34.01-dev [skip ci] 2026-06-04 22:31:43 +00:00
gitea-actions[bot] 4694e67e1c chore(version): pre-release bump to 02.34.01-dev [skip ci] 2026-06-04 22:31:41 +00:00
Jonathan Miller e2e2ac8b56 chore: remove temp files [skip ci] 2026-06-04 17:31:22 -05:00
Jonathan Miller 415eeaac56 chore: minor version bump to 02.34.00 [skip ci]
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 17:31:12 -05:00
gitea-actions[bot] 4c8bb93952 chore: update development channel 02.33.03-dev [skip ci] 2026-06-04 22:24:56 +00:00
gitea-actions[bot] 561fdcd881 chore(version): pre-release bump to 02.33.03-dev [skip ci] 2026-06-04 22:24:54 +00:00
Jonathan Miller 0d096acfa8 fix: use Joomla native CacheControllerFactory for cache clearing
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 25s
Replace manual file deletion with Joomla's built-in cache API
(CacheControllerFactoryInterface::createCacheController->clean).
Same approach as com_cache uses. Cleans both site and admin cache.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 17:22:28 -05:00
Jonathan Miller 3db14d29ef Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 26s
2026-06-04 17:18:26 -05:00
Jonathan Miller dfff3c327f fix: remove Factory::getCache()->gc() from clearCache (crashes in Joomla 6)
File-based cache cleanup already handles purging. The gc() calls
used a Joomla 5 API that may not work in Joomla 6, causing the
AJAX response to fail and show an error icon.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 17:18:13 -05:00
gitea-actions[bot] 4ab3b163f6 chore: update development channel 02.33.02-dev [skip ci] 2026-06-04 22:06:19 +00:00
gitea-actions[bot] 60910c2b8b chore(version): pre-release bump to 02.33.02-dev [skip ci] 2026-06-04 22:06:18 +00:00
Jonathan Miller 4e0151be1b feat: add dlid (license key) and blockChildUninstall to package manifest
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 35s
- dlid prefix="dlid=" tells Joomla to send license key with update requests
- blockChildUninstall prevents removing sub-extensions without full package uninstall

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 17:02:19 -05:00
Jonathan Miller 36d958f31f refactor: pre-release uses CLI version_bump.php instead of inline shell math
Delegates version bumping to moko-platform CLI tools:
- version_bump.php (patch default, --minor for RC)
- version_set_platform.php (stability suffix)
- version_check.php (consistency)

This keeps the workflow thin and the logic in the shared CLI.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 16:45:41 -05:00
Jonathan Miller e482a293c9 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 24s
2026-06-04 16:43:42 -05:00
Jonathan Miller 04a4bf8aba fix: pre-release bumps patch for dev/alpha/beta so each build gets unique version
Without this, repeated dev releases produce the same version and
Joomla won't offer the update (version not higher than installed).

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 16:43:41 -05:00
gitea-actions[bot] b3082f27e3 chore: update development channel 02.33.01-dev [skip ci] 2026-06-04 21:32:23 +00:00
Jonathan Miller f30d7dd7af Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Universal: Auto Version Bump / Version Bump (push) Failing after 3s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 28s
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
2026-06-04 16:31:42 -05:00
Jonathan Miller ecd5b6c786 feat: create privacy data requests from backend with auto-process option
- Add collapsible "New Request" form to privacy view (user select, type, auto-process)
- Controller handles 'create' action (pending) and 'approve' without request_id (auto-process: create + immediate approve)
- User dropdown populated from #__users

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 16:31:32 -05:00
gitea-actions[bot] 1f7419f33d chore: update development channel 02.33.01-dev [skip ci] 2026-06-04 19:29:29 +00:00
Jonathan Miller 171f489e3d feat: add Open button to extensions manager for installed components
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Universal: Auto Version Bump / Version Bump (push) Failing after 3s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 22s
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Shows an "Open" link to the component dashboard for installed
components and packages that have a com_ admin directory.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 14:28:48 -05:00
Jonathan Miller e808a168cb Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 26s
2026-06-04 14:26:54 -05:00
Jonathan Miller 1f89a323d5 feat: auto-discover installed Moko extensions in admin sidebar menu
Static MokoWaaS views listed first, then installed Moko components
auto-discovered from #__extensions. Supported: MokoJoomBackup,
MokoJoomCommunity, MokoJoomCalendar, MokoJoomGallery, MokoJoomCross,
Akeeba Backup, Akeeba Ticket System. New extensions appear automatically.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 14:26:47 -05:00
gitea-actions[bot] 329eca3db6 chore: update development channel 02.33.01-dev [skip ci] 2026-06-04 19:09:51 +00:00
Jonathan Miller 42b47be564 Merge remote-tracking branch 'origin/main' into dev
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 3s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Failing after 5s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 6s
Universal: PR Check / Validate PR (pull_request) Failing after 8s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request_target) Failing after 10s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 27s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
# Conflicts:
#	.mokogitea/workflows/pre-release.yml
#	updates.xml
2026-06-04 14:06:33 -05:00
Jonathan Miller 79ac068bc4 feat: MokoWaaS guided tours — hijack Joomla's system, rebrand to MokoWaaS
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 24s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 8s
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Universal: PR Check / Validate PR (pull_request) Failing after 8s
Generic: Repo Health / Site Health (push) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Successful in 12s
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request_target) Failing after 14s
Universal: Auto Version Bump / Version Bump (push) Failing after 4s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 32s
- Unpublish all default Joomla guided tours (joomla-* UIDs)
- Create 4 MokoWaaS tours: Welcome, Firewall Setup, Helpdesk, Extensions
- Re-enable mod_guidedtours with title "MokoWaaS Tours"
- Add language override MOD_GUIDEDTOURS → "MokoWaaS Tours"
- Re-enable guided tours plugin if disabled

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 14:04:02 -05:00
Jonathan Miller ee7260b435 fix: change /kb/ URLs to /support/products/ across all references
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 25s
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 13:53:39 -05:00
Jonathan Miller 9498a56f98 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 21s
2026-06-04 13:50:22 -05:00
Jonathan Miller 8ea6df020b refactor: remove branding/identity from core plugin (#154)
Remove 8 branding methods and 4 call sites (-428 lines):
- enforceAtumBranding(), hexToHsl(), injectFavicon()
- enforceLoginSupportUrls(), loadLanguageOverrides()
- getPlaceholders(), parseLanguageFile(), setTemplateParam()

MokoWaaS is an admin tools suite, not a branding layer.
Core plugin docblock updated to reflect new purpose.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 13:50:12 -05:00
gitea-actions[bot] b304d6c9a2 chore: update development channel 02.33.01-dev [skip ci] 2026-06-04 18:24:08 +00:00
gitea-actions[bot] 557c15cbe0 chore(version): pre-release bump to 02.33.01-dev [skip ci] 2026-06-04 18:24:07 +00:00
Jonathan Miller 524523b8c6 fix: Chart.js CDN with SRI hash, rename Community Builder to MokoJoomCommunity
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 21s
Chart.js not bundled with Joomla 6 — use jsdelivr CDN with integrity check.
Dashboard user manager button shows MokoJoomCommunity (not Community Builder).

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 13:23:18 -05:00
Jonathan Miller e858130375 fix: hide cpanel module on MokoWaaS dashboard (redundant info)
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 23s
The dashboard already shows site info, plugins, WAF blocks, logins,
and updates. The cpanel module duplicates this. Now it auto-hides
when option=com_mokowaas and view is dashboard or empty.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 13:20:15 -05:00
Jonathan Miller f0e2228700 feat: dashboard charts, visual separator, remove small class
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 26s
- Add WAF activity bar chart and login activity line chart (Chart.js, 14 days)
- Add border-left separator between plugin grid and info panel
- Remove all 'small' CSS class usage for better readability
- Add getWafBlocksByDay() and getLoginsByDay() to DashboardModel

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 13:14:09 -05:00
jmiller c5aef3c939 chore: sync updates.xml SHA256 from dev [skip ci] 2026-06-04 17:57:03 +00:00
Jonathan Miller f401a76227 chore: changelog [Unreleased] workflow, CI gate, contributing docs
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 23s
- CHANGELOG.md now uses [Unreleased] section as staging area
- Only minor version headings (no dev patch numbers)
- pr-check.yml blocks PRs to main if [Unreleased] is empty
- pre-release.yml and auto-release.yml extract from [Unreleased] for release notes
- CONTRIBUTING.md documents the changelog workflow
- RC pre-release bumps minor version consolidating dev patches

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 12:53:04 -05:00
Jonathan Miller 71133cdc24 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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 3s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 23s
2026-06-04 12:33:03 -05:00
Jonathan Miller 7bd9213ec5 ci: RC pre-release bumps minor version, consolidating dev patches
Dev patches (02.33.01, 02.33.02, etc.) exist for the update system.
RC consolidates to next minor: 02.33.xx → 02.34.00-rc.
auto-release already uses --bump minor for stable.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 12:33:01 -05:00
gitea-actions[bot] 5ca1eb98a8 chore: update development channel 02.33.01-dev [skip ci] 2026-06-04 17:31:12 +00:00
Jonathan Miller 65a6cdf505 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Universal: Auto Version Bump / Version Bump (push) Failing after 3s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 20s
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
2026-06-04 12:28:56 -05:00
Jonathan Miller 5ab21a0fac ci: extract changelog into release notes for both pre-release and auto-release
Adds a step after release creation that reads CHANGELOG.md, extracts
the latest version section, and PATCHes it onto the Gitea release body.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 12:28:41 -05:00
gitea-actions[bot] 0a5d43e12b chore: update development channel 02.33.01-dev [skip ci] 2026-06-04 17:24:49 +00:00
Jonathan Miller 92b32dd924 fix: remove tpl_mokoonyx from package manifest
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Universal: Auto Version Bump / Version Bump (push) Failing after 4s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 23s
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
MokoOnyx is a separate repo with its own release pipeline.
Bundling it as a submodule causes "Install path does not exist"
because the build doesn't create the ZIP correctly from submodule content.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 12:23:37 -05:00
jmiller 0d96174f75 chore: shorten update server name [skip ci] 2026-06-04 17:20:12 +00:00
Jonathan Miller 27959a0afe chore: shorten update server name to Package - MokoWaaS [skip ci] 2026-06-04 12:19:26 -05:00
jmiller 6acae6d20f chore: update server pretty name [skip ci] 2026-06-04 17:15:56 +00:00
Jonathan Miller 9a8b3b53fc Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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 3s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 28s
# Conflicts:
#	updates.xml
2026-06-04 12:15:35 -05:00
Jonathan Miller eae734afca chore: update server pretty name - MokoWaaS - Moko Consulting Admin Tools Suite
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 12:15:05 -05:00
gitea-actions[bot] 1a42a71852 chore: update development channel 02.33.01-dev [skip ci] 2026-06-04 17:05:14 +00:00
Jonathan Miller 3976ce78c3 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Universal: Auto Version Bump / Version Bump (push) Failing after 3s
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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 23s
2026-06-04 12:04:52 -05:00
Jonathan Miller 9c4d9f060e chore: remove dev-release.yml, pre-release.yml handles dev builds [skip ci] 2026-06-04 12:04:39 -05:00
gitea-actions[bot] dfa38b6e0e chore(release): build 02.33.01-dev [skip ci] 2026-06-04 17:04:29 +00:00
Jonathan Miller c862a01a0f fix: dev-release stability value is 'dev' not 'development' [skip ci] 2026-06-04 12:04:10 -05:00
jmiller 9dacc01a67 chore: recreate updates.xml [skip ci] 2026-06-04 16:59:51 +00:00
Jonathan Miller e76248a1c9 chore: recreate updates.xml with stable and development entries
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 22s
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 11:59:42 -05:00
Jonathan Miller d30f8eb0db Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 26s
2026-06-04 11:30:53 -05:00
Jonathan Miller ef654ad3fc ci: add dev-release workflow for pre-release builds from dev
Manual dispatch workflow that builds package from dev branch and
uploads to "development" release tag. No static updates.xml —
MokoGitea generates the update feed dynamically.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 11:30:42 -05:00
gitea-actions[bot] c7d914f786 fix: ensure all pre-releases marked prerelease=true [skip ci] 2026-06-04 16:10:48 +00:00
jmiller 729aa3850d chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-04 15:57:59 +00:00
jmiller 6b9a0867ac chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-04 15:56:13 +00:00
jmiller f6c73c4f82 chore: standardize updateservers URL [skip ci] 2026-06-04 15:48:59 +00:00
Jonathan Miller b24e4e097b Merge remote-tracking branch 'origin/main' into dev
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Failing after 5s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 5s
Universal: PR Check / Validate PR (pull_request) Failing after 8s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request_target) Failing after 9s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 24s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 26s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 11s
# Conflicts:
#	.mokogitea/manifest.xml
#	.mokogitea/workflows/issue-branch.yml
#	CHANGELOG.md
#	CODE_OF_CONDUCT.md
#	GOVERNANCE.md
#	LICENSE.md
#	README.md
#	SECURITY.md
#	docs/guides/build-guide.md
#	docs/guides/configuration-guide.md
#	docs/guides/installation-guide.md
#	docs/guides/operations-guide.md
#	docs/guides/rollback-and-recovery-guide.md
#	docs/guides/testing-guide.md
#	docs/guides/troubleshooting-guide.md
#	docs/guides/upgrade-and-versioning-guide.md
#	docs/index.md
#	docs/plugin-basic.md
#	docs/update-server.md
#	src/packages/com_mokowaas/mokowaas.xml
#	src/packages/mod_mokowaas_cpanel/mod_mokowaas_cpanel.xml
#	src/packages/plg_system_mokowaas/Extension/MokoWaaS.php
#	src/packages/plg_system_mokowaas/Field/AllowedIpsField.php
#	src/packages/plg_system_mokowaas/Field/CopyableTokenField.php
#	src/packages/plg_system_mokowaas/Field/CurrentIpField.php
#	src/packages/plg_system_mokowaas/Field/DemoTaskInfoField.php
#	src/packages/plg_system_mokowaas/Field/NextResetField.php
#	src/packages/plg_system_mokowaas/Field/SnapshotTablesField.php
#	src/packages/plg_system_mokowaas/Service/ContentSyncReceiver.php
#	src/packages/plg_system_mokowaas/Service/ContentSyncService.php
#	src/packages/plg_system_mokowaas/Service/DemoResetService.php
#	src/packages/plg_system_mokowaas/mokowaas.xml
#	src/packages/plg_system_mokowaas/script.php
#	src/packages/plg_system_mokowaas/services/provider.php
#	src/packages/plg_system_mokowaas_devtools/mokowaas_devtools.xml
#	src/packages/plg_system_mokowaas_firewall/mokowaas_firewall.xml
#	src/packages/plg_system_mokowaas_monitor/mokowaas_monitor.xml
#	src/packages/plg_system_mokowaas_tenant/mokowaas_tenant.xml
#	src/packages/plg_task_mokowaasdemo/mokowaasdemo.xml
#	src/packages/plg_task_mokowaassync/mokowaassync.xml
#	src/packages/plg_webservices_mokowaas/mokowaas.xml
#	src/packages/plg_webservices_perfectpublisher/perfectpublisher.xml
#	src/packages/plg_webservices_perfectpublisher/services/provider.php
#	src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php
#	src/pkg_mokowaas.xml
2026-06-04 10:43:55 -05:00
Jonathan Miller 6fa3f4fa82 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Failing after 5s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 6s
Universal: PR Check / Validate PR (pull_request) Failing after 9s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request_target) Failing after 10s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 27s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 28s
2026-06-04 10:41:30 -05:00
Jonathan Miller e01167f679 fix: preflight ALTER for #__extensions.element default (strict mode)
Joomla 6 + MySQL STRICT_TRANS_TABLES: the element column is NOT NULL
with no default. Package installer creates placeholder rows before
processing sub-extension manifests, causing INSERT failures. preflight()
adds DEFAULT '' so the INSERT succeeds.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 10:41:26 -05:00
jmiller d4176836a5 chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-04 15:40:27 +00:00
jmiller 375d11c199 chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-04 15:38:22 +00:00
jmiller ef9d98ea04 chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-04 15:31:48 +00:00
Jonathan Miller 6d29e9a853 fix: migrate MokoJoomTOS settings before retiring the plugin
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 24s
Swap order: migrateStandalonePlugins() now runs before
removeRetiredExtensions() so TOS params are copied to
mokowaas_offline before the old plugin is deleted.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 10:31:43 -05:00
Jonathan Miller fea6ae9f0a Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 24s
2026-06-04 10:30:19 -05:00
Jonathan Miller 3f69fe6fc1 feat: retire MokoJoomTOS, MokoATS-Automation, MokoDPCalendarAPI, MokoGalleryCalendar
Add to removeRetiredExtensions(): uninstalls plugins, removes update sites,
and deletes files on MokoWaaS install/update. These features are now built
into MokoWaaS directly.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 10:30:09 -05:00
jmiller 5d303287c0 chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-04 15:28:49 +00:00
jmiller ffecdc4796 chore: remove updates.xml [skip ci] 2026-06-04 15:27:15 +00:00
Jonathan Miller b32a7c12e7 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 22s
# Conflicts:
#	updates.xml
2026-06-04 10:21:17 -05:00
Jonathan Miller 64eade2589 chore: remove update-server workflow and static updates.xml, update changelog
- Remove update-server.yml — MokoGitea generates update feed dynamically
- Remove static updates.xml — no longer needed
- Fix cache cleaner CSRF: send token as POST FormData
- Update CHANGELOG.md with all 02.33.00 changes

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 10:20:54 -05:00
jmiller f1dbc10e4d chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-04 15:18:39 +00:00
jmiller 1289ef81b2 chore: sync updates.xml from development [skip ci] 2026-06-04 15:15:36 +00:00
gitea-actions[bot] 81781e393d chore: update development channel 02.32.52 [skip ci] 2026-06-04 15:15:35 +00:00
gitea-actions[bot] bd403e4617 chore(version): auto-bump 02.32.52 [skip ci] 2026-06-04 15:15:34 +00:00
Jonathan Miller 7c6d8a1b65 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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 33s
2026-06-04 10:15:17 -05:00
Jonathan Miller 31e1843fe1 feat: migrate all Moko update server URLs, fix menu icons
- Add migrateUpdateServerUrls() to postflight: rewrites /raw/branch/main/updates.xml to /updates.xml for all Moko extensions
- Fix Helpdesk icon to fa-handshake-angle, .htaccess to fa-solid fa-file-code (unmapped in joomla-fontawesome.css)

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 10:15:07 -05:00
jmiller 1ab8230191 chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-04 15:13:19 +00:00
jmiller 070df8982b chore: sync updates.xml from development [skip ci] 2026-06-04 14:53:06 +00:00
gitea-actions[bot] 07fd04d27e chore: update development channel 02.32.51 [skip ci] 2026-06-04 14:53:06 +00:00
gitea-actions[bot] 8d4a302730 chore(version): auto-bump 02.32.51 [skip ci] 2026-06-04 14:53:04 +00:00
Jonathan Miller 2fbaf09e88 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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 11s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 24s
2026-06-04 09:52:49 -05:00
Jonathan Miller 36082bd2e3 fix: cache button native Atum markup, tickets unassigned HTML encoding
- Cache cleaner button uses native header-item-content/header-item-icon markup
- Fix <em>Unassigned</em> showing raw HTML in tickets list (escape only non-null values)

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 09:52:41 -05:00
gitea-actions[bot] 99179ad245 chore: sync pre-release workflow — auto RC on PR to main [skip ci] 2026-06-04 14:45:41 +00:00
jmiller 0fccd3f1a4 chore: sync updates.xml from development [skip ci] 2026-06-04 14:39:03 +00:00
gitea-actions[bot] 3bc1e66acf chore: update development channel 02.32.50 [skip ci] 2026-06-04 14:39:02 +00:00
gitea-actions[bot] dcf115e572 chore(version): auto-bump 02.32.50 [skip ci] 2026-06-04 14:39:00 +00:00
Jonathan Miller 75f73b0dff Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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 13s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 25s
2026-06-04 09:38:31 -05:00
Jonathan Miller 30a6f6607a fix: add fixMenuIcons to postflight for submenu icon params
Joomla only renders img column icons for level-1 menu items. Level 2+
need menu_icon in the params JSON. This runs on every install/update.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 09:38:05 -05:00
jmiller ef873bda3b chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-06-04 14:23:05 +00:00
gitea-actions[bot] a2006c2287 chore: update development channel 02.32.49 [skip ci] 2026-06-04 14:21:50 +00:00
jmiller 3243ecba4a chore: sync updates.xml from development [skip ci] 2026-06-04 14:21:50 +00:00
gitea-actions[bot] 0552c0a0b0 chore(version): auto-bump 02.32.49 [skip ci] 2026-06-04 14:21:48 +00:00
Jonathan Miller 8de7b473a8 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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 26s
2026-06-04 09:21:32 -05:00
Jonathan Miller 130aa26f27 fix: admin menu native classes, sys.ini to global language dir for menu icons
- Rewrite mod_mokowaas_menu template to use native MetisMenu classes (item, has-arrow, mm-collapse, mm-active, sidebar-item-title) — no custom CSS/JS
- Add <languages> element to component manifest so sys.ini deploys to administrator/language/en-GB/ for menu title translation

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 09:21:14 -05:00
jmiller 3f6a7af83e chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-06-04 14:20:17 +00:00
jmiller b8083203e9 chore: sync updates.xml from development [skip ci] 2026-06-04 14:10:24 +00:00
gitea-actions[bot] 290fc0fb99 chore: update development channel 02.32.48 [skip ci] 2026-06-04 14:10:24 +00:00
gitea-actions[bot] de7a945470 chore(version): auto-bump 02.32.48 [skip ci] 2026-06-04 14:10:22 +00:00
Jonathan Miller 7d9dbe702b Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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 25s
2026-06-04 09:09:56 -05:00
Jonathan Miller 1819fa276c feat: collapsible admin menu, cache cleaner status module, update server URL
- Admin menu module now collapses by default, auto-expands on MokoWaaS pages (CB-style)
- Add mod_mokowaas_cache: one-click cache cleaner button in admin status bar (replaces Regular Labs Cache Cleaner)
- clearCache now purges all cache files (not just expired), matching RL behavior
- Update server URL changed to https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/updates.xml
- Add setupCacheModule() to package install script

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 09:09:38 -05:00
jmiller 31a4d12ceb chore: sync .mokogitea/workflows/repo-health.yml from moko-platform [skip ci] 2026-06-04 13:46:52 +00:00
jmiller 9fedffe570 chore: sync updates.xml from development [skip ci] 2026-06-04 12:55:58 +00:00
gitea-actions[bot] 8903af5d7f chore: update development channel 02.32.47 [skip ci] 2026-06-04 12:55:57 +00:00
gitea-actions[bot] 1c7738e276 chore(version): auto-bump 02.32.47 [skip ci] 2026-06-04 12:55:56 +00:00
Jonathan Miller 234c6037c0 feat: database tools, cache cleanup, admin menu update, license key warning moved to postflight
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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 13s
Update Server / Update Server (push) Successful in 25s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 1m13s
- Add database tools view (view=database) with table status, optimize, repair, session purge (#127)
- Add cache cleanup view (view=cleanup) with directory size reporting and one-click cleanup (#128)
- Add Database Tools and Cache Cleanup links to admin sidebar menu module
- Add MokoWaaS-specific update badge (blue) separate from other updates in cpanel module
- Add SSL certificate expiry monitoring to cpanel module (#148)
- Move license key warning from every-page onAfterRoute to package postflight (install/update only)
- Add privacy and waflog menu entries to component manifest submenu

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 07:55:16 -05:00
Jonathan Miller 055562b06a fix: merge + stash pop [skip ci]
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 07:38:21 -05:00
Jonathan Miller f057f0ba86 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev 2026-06-04 07:38:18 -05:00
Jonathan Miller 500644bc8d fix: license key warning shows on every admin page load, non-dismissable
Restored warnMissingLicenseKey() in onAfterRoute. No session caching —
the warning fires on every admin page load if no download key (dlid)
is configured in the MokoWaaS update site. Only shown to master users.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 07:36:57 -05:00
jmiller 47e3802293 chore: sync updates.xml from development [skip ci] 2026-06-04 12:24:44 +00:00
gitea-actions[bot] a30db55024 chore: update development channel 02.32.46 [skip ci] 2026-06-04 12:24:44 +00:00
gitea-actions[bot] 53dec689b3 chore(version): auto-bump 02.32.46 [skip ci] 2026-06-04 12:24:42 +00:00
Jonathan Miller 861086bf33 fix: code review — 10 security and quality fixes
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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 57s
#1  exportSettings: added CSRF token check (Session::checkToken('get'))
#2  All controllers: added explicit return after jsonForbidden() and
    early-exit jsonResponse() calls — prevents execution fallthrough
#3  Delete/update handlers: return validation implicit via getInt
#6  WAF scanInput: double urldecode to catch %25xx encoding tricks
#7  PrivacyModel anonymize: now clears #__user_profiles and
    #__contact_details (GDPR completeness)
#12 SLA responded: only marked on staff replies, not customer self-replies
    — prevents customers from clearing their own SLA timer
#13 MokoWaaSHelper: getMasterUsernames() changed to private static
    — prevents third-party code from accessing decoded master usernames

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 07:24:33 -05:00
Jonathan Miller ca2160d42f chore: remove duplicate <version> tags from 3 manifests (#120) [skip ci]
CI auto-bump was inserting duplicate version lines. Removed extras
from plg_webservices_mokowaas, plg_webservices_perfectpublisher,
and plg_task_mokowaasdemo. All 14 extensions now at 02.32.45.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 07:15:11 -05:00
Jonathan Miller d193d0992e Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (push) Has been skipped
Universal: Auto Version Bump / Version Bump (push) Failing after 3s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 1m6s
2026-06-04 07:09:09 -05:00
Jonathan Miller 0620ffd735 feat: expanded automation — Joomla event triggers, create_ticket action, behavior options (#151)
New trigger events hooked into core plugin:
- user_login — fires on successful Joomla login
- user_register — fires on new user creation
- user_login_failed — fires on failed login attempt

New action type: create_ticket with behavior options:
- append: add reply to existing open ticket (same user+category)
- always_new: always create a new ticket
- skip_if_open: do nothing if open ticket exists

New method: runSystemEventAutomation() for non-ticket events
that builds a virtual context object from event data.

Automation rules can now create tickets from any system event,
with intelligent deduplication to avoid ticket spam.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 07:08:59 -05:00
jmiller 76fe9ba311 chore: sync updates.xml from development [skip ci] 2026-06-04 12:05:57 +00:00
gitea-actions[bot] 0b49a959f4 chore: update development channel 02.32.45 [skip ci] 2026-06-04 12:05:56 +00:00
gitea-actions[bot] 72e5e31a31 chore(version): auto-bump 02.32.45 [skip ci] 2026-06-04 12:05:53 +00:00
Jonathan Miller 1389c26895 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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 8s
Update Server / Update Server (push) Successful in 21s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 40s
2026-06-04 07:05:33 -05:00
Jonathan Miller 69776d9b77 feat: MokoWaaS admin sidebar menu module (like CB's mod_cbadmin)
New module mod_mokowaas_menu renders a dedicated MokoWaaS section
in the admin sidebar at position=menu, ordering=0 (before CB at 1
and Joomla's mod_menu at 2).

Menu items: Dashboard, Helpdesk, Extensions, .htaccess Maker,
Privacy Guard, WAF Log, Feature Plugins

Active state highlighting on current view.
Auto-created on package install with access=Special.
Added to package manifest.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 07:05:27 -05:00
jmiller 806a798b87 chore: sync updates.xml from development [skip ci] 2026-06-04 12:02:13 +00:00
gitea-actions[bot] 6f7495703c chore: update development channel 02.32.44 [skip ci] 2026-06-04 12:02:12 +00:00
gitea-actions[bot] 9cb49ec4b9 chore(version): auto-bump 02.32.44 [skip ci] 2026-06-04 12:02:11 +00:00
Jonathan Miller ade768b94c Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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 12s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 1m9s
2026-06-04 07:01:55 -05:00
Jonathan Miller d3561dd5c9 feat: Part 1 batch 2 — helpdesk admin UIs (#137, #138, #139)
Ticket Categories admin (view=categories):
- Inline editable table with SLA times, auto-assign user
- Add/save/delete with AJAX
- SLA response + resolution minutes per category

Canned Responses admin (view=canned):
- Card list with title + preview
- Modal form for new responses with category filter
- Delete with AJAX

Automation Rules admin (view=automation):
- Card list with conditions + actions displayed inline
- Enable/disable toggle per rule
- Modal form with trigger, conditions JSON, actions JSON
- Delete with AJAX

Controller tasks: saveCategory, deleteCategory, saveCanned,
deleteCanned, saveAutomation, deleteAutomation, toggleAutomation

All ACL-protected (tickets ACL for categories/canned, core.admin
for automation rules).

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 07:01:46 -05:00
jmiller d899bf945e chore: sync updates.xml from development [skip ci] 2026-06-04 11:44:54 +00:00
gitea-actions[bot] 6ca195fd9f chore: update development channel 02.32.43 [skip ci] 2026-06-04 11:44:54 +00:00
gitea-actions[bot] abd7a4a35e chore(version): auto-bump 02.32.43 [skip ci] 2026-06-04 11:44:52 +00:00
Jonathan Miller 788c516fd6 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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 32s
2026-06-04 06:44:37 -05:00
Jonathan Miller 2919722dab feat: Part 1 batch — security headers, auto-ban, SSL monitor, IP display, settings export
#118 Display current IP on dashboard info bar
#124 HTTP security headers at runtime (X-Frame, X-Content-Type, X-XSS,
     Referrer-Policy, HSTS, CSP, Permissions-Policy) — new firewall
     fieldset with per-header toggles
#143 WAF auto-ban: threshold + window params, auto-adds to IP blocklist
     after N blocks in M minutes
#148 SSL certificate expiry monitoring in cpanel module (green/yellow/red
     badge with days remaining)
#132 Settings import/export — export all plugin + component params as
     JSON, import on another site

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 06:44:14 -05:00
jmiller 6892b6ac44 chore: sync updates.xml from development [skip ci] 2026-06-04 04:42:50 +00:00
gitea-actions[bot] 46a9701b62 chore: update development channel 02.32.42 [skip ci] 2026-06-04 04:42:49 +00:00
gitea-actions[bot] 4b4d5c714b chore(version): auto-bump 02.32.42 [skip ci] 2026-06-04 04:42:48 +00:00
Jonathan Miller 645fbc66c6 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
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
2026-06-03 23:42:29 -05:00
Jonathan Miller 8f936fc92c feat: WAF log viewer with filters, one-click ban, and purge (#144)
Admin view at MokoWaaS > WAF Log:

- Rule distribution cards showing block counts per shield
- Filterable log table: rule, IP, search (URI/detail/UA), date range
- Pagination (50 per page)
- One-click IP ban from any log entry or top IPs sidebar
- Top 10 blocked IPs sidebar with ban buttons
- Purge old logs (configurable days)
- Color-coded rule badges (sqli=red, xss=red, mua=yellow, etc.)

WaflogModel:
- getLogs with filters + pagination
- getTotal for page count
- getRuleCounts for distribution cards
- getTopIps for sidebar
- getRuleNames for filter dropdown
- purgeLogs(days) with affected count
- banIp adds to firewall plugin IP blocklist params

ACL: core.admin (Super Users only)
Submenu: MokoWaaS > WAF Log

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-03 23:42:18 -05:00
jmiller 3a1fc7e4ac chore: sync updates.xml from development [skip ci] 2026-06-03 16:54:20 +00:00
gitea-actions[bot] d22d470aa2 chore: update development channel 02.32.41 [skip ci] 2026-06-03 16:54:19 +00:00
gitea-actions[bot] 8cd80ae7d2 chore(version): auto-bump 02.32.41 [skip ci] 2026-06-03 16:54:18 +00:00
Jonathan Miller 6a4f81dd32 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 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 / 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-03 11:54:02 -05:00
Jonathan Miller dd20e42cb2 feat: Privacy Guard - data compliance, consent, retention (#150)
Admin Privacy Dashboard (view=privacy):
- Data subject requests list with approve/deny actions
- Retention policies table with active status
- Summary cards (pending, total, consent entries, policies)
- Export user data as JSON download
- ACL: core.admin only

PrivacyModel:
- createRequest/processRequest for export/delete/anonymize
- exportUserData: profile, articles, action logs, tickets, replies,
  consent history, Community Builder profile
- anonymizeUserData: replace PII, block account, clear logs
- deleteUserData: full hard delete (anonymize first, then remove)
- logConsent/getUserConsent: consent tracking
- enforceRetentionPolicies: action_logs, waf_logs, sessions,
  inactive_users, closed_tickets (scheduled task ready)
- getDashboardSummary

Frontend Self-Service (/index.php?option=com_mokowaas&view=privacy):
- Download My Data, Anonymize, Delete Account buttons
- Request history table
- Consent history table
- Login required

Database tables:
- #__mokowaas_consent_log
- #__mokowaas_data_requests
- #__mokowaas_retention_policies (5 defaults)

Submenu: MokoWaaS > Privacy Guard

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-03 11:53:54 -05:00
jmiller 63fb1339b8 chore: sync .mokogitea/workflows/repo-health.yml from moko-platform [skip ci] 2026-06-03 09:36:51 +00:00
jmiller c2a90265d2 chore: sync updates.xml from development [skip ci] 2026-06-03 03:52:43 +00:00
gitea-actions[bot] 53fe8c08a9 chore: update development channel 02.32.40 [skip ci] 2026-06-03 03:52:42 +00:00
gitea-actions[bot] 5a274f844c chore(version): auto-bump 02.32.40 [skip ci] 2026-06-03 03:52:41 +00:00
Jonathan Miller 4c728ef7b6 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 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
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-02 22:52:28 -05:00
Jonathan Miller 79bc17912a feat: helpdesk email notifications (#135)
NotificationService with dual recipient support:
- Joomla user IDs (looks up email from #__users)
- Raw email addresses (comma-separated)
- Admin emails + admin user IDs from component params
- Per-event smart routing (creator, assignee, admins)

Events:
- ticket_created → admin emails + assigned user
- ticket_replied → creator + assigned user (skip internal notes)
- status_changed → creator (includes old/new status)
- ticket_assigned → newly assigned user

Email format: plain text with ticket details + view link

Automation engine: added send_email action type for rules
to send emails to specific addresses.

Config stored in com_mokowaas params.notifications:
  admin_emails: comma-separated email addresses
  admin_user_ids: comma-separated Joomla user IDs

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 22:52:21 -05:00
jmiller 236a148d42 chore: sync .mokogitea/workflows/repo-health.yml from moko-platform [skip ci] 2026-06-03 03:10:33 +00:00
jmiller c9889d4abe chore: sync updates.xml from development [skip ci] 2026-06-03 03:09:34 +00:00
gitea-actions[bot] bc22f33a0c chore: update development channel 02.32.39 [skip ci] 2026-06-03 03:09:34 +00:00
gitea-actions[bot] 755954425e chore(version): auto-bump 02.32.39 [skip ci] 2026-06-03 03:09:32 +00:00
Jonathan Miller 92cbcfeefd 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 10s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 22s
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 22:09:13 -05:00
Jonathan Miller aab196c26b fix: hide import buttons after successful import + fix Factory class error
Import markers stored in com_mokowaas component params (imported_admintools,
imported_ats). checkAdminToolsAvailable() and checkAtsAvailable() return null
if already imported, hiding the dashboard banner and ticket list button.

Also fixed missing 'use Joomla\CMS\Factory' in admin tickets template.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 22:03:54 -05:00
jmiller d306b01260 chore: sync updates.xml from development [skip ci] 2026-06-03 00:29:37 +00:00
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
jmiller 7e2476b250 chore: sync updates.xml from development [skip ci] 2026-06-02 23:48:59 +00: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
jmiller 6b195d0514 chore: sync updates.xml from development [skip ci] 2026-06-02 23:46:32 +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
Moko Consulting fc1f3dd903 chore(ci): sync CI issue reporter from Template-Joomla
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Site Health (push) Has been skipped
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
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-02 21:33:03 +00:00
Moko Consulting 4f1b9ac3f2 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 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
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-02 21:33:02 +00:00
Moko Consulting 188defdf1b 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 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 21:33:00 +00:00
gitea-actions[bot] eab0ed1b80 chore: update development channel 02.32.35 [skip ci] 2026-06-02 20:51:48 +00:00
jmiller 3b972efcdc chore: sync updates.xml from development [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
jmiller 2fd3f04f79 chore: sync updates.xml from development [skip ci] 2026-06-02 20:47:40 +00: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
jmiller 2b1bbb9c94 chore: sync updates.xml from development [skip ci] 2026-06-02 20:33:50 +00: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
jmiller 34cf1235c2 chore: sync updates.xml from development [skip ci] 2026-06-02 20:31:16 +00: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
jmiller a8341d456d chore: sync updates.xml from development [skip ci] 2026-06-02 20:25:39 +00: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
jmiller d49fdd24fc chore: sync updates.xml from development [skip ci] 2026-06-02 19:59:15 +00: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
jmiller 47db66b70b chore: sync updates.xml from development [skip ci] 2026-06-02 19:52:40 +00: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
jmiller ca9ef82caf chore: sync updates.xml from development [skip ci] 2026-06-02 19:34:30 +00: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
jmiller b7057745a3 chore: sync updates.xml from development [skip ci] 2026-06-02 19:31:59 +00: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
jmiller 6d3eaa4471 chore: sync updates.xml from development [skip ci] 2026-06-02 19:14:31 +00: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
jmiller 4237740d32 chore: sync updates.xml from development [skip ci] 2026-06-02 19:09:25 +00: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
jmiller f79dc2a26e chore: sync updates.xml from development [skip ci] 2026-06-02 19:06:01 +00: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
jmiller 903999a262 chore: sync updates.xml from development [skip ci] 2026-06-02 18:54:55 +00: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
gitea-actions[bot] d4514aa37d chore: update channels for 02.33.00 [skip ci] 2026-06-02 18:53:36 +00:00
gitea-actions[bot] 723f25bb59 chore(release): build 02.33.00 [skip ci] 2026-06-02 18:53:35 +00:00
jmiller 1522416287 Merge pull request 'chore(release): stable release' (#134) from dev into main
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 25s
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
chore(release): stable release
2026-06-02 18:53:23 +00:00
Jonathan Miller 83402f84d5 fix: resolve merge conflicts from main — keep submodules: recursive
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Failing after 5s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Failing after 5s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 13s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 27s
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
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) 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
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 13:53:01 -05:00
Jonathan Miller 605d940445 fix: resolve workflow merge conflict [skip ci]
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 13:51:49 -05:00
Jonathan Miller 963a1f0c93 feat: dashboard 3-column layout with tables and large action buttons (#129)
Left column (8 cols): Feature plugin card grid
Right column (4 cols): Pending Updates, Checked Out Items, WAF Blocks, Recent Logins
Action buttons full-width with large icons.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 13:51:11 -05:00
jmiller d32b0d414f chore: sync updates.xml from development [skip ci] 2026-06-02 18:44:51 +00:00
gitea-actions[bot] ce53f7c879 chore: update development channel 02.32.21 [skip ci] 2026-06-02 18:44:50 +00:00
gitea-actions[bot] 0dd77817df chore(version): auto-bump 02.32.21 [skip ci] 2026-06-02 18:44:48 +00:00
Jonathan Miller 3032bcd418 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: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Failing after 6s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Failing after 6s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Update Server / Update Server (push) Successful in 15s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 31s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) 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
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) 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
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
2026-06-02 13:44:29 -05:00
Jonathan Miller 183c8e6d29 fix: add text labels to cpanel action buttons [skip ci]
Clear Cache and Check Updates buttons now show text labels instead
of icon-only.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 13:44:22 -05:00
jmiller c1b587aed4 chore: sync updates.xml from development [skip ci] 2026-06-02 17:41:06 +00:00
gitea-actions[bot] e7979baf76 chore: update development channel 02.32.20 [skip ci] 2026-06-02 17:41:05 +00:00
gitea-actions[bot] 3850d8636e chore(version): auto-bump 02.32.20 [skip ci] 2026-06-02 17:41:03 +00:00
Jonathan Miller d3daa01667 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: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Failing after 5s
Universal: Auto Version Bump / Version Bump (push) Failing after 4s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Update Server / Update Server (push) Successful in 12s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 29s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) 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
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) 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
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
2026-06-02 12:40:51 -05:00
Jonathan Miller 838820f558 fix: correct KB article URLs to match live site category aliases [skip ci]
MokoWaaS -> /kb/mokowaas-platform
MokoOnyx -> /kb/mokoonyx-template

Also updated all live KB category descriptions with read more links
and unpublished duplicate categories (68, 69).

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 12:40:46 -05:00
jmiller a1ab5f512a chore: sync updates.xml from development [skip ci] 2026-06-02 17:36:53 +00:00
gitea-actions[bot] bb3c40594f chore: update development channel 02.32.19 [skip ci] 2026-06-02 17:36:52 +00:00
gitea-actions[bot] 6fd6acc716 chore(version): auto-bump 02.32.19 [skip ci] 2026-06-02 17:36:50 +00:00
Jonathan Miller 623edf7254 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
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Failing after 5s
Universal: Auto Version Bump / Version Bump (push) Failing after 5s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Update Server / Update Server (push) Successful in 12s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 37s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 38s
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
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) 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
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
2026-06-02 12:36:36 -05:00
Jonathan Miller 32d5579d56 chore: remove MokoCassiopeia from catalog (superseded by MokoOnyx) [skip ci]
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 12:36:31 -05:00
jmiller 3605d77135 chore: sync updates.xml from development [skip ci] 2026-06-02 16:19:10 +00:00
gitea-actions[bot] da5ee0a76b chore: update development channel 02.32.18 [skip ci] 2026-06-02 16:19:10 +00:00
gitea-actions[bot] ebc482cc8f chore(version): auto-bump 02.32.18 [skip ci] 2026-06-02 16:19:08 +00:00
Jonathan Miller 4fe546091f Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (push) Has been skipped
Universal: Auto Version Bump / Version Bump (push) Failing after 4s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Failing after 4s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Update Server / Update Server (push) Successful in 11s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 24s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) 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
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) 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
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
2026-06-02 11:18:54 -05:00
Jonathan Miller 16d3a9b535 feat: extension manager - uninstall button, KB article links, no update logic [skip ci]
- Replaced repo links with mokoconsulting.tech/kb/ article links
- Added uninstall button for installed non-protected packages
- Protected packages (MokoWaaS) cannot be uninstalled
- Uninstall confirms before proceeding via com_installer
- Removed update_available status (updates go through Joomla native)

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 11:18:33 -05:00
jmiller 23496adb3a chore: sync updates.xml from development [skip ci] 2026-06-02 16:16:13 +00:00
gitea-actions[bot] bca298cbfe chore: update development channel 02.32.17 [skip ci] 2026-06-02 16:16:12 +00:00
gitea-actions[bot] fe90cfd99f chore(version): auto-bump 02.32.17 [skip ci] 2026-06-02 16:16:10 +00:00
Jonathan Miller 33da807dcc 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: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Failing after 4s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Failing after 6s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Update Server / Update Server (push) Successful in 13s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 25s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) 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
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) 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
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
2026-06-02 11:15:53 -05:00
Jonathan Miller 29305f66bf fix: extension manager is install-only, updates go through Joomla [skip ci]
Removed update_available status and update button — each package
registers its own update server URL, so updates are handled via
Joomla's native System > Update mechanism.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 11:15:38 -05:00
jmiller d728af427c chore: sync updates.xml from development [skip ci] 2026-06-02 16:13:43 +00:00
gitea-actions[bot] 2ac5d57b75 chore: update development channel 02.32.16 [skip ci] 2026-06-02 16:13:42 +00:00
gitea-actions[bot] 167b05e75b chore(version): auto-bump 02.32.16 [skip ci] 2026-06-02 16:13:40 +00:00
Jonathan Miller 2546f542e7 Merge branch 'dev' of https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS into dev
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Failing after 5s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Failing after 5s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Update Server / Update Server (push) Successful in 13s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 27s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) 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
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) 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
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
2026-06-02 11:13:20 -05:00
Jonathan Miller 885b24bfa9 feat: Moko Extensions manager - browse and install packages
New view at index.php?option=com_mokowaas&view=extensions showing:
- Curated catalog of Moko Consulting Joomla packages
- Install status (installed, update available, not installed)
- Local vs remote version comparison
- One-click install/update from Gitea releases
- Repo link for each package
- Grouped by category (Platform, Templates, Components, Modules, Plugins)
- Quick access button on the dashboard

Catalog includes: MokoWaaS, MokoOnyx, MokoCassiopeia, MokoJoomTOS,
MokoJoomHero, MokoWaaSAnnounce, MokoDPCalendarAPI, MokoGalleryCalendar

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 11:13:13 -05:00
jmiller 7fb136b6ef chore: sync updates.xml from development [skip ci] 2026-06-02 15:22:48 +00:00
gitea-actions[bot] 155b8e6d5c chore: update development channel 02.32.15 [skip ci] 2026-06-02 15:22:47 +00:00
gitea-actions[bot] 8d6026b62a chore(version): auto-bump 02.32.15 [skip ci] 2026-06-02 15:22:45 +00:00
Jonathan Miller 7632acfbd8 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 12s
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 10:22:31 -05:00
Jonathan Miller 7de88eab36 fix: remove small css class from cpanel consolidated bar [skip ci]
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 10:22:16 -05:00
jmiller 9ce2eb65f1 chore: sync updates.xml from development [skip ci] 2026-06-02 15:18:05 +00:00
gitea-actions[bot] 6d3af46d73 chore: update development channel 02.32.14 [skip ci] 2026-06-02 15:18:04 +00:00
gitea-actions[bot] a04de05544 chore(version): auto-bump 02.32.14 [skip ci] 2026-06-02 15:18:01 +00:00
Jonathan Miller 5bec1393fc 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 19s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 33s
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 10:17:44 -05:00
Jonathan Miller 7b8bbf024a fix: consolidate cpanel bottom rows into single compact bar [skip ci]
Merged disk/IP/versions, plugin badges, and quick actions into one
inline row with border separators. Icons-only for action buttons.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 10:17:32 -05:00
jmiller 78d24d2d15 chore: sync updates.xml from development [skip ci] 2026-06-02 15:16:39 +00:00
gitea-actions[bot] 53a5355600 chore: update development channel 02.32.13 [skip ci] 2026-06-02 15:16:38 +00:00
gitea-actions[bot] ac753d090f chore(version): auto-bump 02.32.13 [skip ci] 2026-06-02 15:16:35 +00:00
Jonathan Miller 0cfcd8282c feat: cascade enable/disable across all MokoWaaS extensions
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 7s
Update Server / Update Server (push) Successful in 23s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 35s
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
When the core system plugin is disabled, all feature plugins
(firewall, tenant, devtools, monitor) and the cpanel module are
automatically disabled too. Re-enabling cascades the same way.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 10:16:12 -05:00
Jonathan Miller 0649741a1c fix: cpanel module show title off by default [skip ci]
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 10:14:14 -05:00
Jonathan Miller d9495abab1 fix: add card wrapper to cpanel module for proper Atum styling [skip ci]
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 10:13:33 -05:00
jmiller 2e673f0d55 chore: sync updates.xml from development [skip ci] 2026-06-02 15:11:08 +00:00
gitea-actions[bot] 82aa63edd5 chore: update development channel 02.32.12 [skip ci] 2026-06-02 15:11:08 +00:00
gitea-actions[bot] da49140bff chore(version): auto-bump 02.32.12 [skip ci] 2026-06-02 15:11:06 +00:00
Jonathan Miller 039ae15559 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 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 10:10:51 -05:00
Jonathan Miller c5b04891ce feat: cpanel module display options - toggle each section (#130)
Added module params to show/hide individual sections:
- Collapsed by default (toggle)
- Health status
- Stats cards (articles, users, updates)
- Disk usage bar
- Current IP display
- Feature plugin badges
- Quick action buttons
- Joomla/PHP versions

Each section respects its toggle. Plugin badges link to config pages.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 10:10:46 -05:00
gitea-actions[bot] 2261bf6ba3 chore(version): auto-bump
┌────────────────────────────────────────────────────────────────────┐
│ version_read  v04.00.15                                            │
│ Read version — manifest.xml is canonical, falls back to README.md and Joomla XML│
└────────────────────────────────────────────────────────────────────┘

02.32.11 [skip ci]
2026-06-02 15:07:46 +00:00
Jonathan Miller 9f229962e2 feat: cpanel collapsed by default, plugin badges link to config (#130)
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
- Module body collapsed by default, header row toggles expand/collapse
- Feature plugin badges are now clickable links to plugin config page
- Added extension_id to helper query for URL building

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 10:07:32 -05:00
Jonathan Miller 681f09f28c fix: resolve merge conflict in firewall manifest [skip ci]
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 10:01:06 -05:00
Jonathan Miller f21bcdd6bb feat: Web Application Firewall with 10 security shields (#122)
Shields implemented:
- SQLiShield — SQL injection detection on GET/POST/COOKIE
- XSSShield — cross-site scripting detection on GET/POST
- MUAShield — malicious user agent blocking (configurable list)
- RFIShield — remote file inclusion prevention
- DFIShield — directory traversal / local file inclusion prevention
- Block sensitive files (htaccess.txt, configuration.php-dist, etc.)
- Block direct PHP execution in images/media/tmp/cache/logs
- Block template switching (tmpl=/template= params)
- IP deny list with CIDR/wildcard support
- Admin secret URL parameter with session persistence

All shields individually toggleable. Master users and trusted IPs bypass.
Blocked requests logged to #__mokowaas_waf_log table.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 09:59:49 -05:00
Jonathan Miller d3ec76dc0f feat: bundle MokoOnyx template as sub-extension of pkg_mokowaas
Adds MokoOnyx as a git submodule at src/packages/tpl_mokoonyx (pinned
to main branch) so it packages alongside all other MokoWaaS extensions.
Every WaaS site uses both, so installing them as a single package
ensures they stay in sync.

- Added submodule for MokoOnyx repo
- Added template entry to pkg_mokowaas.xml
- Added mokoonyx to protected extensions in script.php
- Added submodules: recursive to CI checkout steps

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 09:57:49 -05:00
gitea-actions[bot] e30bdb64cb chore(version): auto-bump
┌────────────────────────────────────────────────────────────────────┐
│ version_read  v04.00.15                                            │
│ Read version — manifest.xml is canonical, falls back to README.md and Joomla XML│
└────────────────────────────────────────────────────────────────────┘

02.32.10 [skip ci]
2026-06-02 14:24:28 +00:00
Jonathan Miller 75f93e43ad 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) Failing after 8s
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 09:24:12 -05:00
Jonathan Miller a5795d381c fix: use Atum 'top' position for full-width cpanel module [skip ci]
Atum template has a 'top' position that renders full-width above the
component output inside the content section. Removed the column-span
CSS hack since top position is naturally full-width.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 09:24:00 -05:00
gitea-actions[bot] fdc53ce76d chore(version): auto-bump
┌────────────────────────────────────────────────────────────────────┐
│ version_read  v04.00.15                                            │
│ Read version — manifest.xml is canonical, falls back to README.md and Joomla XML│
└────────────────────────────────────────────────────────────────────┘

02.32.09 [skip ci]
2026-06-02 14:20:03 +00:00
Jonathan Miller 1ee52979de 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) Failing after 9s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 24s
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 09:19:45 -05:00
Jonathan Miller 1b68e9e478 fix: cpanel module renders full-width card instead of quickicon column [skip ci]
Uses CSS column-span:all with :has() selector to force the module's
parent .module-wrapper to span across all card-columns, rendering as
a full-width card at the top of the dashboard.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 09:19:31 -05:00
gitea-actions[bot] f4085d7ea6 chore(version): auto-bump
┌────────────────────────────────────────────────────────────────────┐
│ version_read  v04.00.15                                            │
│ Read version — manifest.xml is canonical, falls back to README.md and Joomla XML│
└────────────────────────────────────────────────────────────────────┘

02.32.08 [skip ci]
2026-06-02 14:15:20 +00:00
Jonathan Miller 01e11d1600 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 2s
Update Server / Update Server (push) Failing after 8s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 22s
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 09:15:08 -05:00
Jonathan Miller 9d1b6d01a4 fix: move cpanel module to icon position for top-of-dashboard rendering [skip ci]
Joomla's cpanel view renders quickicon (icon position) modules above
regular cpanel modules. Changed from position=cpanel to position=icon
so MokoWaaS appears at the very top of the admin dashboard.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 09:14:57 -05:00
gitea-actions[bot] ad7d974654 chore(version): auto-bump
┌────────────────────────────────────────────────────────────────────┐
│ version_read  v04.00.15                                            │
│ Read version — manifest.xml is canonical, falls back to README.md and Joomla XML│
└────────────────────────────────────────────────────────────────────┘

02.32.07 [skip ci]
2026-06-02 14:08:56 +00:00
Jonathan Miller 7c87c8ec3f 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) Failing after 12s
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 09:08:43 -05:00
Jonathan Miller 801e00e310 chore: set cpanel module ordering to 0 [skip ci]
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 09:08:25 -05:00
gitea-actions[bot] b6d67ed68a chore(version): auto-bump
┌────────────────────────────────────────────────────────────────────┐
│ version_read  v04.00.15                                            │
│ Read version — manifest.xml is canonical, falls back to README.md and Joomla XML│
└────────────────────────────────────────────────────────────────────┘

02.32.06 [skip ci]
2026-06-02 14:08:11 +00:00
Jonathan Miller 7b34bca639 feat: enhanced cpanel module with stats, disk, IP, and quick actions (#117)
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 11s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 27s
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
- Article count, user count, pending updates with badges
- Disk usage progress bar with color coding
- Current IP display
- Clear Cache button with AJAX spinner
- Check Updates + update count button
- Module defaults to ordering=-1 (top) and access=Super Users
- Added padding to module container

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 09:07:57 -05:00
Jonathan Miller 91140076db fix: resolve merge conflicts - keep clean version tags [skip ci]
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 09:00:42 -05:00
Jonathan Miller 94b0e1760b fix: repair mangled <version> tags in all 12 manifests (#120)
The CI auto-bump's version_read.php was printing its CliFramework
banner to stdout, which got captured by exec() and injected into
<version> tags via version_set_platform.php's regex replacement.

Root cause fixed in moko-platform (--quiet flag + version format
validation). This commit repairs all corrupted manifests and adds
padding to the cpanel module.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 09:00:20 -05:00
gitea-actions[bot] 679f5e986d chore(version): auto-bump
┌────────────────────────────────────────────────────────────────────┐
│ version_read  v04.00.15                                            │
│ Read version — manifest.xml is canonical, falls back to README.md and Joomla XML│
└────────────────────────────────────────────────────────────────────┘

02.32.05 [skip ci]
2026-06-02 13:55:46 +00:00
Jonathan Miller 176f67dd30 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) 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 08:55:36 -05:00
Jonathan Miller 46275d81a8 fix: cpanel module $this->escape() crash (#121)
Joomla 5 module templates don't have $this — data comes via extracted
variables from getLayoutData(). Replace $this->escape() with
htmlspecialchars() and remove $app reference.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 08:55:22 -05:00
gitea-actions[bot] 75e004b416 chore(version): auto-bump
┌────────────────────────────────────────────────────────────────────┐
│ version_read  v04.00.15                                            │
│ Read version — manifest.xml is canonical, falls back to README.md and Joomla XML│
└────────────────────────────────────────────────────────────────────┘

02.32.04 [skip ci]
2026-06-02 13:54:06 +00:00
Jonathan Miller c96bfaf646 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) Failing after 9s
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 08:53:53 -05:00
Jonathan Miller 0183c34a0d feat: add MokoWaaS cpanel module for Joomla admin dashboard (#117)
Admin dashboard module (mod_mokowaas_cpanel) showing:
- MokoWaaS version, debug/offline status badges
- Health indicator (database connectivity check)
- Feature plugin status badges (enabled/disabled)
- Link to full MokoWaaS control panel
- Auto-assigns to cpanel position on install

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 08:53:47 -05:00
Jonathan Miller fed6102980 chore: restore universal moko-platform workflows and static updates.xml
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 27s
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
- Add all 15 universal workflows from moko-platform
- Add static updates.xml (licensing system deferred)
- Update .gitignore to allow updates.xml

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 08:14:06 -05:00
jmiller e7f03b1bf5 Merge pull request 'chore: 02.31.00 release' (#109) from dev into main 2026-06-01 02:17:45 +00:00
jmiller 7b5148d089 Merge pull request 'chore: 02.30.00 version bump' (#108) from dev into main 2026-05-31 16:25:27 +00:00
jmiller ceb6b8de3d Merge pull request 'feat: integrate MokoGitea license system for update checks' (#107) from dev into main 2026-05-31 15:42:59 +00:00
jmiller 505d9dbdcd Merge pull request 'fix: 02.29.04 - remove secondary master user enforcement' (#106) from dev into main
Merge pull request #106: fix: 02.29.04 - remove secondary master user enforcement
2026-05-31 14:06:30 +00:00
148 changed files with 14614 additions and 3398 deletions
+1
View File
@@ -122,6 +122,7 @@ build/
dist/
out/
site/
!src/packages/*/site/
*.map
*.css.map
*.js.map
+4
View File
@@ -0,0 +1,4 @@
[submodule "src/packages/tpl_mokoonyx"]
path = src/packages/tpl_mokoonyx
url = https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx.git
branch = main
+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.03</version>
<version>02.34.08</version>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity>
<governance>
+6 -9
View File
@@ -48,15 +48,12 @@ jobs:
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
if [ -d "/opt/moko-platform/cli" ]; then
echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV"
else
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \
/tmp/moko-platform-api
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
fi
rm -rf /tmp/moko-platform-api
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \
/tmp/moko-platform-api
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
- name: Bump version
run: |
+61 -15
View File
@@ -7,7 +7,7 @@
# INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/universal/auto-release.yml.template
# VERSION: 09.23.00
# VERSION: 05.00.00
# BRIEF: Universal build & release detects platform from manifest.xml
#
# +========================================================================+
@@ -74,17 +74,17 @@ jobs:
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
# Always fetch latest CLI tools — never use stale cache from previous runs
rm -rf /tmp/moko-platform-api
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
/tmp/moko-platform-api
cd /tmp/moko-platform-api
composer install --no-dev --no-interaction --quiet
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
- name: Rename branch to rc
run: |
php /tmp/moko-platform-api/cli/branch_rename.php \
php ${MOKO_CLI}/branch_rename.php \
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
@@ -100,15 +100,16 @@ jobs:
- name: Publish RC release
run: |
php /tmp/moko-platform-api/cli/release_publish.php \
php ${MOKO_CLI}/release_publish.php \
--path . --stability rc --bump minor --branch rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}"
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--skip-update-stream
- name: Summary
if: always()
run: |
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
echo "Branch renamed to rc, minor bump, RC + lesser stream releases built, updates.xml synced" >> $GITHUB_STEP_SUMMARY
echo "Branch renamed to rc, minor bump, RC release built (updates.xml managed by Gitea Pages)" >> $GITHUB_STEP_SUMMARY
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
release:
@@ -131,30 +132,75 @@ jobs:
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
- name: Check for merge conflict markers
run: |
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
if [ -n "$CONFLICTS" ]; then
echo "::error::Merge conflict markers found - aborting release"
echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "No conflict markers found"
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
run: |
# Ensure PHP + Composer are available
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
# Always fetch latest CLI tools — never use stale cache from previous runs
rm -rf /tmp/moko-platform-api
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
/tmp/moko-platform-api
cd /tmp/moko-platform-api
composer install --no-dev --no-interaction --quiet
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
- name: "Publish stable release"
run: |
php /tmp/moko-platform-api/cli/release_publish.php \
php ${MOKO_CLI}/release_publish.php \
--path . --stability stable --bump minor --branch main \
--token "${{ secrets.MOKOGITEA_TOKEN }}"
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--skip-update-stream
- name: Update release notes from CHANGELOG.md
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
# Extract [Unreleased] section from changelog
if [ -f "CHANGELOG.md" ]; then
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
[ -z "$NOTES" ] && NOTES="Stable release"
else
NOTES="Stable release"
fi
# Update release body via API
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
"${API_BASE}/releases/tags/stable" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -n "$RELEASE_ID" ]; then
python3 -c "
import json, urllib.request
body = open('/dev/stdin').read()
payload = json.dumps({'body': body}).encode()
req = urllib.request.Request(
'${API_BASE}/releases/${RELEASE_ID}',
data=payload, method='PATCH',
headers={
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
'Content-Type': 'application/json'
})
urllib.request.urlopen(req)
" <<< "$NOTES"
echo "Release notes updated from CHANGELOG.md"
fi
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
- name: "Step 9: Mirror release to GitHub"
@@ -167,7 +213,7 @@ jobs:
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php /tmp/moko-platform-api/cli/release_mirror.php \
php ${MOKO_CLI}/release_mirror.php \
--version "$VERSION" --tag "$RELEASE_TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
@@ -198,7 +244,7 @@ jobs:
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Delete rc branch (ephemeral created by promote-rc)
# Delete rc branch (ephemeral - created by promote-rc)
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/branches/rc" 2>/dev/null \
&& echo "Deleted rc branch" || echo "rc branch not found"
@@ -241,7 +287,7 @@ jobs:
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php /tmp/moko-platform-api/cli/version_reset_dev.php \
php ${MOKO_CLI}/version_reset_dev.php \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
--branch dev --path . 2>&1 || true
@@ -255,7 +301,7 @@ jobs:
echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
echo "## Already Released ${VERSION}" >> $GITHUB_STEP_SUMMARY
echo "## Already Released - ${VERSION}" >> $GITHUB_STEP_SUMMARY
else
echo "" >> $GITHUB_STEP_SUMMARY
echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation
# VERSION: 02.32.03
# VERSION: 02.34.08
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
+532 -236
View File
@@ -1,236 +1,532 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.CI
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/universal/pr-check.yml.template
# VERSION: 09.23.00
# BRIEF: PR gate — branch policy + code validation before merge
name: "Universal: PR Check"
on:
pull_request:
types: [opened, synchronize, reopened, edited]
permissions:
contents: read
pull-requests: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ── Branch Policy ──────────────────────────────────────────────────────
branch-policy:
name: Branch Policy
runs-on: ubuntu-latest
steps:
- name: Check branch merge target
run: |
HEAD="${{ github.head_ref }}"
BASE="${{ github.base_ref }}"
echo "PR: ${HEAD} → ${BASE}"
ALLOWED=true
REASON=""
case "$HEAD" in
feature/*|feat/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Feature branches must target 'dev', not '${BASE}'"
fi
;;
fix/*|bugfix/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Fix branches must target 'dev', not '${BASE}'"
fi
;;
patch/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
ALLOWED=false
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
fi
;;
hotfix/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
fi
;;
rc)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="RC branch can only merge into 'main', not '${BASE}'"
fi
;;
dev)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Dev branch can only merge into 'main', not '${BASE}'"
fi
;;
esac
if [ "$ALLOWED" = false ]; then
echo "::error::${REASON}"
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Branch policy: OK (${HEAD} → ${BASE})"
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
# ── Code Validation ────────────────────────────────────────────────────
validate:
name: Validate PR
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Detect platform
id: platform
run: |
# Read platform from XML manifest (<platform> tag) or plain text fallback
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
[ -z "$PLATFORM" ] && PLATFORM="generic"
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
- name: Setup PHP
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
fi
- name: PHP syntax check
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
ERRORS=0
while IFS= read -r -d '' file; do
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
echo "PHP lint: ${ERRORS} error(s)"
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
- name: Validate platform manifest
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
if [ -z "$MANIFEST" ]; then
echo "::warning::No Joomla manifest found (WaaS site)"
exit 0
fi
echo "Manifest: ${MANIFEST}"
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
fi
for ELEMENT in name version description; do
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
done
echo "Joomla manifest valid"
;;
dolibarr)
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
if [ -z "$MOD_FILE" ]; then
echo "::error::No mod*.class.php found"
exit 1
fi
echo "Dolibarr module: ${MOD_FILE}"
;;
*)
echo "Generic platform — no manifest validation"
;;
esac
- name: Check update stream format
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
if [ -f "updates.xml" ]; then
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
fi
echo "updates.xml valid"
fi
;;
dolibarr)
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
;;
esac
- name: Check changelog has unreleased entry
run: |
if [ ! -f "CHANGELOG.md" ]; then
echo "::warning::No CHANGELOG.md found"
exit 0
fi
# Check for content under [Unreleased] section
if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then
echo "::error::CHANGELOG.md missing [Unreleased] section"
exit 1
fi
# Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased
UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true)
if [ "$UNRELEASED_CONTENT" -eq 0 ]; then
echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes."
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY
echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]"
- name: Verify package source
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
if [ ! -d "$SOURCE_DIR" ]; then
echo "::warning::No src/ or htdocs/ directory"
exit 0
fi
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
echo "Source: ${FILE_COUNT} files"
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
# ── Pre-Release RC Build ─────────────────────────────────────────────────
pre-release:
name: Build RC Package
runs-on: ubuntu-latest
needs: [branch-policy, validate]
steps:
- name: Trigger RC pre-release
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.head_ref }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.CI
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/universal/pr-check.yml.template
# VERSION: 09.23.00
# BRIEF: PR gate — branch policy + code validation before merge
name: "Universal: PR Check"
on:
pull_request:
types: [opened, synchronize, reopened, edited]
permissions:
contents: read
pull-requests: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ── Branch Policy ──────────────────────────────────────────────────────
branch-policy:
name: Branch Policy
runs-on: ubuntu-latest
steps:
- name: Check branch merge target
run: |
HEAD="${{ github.head_ref }}"
BASE="${{ github.base_ref }}"
echo "PR: ${HEAD} → ${BASE}"
ALLOWED=true
REASON=""
case "$HEAD" in
feature/*|feat/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Feature branches must target 'dev', not '${BASE}'"
fi
;;
fix/*|bugfix/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Fix branches must target 'dev', not '${BASE}'"
fi
;;
patch/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
ALLOWED=false
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
fi
;;
hotfix/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
fi
;;
rc)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="RC branch can only merge into 'main', not '${BASE}'"
fi
;;
dev)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Dev branch can only merge into 'main', not '${BASE}'"
fi
;;
esac
if [ "$ALLOWED" = false ]; then
echo "::error::${REASON}"
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Branch policy: OK (${HEAD} → ${BASE})"
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
# ── Code Validation ────────────────────────────────────────────────────
validate:
name: Validate PR
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check for merge conflict markers
run: |
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
if [ -n "$CONFLICTS" ]; then
echo "::error::Merge conflict markers found in source files"
echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "No conflict markers found"
- name: Detect platform
id: platform
run: |
# Read platform from XML manifest (<platform> tag) or plain text fallback
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
[ -z "$PLATFORM" ] && PLATFORM="generic"
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
- name: Setup PHP
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
fi
- name: PHP syntax check
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
ERRORS=0
while IFS= read -r -d '' file; do
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
echo "PHP lint: ${ERRORS} error(s)"
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
- name: Joomla JEXEC guard check
if: steps.platform.outputs.platform == 'joomla'
run: |
ERRORS=0
while IFS= read -r -d '' file; do
# Skip vendor, node_modules, and index.html stub files
case "$file" in ./vendor/*|./node_modules/*) continue ;; esac
# Check first 10 lines for JEXEC or JPATH guard
if ! head -20 "$file" | grep -qE "defined\s*\(\s*['\"](_JEXEC|JPATH_BASE|\\\\JPATH_PLATFORM)['\"]"; then
echo "::error file=${file}::Missing JEXEC guard: ${file}"
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -path "*/src/*" -not -path "./.git/*" -not -path "./vendor/*" -print0)
if [ "$ERRORS" -gt 0 ]; then
echo "::error::${ERRORS} PHP file(s) missing defined('_JEXEC') or die guard"
echo "## JEXEC Guard Check: Failed" >> $GITHUB_STEP_SUMMARY
echo "${ERRORS} file(s) in src/ are missing the Joomla execution guard." >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "JEXEC guard: OK"
- name: Joomla directory listing protection
if: steps.platform.outputs.platform == 'joomla'
run: |
MISSING=0
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && exit 0
while IFS= read -r dir; do
if [ ! -f "${dir}/index.html" ]; then
echo "::warning::Missing index.html in ${dir} (directory listing protection)"
MISSING=$((MISSING + 1))
fi
done < <(find "$SOURCE_DIR" -type d -not -path "./.git/*" -not -path "*/vendor/*" -not -path "*/node_modules/*")
if [ "$MISSING" -gt 0 ]; then
echo "## Directory Protection" >> $GITHUB_STEP_SUMMARY
echo "${MISSING} director(ies) missing index.html" >> $GITHUB_STEP_SUMMARY
fi
echo "Directory protection: ${MISSING} missing (advisory)"
- name: Joomla script file and asset checks
if: steps.platform.outputs.platform == 'joomla'
run: |
ERRORS=0
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
[ -z "$MANIFEST" ] && exit 0
MANIFEST_DIR=$(dirname "$MANIFEST")
# Check scriptfile exists if declared
SCRIPTFILE=$(sed -n 's/.*<scriptfile>\([^<]*\)<\/scriptfile>.*/\1/p' "$MANIFEST" 2>/dev/null)
if [ -n "$SCRIPTFILE" ]; then
if [ ! -f "${MANIFEST_DIR}/${SCRIPTFILE}" ]; then
echo "::error::Manifest declares <scriptfile>${SCRIPTFILE}</scriptfile> but file not found at ${MANIFEST_DIR}/${SCRIPTFILE}"
ERRORS=$((ERRORS + 1))
else
echo "Script file: ${MANIFEST_DIR}/${SCRIPTFILE} (OK)"
fi
fi
# Require joomla.asset.json and validate it
ASSET_JSON=$(find "$MANIFEST_DIR" -name "joomla.asset.json" -not -path "./.git/*" 2>/dev/null | head -1)
if [ -z "$ASSET_JSON" ]; then
echo "::error::joomla.asset.json not found — Joomla asset system is required"
ERRORS=$((ERRORS + 1))
else
if command -v php &> /dev/null; then
php -r "json_decode(file_get_contents('$ASSET_JSON')); if(json_last_error()!==JSON_ERROR_NONE){echo json_last_error_msg();exit(1);}" 2>&1 || {
echo "::error::joomla.asset.json is not valid JSON"
ERRORS=$((ERRORS + 1))
}
fi
echo "joomla.asset.json: valid"
fi
# Validate all XML files in src/ are well-formed
XML_ERRORS=0
if command -v php &> /dev/null; then
while IFS= read -r -d '' xmlfile; do
if ! php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$xmlfile'); if(!\$x){foreach(libxml_get_errors() as \$e) echo trim(\$e->message) . ' in $xmlfile'; exit(1);}" 2>&1; then
XML_ERRORS=$((XML_ERRORS + 1))
fi
done < <(find "$MANIFEST_DIR" -name "*.xml" -not -path "./.git/*" -print0)
fi
if [ "$XML_ERRORS" -gt 0 ]; then
echo "::error::${XML_ERRORS} XML file(s) are malformed"
ERRORS=$((ERRORS + 1))
else
echo "XML well-formedness: OK"
fi
[ "$ERRORS" -gt 0 ] && exit 1
echo "Joomla asset checks: OK"
- name: Validate platform manifest
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
if [ -z "$MANIFEST" ]; then
echo "::warning::No Joomla manifest found (WaaS site)"
exit 0
fi
echo "Manifest: ${MANIFEST}"
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
fi
for ELEMENT in name version description; do
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
done
# Block legacy raw/branch update server URLs on MokoGitea
RAW_URLS=$(grep -n 'raw/branch' "$MANIFEST" | grep -i 'mokoconsulting\|mokogitea\|git\.mokoconsulting\.tech' || true)
if [ -n "$RAW_URLS" ]; then
echo "::error::Manifest contains legacy raw/branch update server URL on MokoGitea. Use the Gitea Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)"
echo "$RAW_URLS"
exit 1
fi
echo "Joomla manifest valid"
;;
dolibarr)
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
if [ -z "$MOD_FILE" ]; then
echo "::error::No mod*.class.php found"
exit 1
fi
echo "Dolibarr module: ${MOD_FILE}"
;;
*)
echo "Generic platform — no manifest validation"
;;
esac
- name: Check update stream format
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
if [ -f "updates.xml" ]; then
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
fi
echo "updates.xml valid"
fi
;;
dolibarr)
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
;;
esac
- name: Check changelog has unreleased entries (PRs to main)
if: github.base_ref == 'main'
run: |
if [ ! -f "CHANGELOG.md" ]; then
echo "::error::CHANGELOG.md not found — required for releases"
exit 1
fi
# Extract content between [Unreleased] and next ## heading
ENTRIES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found && /^- /{count++} END{print count+0}' CHANGELOG.md)
if [ "$ENTRIES" -eq 0 ]; then
echo "::error::CHANGELOG.md has no entries under [Unreleased]. Add changelog entries before releasing."
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "No entries found under \`[Unreleased]\` in CHANGELOG.md." >> $GITHUB_STEP_SUMMARY
echo "Add entries describing what changed before merging to main." >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Changelog: ${ENTRIES} unreleased entries found"
echo "## Changelog Check: Passed" >> $GITHUB_STEP_SUMMARY
echo "${ENTRIES} entries under [Unreleased]" >> $GITHUB_STEP_SUMMARY
- name: Validate Joomla language files
if: steps.platform.outputs.platform == 'joomla'
run: |
ERRORS=0
WARNINGS=0
# Require both en-GB and en-US language directories
LANG_ROOT=$(find . -path "*/language" -type d -not -path "./.git/*" 2>/dev/null | head -1)
if [ -z "$LANG_ROOT" ]; then
echo "No language/ directory found — skipping"
exit 0
fi
if [ ! -d "$LANG_ROOT/en-GB" ]; then
echo "::error::Missing en-GB language directory (${LANG_ROOT}/en-GB)"
ERRORS=$((ERRORS + 1))
fi
if [ ! -d "$LANG_ROOT/en-US" ]; then
echo "::error::Missing en-US language directory (${LANG_ROOT}/en-US)"
ERRORS=$((ERRORS + 1))
fi
# Check that en-GB and en-US have matching .ini files
if [ -d "$LANG_ROOT/en-GB" ] && [ -d "$LANG_ROOT/en-US" ]; then
for GB_INI in "$LANG_ROOT/en-GB"/*.ini; do
[ ! -f "$GB_INI" ] && continue
US_INI="$LANG_ROOT/en-US/$(basename "$GB_INI")"
if [ ! -f "$US_INI" ]; then
echo "::error::$(basename "$GB_INI") exists in en-GB but missing from en-US"
ERRORS=$((ERRORS + 1))
fi
done
for US_INI in "$LANG_ROOT/en-US"/*.ini; do
[ ! -f "$US_INI" ] && continue
GB_INI="$LANG_ROOT/en-GB/$(basename "$US_INI")"
if [ ! -f "$GB_INI" ]; then
echo "::error::$(basename "$US_INI") exists in en-US but missing from en-GB"
ERRORS=$((ERRORS + 1))
fi
done
fi
# Find all .ini language files
INI_FILES=$(find . -path "*/language/*/*.ini" -not -path "./.git/*" 2>/dev/null)
if [ -z "$INI_FILES" ]; then
echo "No .ini language files found"
[ "$ERRORS" -gt 0 ] && exit 1
exit 0
fi
echo "Found $(echo "$INI_FILES" | wc -l) language file(s)"
for FILE in $INI_FILES; do
FNAME=$(basename "$FILE")
LINENUM=0
SEEN_KEYS=""
while IFS= read -r line || [ -n "$line" ]; do
LINENUM=$((LINENUM + 1))
# Skip empty lines and comments
[ -z "$line" ] && continue
echo "$line" | grep -qE '^\s*;' && continue
echo "$line" | grep -qE '^\s*$' && continue
# Must match KEY="VALUE" format
if ! echo "$line" | grep -qE '^[A-Z_][A-Z0-9_]*=".*"$'; then
echo "::error file=${FILE},line=${LINENUM}::Malformed line: ${line}"
ERRORS=$((ERRORS + 1))
continue
fi
# Extract key and check for duplicates
KEY=$(echo "$line" | sed 's/=.*//')
if echo "$SEEN_KEYS" | grep -qx "$KEY"; then
echo "::error file=${FILE},line=${LINENUM}::Duplicate key: ${KEY}"
ERRORS=$((ERRORS + 1))
fi
SEEN_KEYS="${SEEN_KEYS}
${KEY}"
done < "$FILE"
echo " ${FILE}: checked ${LINENUM} lines"
done
# Cross-check en-GB vs en-US key consistency
GB_DIR=$(find . -path "*/language/en-GB" -type d -not -path "./.git/*" 2>/dev/null | head -1)
US_DIR=$(find . -path "*/language/en-US" -type d -not -path "./.git/*" 2>/dev/null | head -1)
if [ -n "$GB_DIR" ] && [ -n "$US_DIR" ]; then
for GB_FILE in "$GB_DIR"/*.ini; do
[ ! -f "$GB_FILE" ] && continue
FNAME=$(basename "$GB_FILE")
US_FILE="$US_DIR/$FNAME"
[ ! -f "$US_FILE" ] && continue
GB_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$GB_FILE" 2>/dev/null | sort)
US_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$US_FILE" 2>/dev/null | sort)
# Keys in en-GB but not en-US
MISSING_US=$(comm -23 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
if [ -n "$MISSING_US" ]; then
echo "::warning::Keys in en-GB/$FNAME but missing from en-US/$FNAME:"
echo "$MISSING_US" | while read -r k; do echo " - $k"; done
WARNINGS=$((WARNINGS + 1))
fi
# Keys in en-US but not en-GB
MISSING_GB=$(comm -13 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
if [ -n "$MISSING_GB" ]; then
echo "::warning::Keys in en-US/$FNAME but missing from en-GB/$FNAME:"
echo "$MISSING_GB" | while read -r k; do echo " - $k"; done
WARNINGS=$((WARNINGS + 1))
fi
done
fi
{
echo "### Language File Validation"
echo "| Metric | Count |"
echo "|---|---|"
echo "| Files checked | $(echo "$INI_FILES" | wc -l) |"
echo "| Errors | ${ERRORS} |"
echo "| Warnings | ${WARNINGS} |"
} >> $GITHUB_STEP_SUMMARY
if [ "$ERRORS" -gt 0 ]; then
echo "::error::Language validation failed with ${ERRORS} error(s)"
exit 1
fi
echo "Language files: OK (${WARNINGS} warning(s))"
- name: Check changelog has unreleased entry
run: |
if [ ! -f "CHANGELOG.md" ]; then
echo "::warning::No CHANGELOG.md found"
exit 0
fi
# Check for content under [Unreleased] section
if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then
echo "::error::CHANGELOG.md missing [Unreleased] section"
exit 1
fi
# Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased
UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true)
if [ "$UNRELEASED_CONTENT" -eq 0 ]; then
echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes."
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY
echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]"
- name: Verify package source
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
if [ ! -d "$SOURCE_DIR" ]; then
echo "::warning::No src/ or htdocs/ directory"
exit 0
fi
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
echo "Source: ${FILE_COUNT} files"
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
# ── Pre-Release RC Build ─────────────────────────────────────────────────
pre-release:
name: Build RC Package
runs-on: ubuntu-latest
needs: [branch-policy, validate]
steps:
- name: Trigger RC pre-release
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.head_ref }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
# ── Issue Reporter ──────────────────────────────────────────────────────
report-issues:
name: Report Issues
runs-on: ubuntu-latest
needs: [branch-policy, validate]
if: >-
always() &&
needs.validate.result == 'failure'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
sparse-checkout: automation/ci-issue-reporter.sh
sparse-checkout-cone-mode: false
- name: "File issue for PR validation failure"
env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
chmod +x automation/ci-issue-reporter.sh
./automation/ci-issue-reporter.sh \
--gate "PR Validation" \
--workflow "PR Check" \
--severity error \
--details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed."
+74 -64
View File
@@ -7,7 +7,7 @@
# INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /templates/workflows/universal/pre-release.yml.template
# VERSION: 09.23.00
# VERSION: 05.01.00
# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch
name: "Universal: Pre-Release"
@@ -17,6 +17,10 @@ on:
types: [closed]
branches:
- dev
pull_request_target:
types: [synchronize, opened, reopened]
branches:
- main
workflow_dispatch:
inputs:
stability:
@@ -43,7 +47,8 @@ jobs:
runs-on: release
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev')
(github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev') ||
(github.event_name == 'pull_request_target' && github.event.pull_request.base.ref == 'main')
steps:
- name: Checkout
@@ -51,6 +56,7 @@ jobs:
with:
fetch-depth: 0
token: ${{ secrets.MOKOGITEA_TOKEN }}
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || '' }}
- name: Setup moko-platform tools
env:
@@ -60,7 +66,6 @@ jobs:
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
# Always fetch latest CLI tools — never use stale cache from previous runs
rm -rf /tmp/moko-platform-api
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
@@ -76,24 +81,40 @@ jobs:
- name: Resolve metadata and bump version
id: meta
run: |
STABILITY="${{ inputs.stability || 'development' }}"
# Auto-detect stability: RC for PRs targeting main, else use input or default to development
if [ "${{ github.event_name }}" = "pull_request_target" ] && [ "${{ github.event.pull_request.base.ref }}" = "main" ]; then
STABILITY="release-candidate"
else
STABILITY="${{ inputs.stability || 'development' }}"
fi
case "$STABILITY" in
development) TAG="development" ;;
alpha) TAG="alpha" ;;
beta) TAG="beta" ;;
release-candidate) TAG="release-candidate" ;;
development) SUFFIX="-dev"; TAG="development" ;;
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
beta) SUFFIX="-beta"; TAG="beta" ;;
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
esac
# Set stability suffix, bump preserves it, fix consistency
# Bump version via CLI: patch for dev/alpha/beta, minor for RC
case "$STABILITY" in
release-candidate) BUMP="minor" ;;
*) BUMP="patch" ;;
esac
php ${MOKO_CLI}/version_bump.php --path . $([ "$BUMP" = "minor" ] && echo "--minor") 2>/dev/null || true
# Set stability suffix and verify consistency
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01")
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
php ${MOKO_CLI}/version_set_platform.php \
--path . --version "$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo '00.00.01')" \
--branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true
--path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
# Read final version (includes suffix, e.g. 01.02.15-dev)
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null)
[ -z "$VERSION" ] && VERSION="00.00.01"
# Append suffix for output
if [ -n "$SUFFIX" ]; then
VERSION="${VERSION}${SUFFIX}"
fi
# Commit version bump
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
@@ -118,11 +139,12 @@ jobs:
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION} ==="
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
- name: Create release
id: release
@@ -135,6 +157,41 @@ jobs:
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --branch dev --prerelease
- name: Update release notes from CHANGELOG.md
run: |
TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
# Extract [Unreleased] section from changelog (everything between [Unreleased] and next ## heading)
if [ -f "CHANGELOG.md" ]; then
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
else
NOTES="Release ${VERSION}"
fi
# Update release body via API
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
"${API_BASE}/releases/tags/${TAG}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -n "$RELEASE_ID" ]; then
python3 -c "
import json, urllib.request
body = open('/dev/stdin').read()
payload = json.dumps({'body': body}).encode()
req = urllib.request.Request(
'${API_BASE}/releases/${RELEASE_ID}',
data=payload, method='PATCH',
headers={
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
'Content-Type': 'application/json'
})
urllib.request.urlopen(req)
" <<< "$NOTES"
echo "Release notes updated from CHANGELOG.md"
fi
- name: Build package and upload
id: package
run: |
@@ -146,55 +203,8 @@ jobs:
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --output /tmp || true
- name: Update updates.xml
if: steps.platform.outputs.platform == 'joomla'
run: |
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
SHA256="${{ steps.package.outputs.sha256_zip }}"
if [ ! -f "updates.xml" ]; then
echo "No updates.xml -- skipping"
exit 0
fi
SHA_FLAG=""
[ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}"
php ${MOKO_CLI}/updates_xml_build.php \
--path . --version "${VERSION}" --stability "${STABILITY}" \
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
${SHA_FLAG}
# Commit and push
if ! git diff --quiet updates.xml 2>/dev/null; then
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git add updates.xml
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
git push origin HEAD 2>&1 || echo "WARNING: push failed"
fi
- name: "Sync updates.xml to all branches"
if: steps.platform.outputs.platform == 'joomla'
run: |
CURRENT_BRANCH="${{ github.ref_name }}"
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
for BRANCH in main dev; do
[ "$BRANCH" = "$CURRENT_BRANCH" ] && continue
echo "Syncing updates.xml -> ${BRANCH}"
git fetch origin "${BRANCH}" 2>/dev/null || continue
git checkout "origin/${BRANCH}" -- updates.xml 2>/dev/null || continue
git checkout "${CURRENT_BRANCH}" -- updates.xml
if ! git diff --quiet updates.xml 2>/dev/null; then
git add updates.xml
git commit -m "chore: sync updates.xml from ${CURRENT_BRANCH} [skip ci]"
git push origin HEAD:refs/heads/${BRANCH} 2>&1 || echo "WARNING: push to ${BRANCH} failed"
fi
git checkout "${CURRENT_BRANCH}" 2>/dev/null
done
# updates.xml is generated dynamically by MokoGitea license server
# No need to build, commit, or sync updates.xml from workflows
- name: "Delete lesser pre-release channels (cascade)"
continue-on-error: true
File diff suppressed because it is too large Load Diff
-302
View File
@@ -1,302 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /templates/workflows/update-server.yml
# VERSION: 09.23.00
# BRIEF: Pre-release build + update server XML for dev/alpha/beta/rc branches
#
# Thin wrapper around moko-platform CLI tools.
# Builds packages, updates updates.xml, and optionally deploys via SFTP.
#
# Joomla filters update entries by the user's "Minimum Stability" setting.
name: "Update Server"
on:
push:
branches:
- 'dev'
- 'dev/**'
- 'alpha/**'
- 'beta/**'
- 'rc/**'
paths:
- 'src/**'
- 'htdocs/**'
pull_request:
types: [closed]
branches:
- 'dev'
- 'dev/**'
- 'alpha/**'
- 'beta/**'
- 'rc/**'
paths:
- 'src/**'
- 'htdocs/**'
workflow_dispatch:
inputs:
stability:
description: 'Stability tag'
required: true
default: 'development'
type: choice
options:
- development
- alpha
- beta
- rc
- stable
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
permissions:
contents: write
jobs:
update-xml:
name: Update Server
runs-on: release
if: >-
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 0
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.MOKOGITEA_TOKEN }}"}}}'
run: |
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
# Always fetch latest CLI tools — never use stale cache from previous runs
rm -rf /tmp/moko-platform
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
/tmp/moko-platform 2>/dev/null || true
if [ -d "/tmp/moko-platform" ] && [ -f "/tmp/moko-platform/composer.json" ]; then
cd /tmp/moko-platform && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
fi
echo "MOKO_CLI=/tmp/moko-platform/cli" >> "$GITHUB_ENV"
- name: Detect platform
id: platform
run: php ${MOKO_CLI}/manifest_read.php --path . --github-output
- name: Resolve stability and bump version
id: meta
run: |
BRANCH="${{ github.ref_name }}"
# Configure git for bot pushes
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
# Determine stability from branch or manual input
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
STABILITY="${{ inputs.stability }}"
elif [[ "$BRANCH" == rc/* ]]; then
STABILITY="rc"
elif [[ "$BRANCH" == beta/* ]]; then
STABILITY="beta"
elif [[ "$BRANCH" == alpha/* ]]; then
STABILITY="alpha"
else
STABILITY="development"
fi
# Gitea release tag per stability
case "$STABILITY" in
development) TAG="development" ;;
alpha) TAG="alpha" ;;
beta) TAG="beta" ;;
rc) TAG="release-candidate" ;;
*) TAG="stable" ;;
esac
# Bump patch, set platform suffix, fix consistency — version_bump preserves suffix
php ${MOKO_CLI}/version_set_platform.php \
--path . --version "$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo '00.00.01')" \
--branch "$BRANCH" --stability "$STABILITY" 2>/dev/null || true
php ${MOKO_CLI}/version_bump.php --path . 2>/dev/null || true
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
# Read final version (includes suffix, e.g. 01.02.15-dev)
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01")
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
# Commit version bump if changed
git add -A
git diff --cached --quiet || {
git commit -m "chore(version): auto-bump ${VERSION} [skip ci]" \
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
git push
}
- name: Create release and upload package
id: package
run: |
VERSION="${{ steps.meta.outputs.version }}"
TAG="${{ steps.meta.outputs.tag }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
# Create or update Gitea release
php ${MOKO_CLI}/release_create.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
# Build package and upload
php ${MOKO_CLI}/release_package.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --output /tmp || true
- name: Update updates.xml
if: steps.platform.outputs.platform == 'joomla'
run: |
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
SHA256="${{ steps.package.outputs.sha256_zip }}"
if [ ! -f "updates.xml" ]; then
echo "No updates.xml — skipping"
exit 0
fi
SHA_FLAG=""
[ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}"
php ${MOKO_CLI}/updates_xml_build.php \
--path . --version "${VERSION}" --stability "${STABILITY}" \
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
${SHA_FLAG}
# Commit and push updates.xml
git add updates.xml
git diff --cached --quiet || {
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
git push
}
- name: Sync updates.xml to main
if: github.ref_name != 'main' && steps.platform.outputs.platform == 'joomla'
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
GITEA_TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
FILE_SHA=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then
python3 -c "
import base64, json, urllib.request, sys
with open('updates.xml', 'rb') as f:
content = base64.b64encode(f.read()).decode()
payload = json.dumps({
'content': content,
'sha': '${FILE_SHA}',
'message': 'chore: sync updates.xml from ${{ steps.meta.outputs.stability }} [skip ci]',
'branch': 'main'
}).encode()
req = urllib.request.Request(
'${API_BASE}/contents/updates.xml',
data=payload, method='PUT',
headers={
'Authorization': 'token ${GITEA_TOKEN}',
'Content-Type': 'application/json'
})
try:
urllib.request.urlopen(req)
print('updates.xml synced to main')
except Exception as e:
print(f'WARNING: sync to main failed: {e}', file=sys.stderr)
"
fi
- name: SFTP deploy to dev server
if: contains(github.ref, 'dev/') || github.ref == 'refs/heads/dev'
env:
DEV_HOST: ${{ vars.DEV_FTP_HOST }}
DEV_PATH: ${{ vars.DEV_FTP_PATH }}
DEV_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
DEV_USER: ${{ vars.DEV_FTP_USERNAME }}
DEV_PORT: ${{ vars.DEV_FTP_PORT }}
DEV_KEY: ${{ secrets.DEV_FTP_KEY }}
DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
run: |
# Permission check: admin or maintain role required
ACTOR="${{ github.actor }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
"${API_BASE}/collaborators/${ACTOR}/permission" 2>/dev/null | \
python3 -c "import sys,json; print(json.load(sys.stdin).get('permission','read'))" 2>/dev/null || echo "read")
case "$PERMISSION" in
admin|maintain|write) ;;
*)
echo "Deploy denied: ${ACTOR} has '${PERMISSION}' — requires admin, maintain, or write"
exit 0
;;
esac
[ -z "$DEV_HOST" ] || [ -z "$DEV_PATH" ] && { echo "DEV FTP not configured — skipping SFTP"; exit 0; }
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
[ ! -d "$SOURCE_DIR" ] && exit 0
PORT="${DEV_PORT:-22}"
REMOTE="${DEV_PATH%/}"
[ -n "$DEV_SUFFIX" ] && REMOTE="${REMOTE}/${DEV_SUFFIX#/}"
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
"$DEV_HOST" "$PORT" "$DEV_USER" "$REMOTE" > /tmp/sftp-config.json
if [ -n "$DEV_KEY" ]; then
echo "$DEV_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
else
printf ',"password":"%s"}' "$DEV_PASS" >> /tmp/sftp-config.json
fi
PLATFORM=$(php ${MOKO_CLI}/platform_detect.php --path . 2>/dev/null || true)
if [ "$PLATFORM" = "waas-component" ] && [ -f "${MOKO_CLI}/../deploy/deploy-joomla.php" ]; then
php ${MOKO_CLI}/../deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
elif [ -f "${MOKO_CLI}/../deploy/deploy-sftp.php" ]; then
php ${MOKO_CLI}/../deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
fi
rm -f /tmp/deploy_key /tmp/sftp-config.json
echo "SFTP deploy to dev complete" >> $GITHUB_STEP_SUMMARY
- name: Summary
if: always()
run: |
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
DISPLAY="${VERSION}"
echo "## Update Server" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Stability | \`${STABILITY}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${DISPLAY}\` |" >> $GITHUB_STEP_SUMMARY
+53 -18
View File
@@ -14,12 +14,58 @@
INGROUP: MokoWaaS.Documentation
REPO: https://github.com/mokoconsulting-tech/mokowaas
PATH: ./CHANGELOG.md
VERSION: 02.32.03
VERSION: 02.34.08
BRIEF: Version history using `Keep a Changelog`
-->
# Changelog
## [02.32.00] - 2026-06-02
## [Unreleased]
### Added
- Database Tools view — table status, optimize, repair, session purge (#127)
- Cache Cleanup view — directory size reporting and one-click cleanup (#128)
- mod_mokowaas_cache — one-click cache cleaner button in admin status bar (replaces Regular Labs Cache Cleaner)
- mod_mokowaas_menu — collapsible admin sidebar menu using native MetisMenu classes (like Community Builder)
- SSL certificate expiry monitoring in cpanel module (#148)
- MokoWaaS-specific update badge (blue) separate from other updates in cpanel module
- migrateUpdateServerUrls() — rewrites all Moko extension update server URLs to clean /updates.xml on install/update
- fixMenuIcons() — sets menu_icon params on submenu items (Joomla only renders img on level 1)
- setupCacheModule() — registers cache cleaner module in status bar position on install
- Component config.xml for Joomla Options modal (#149)
- preflight() ALTER for #__extensions.element default (MySQL strict mode fix)
- Retire MokoJoomTOS, MokoATS-Automation, MokoDPCalendarAPI, MokoGalleryCalendar on install
- MokoJoomTOS settings auto-migrate to mokowaas_offline before removal
- dev-release and pre-release workflows with changelog extraction into release notes
- RC pre-release consolidates dev patches into clean minor version bump
### Changed
- Move security hardening methods (protectPlugin, ensureProtectedFlag, isOurExtension) from core plugin to firewall plugin (#155)
- Admin menu module uses native Joomla MetisMenu CSS classes
- Helpdesk icon changed to fa-handshake-angle, .htaccess to fa-solid fa-file-code
- clearCache purges all cache files recursively (replaces Regular Labs Cache Cleaner behavior)
- License key warning moved from every-page onAfterRoute to package postflight only
- Update server URL changed to dynamic MokoGitea feed
- Component manifest adds `<languages>` for global language dir deployment
- Privacy and WAF Log added to component manifest submenu
- MokoOnyx template removed from package manifest (separate repo/release)
### Removed
- Static updates.xml — MokoGitea generates update feed dynamically from releases
- update-server.yml workflow — replaced by pre-release.yml
### Fixed
- Tickets list showing raw `<em>Unassigned</em>` HTML instead of italic text
- Cache cleaner CSRF failure — token now sent as POST FormData
- Admin menu icons missing for Helpdesk and .htaccess Maker
- Firewall install error "Field 'element' doesn't have a default value" (MySQL strict mode)
## [02.32] - 2026-06-02
### Added
- Admin control panel dashboard in com_mokowaas with site info bar, feature plugin grid, and quick actions
- Feature plugin architecture — MokoWaaS features split into toggleable plugins managed from the dashboard
@@ -43,7 +89,8 @@
- License key validation (licensing system not ready — will return in future release)
- Dynamic MokoGitea update feed dependency (replaced with static updates.xml)
## [02.31.00] - 2026-06-01
## [02.31] - 2026-06-01
### Added
- License key support via Joomla's native Update Sites download key system (dlid)
- Update server URL migrated from static XML to MokoGitea's dynamic update feed endpoint
@@ -76,7 +123,8 @@
- Site Aliases config tab (hardcoded to dev.{primary_domain})
- File sync (images/, files/, media/) — sync is API/DB content only
## [02.29.03] - 2026-05-31
## [02.29] - 2026-05-31
### Added
- `allow_extension_updates` param — separate update rights from installer restrictions; tenants can update extensions by default even when the installer is restricted
- Hardcoded master usernames — multiple privileged users supported with identical access
@@ -90,7 +138,6 @@
- Demo Mode with configurable warning banner on frontend when enabled
### Fixed
- Demo banner countdown now shows weeks/days/months for longer intervals instead of raw hours
- `DemoResetService` — baseline snapshot and restore for DB tables + media files
- API endpoints `POST /?mokowaas=reset` and `POST /?mokowaas=snapshot` (query-string)
@@ -105,16 +152,4 @@
- Package installer: clean up legacy `mokowaasbrand` extension entries and files on install/update
- API endpoint `GET /?mokowaas=extensions` and `GET /api/v1/mokowaas/extensions` — list installed extensions with version, status, and update server info
## [02.20.00] --- 2026-05-28
## [02.20.00] --- 2026-05-28
## [02.19.00] --- 2026-05-28
## [02.18.00] --- 2026-05-28
All notable changes to the MokoWaaS plugin will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [02.20] --- 2026-05-28
+1 -1
View File
@@ -14,7 +14,7 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Documentation
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.32.03
VERSION: 02.34.08
PATH: ./CODE_OF_CONDUCT.md
BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default
-->
+24
View File
@@ -127,6 +127,30 @@ The version tools update all files containing version stamps:
Files synced from other repos (with a `# REPO:` header) are not touched.
## Changelog
We use [Keep a Changelog](https://keepachangelog.com/) with an `[Unreleased]` staging section.
### Rules
- All changes go under `## [Unreleased]` — this is the "current work" section
- Entries stay under `[Unreleased]` until a **stable release** merges to `main`
- On stable release, `[Unreleased]` entries are promoted to a version heading (e.g., `## [02.34] - 2026-06-10`)
- Only **minor versions** get changelog headings — patch numbers from dev are never shown
- Dev/alpha/beta/RC pre-release descriptions pull from `[Unreleased]` automatically
- **CI will block PRs to main** if `[Unreleased]` has no entries
### Categories
Use these headings under each version:
- `### Added` — new features
- `### Changed` — changes to existing functionality
- `### Deprecated` — features that will be removed
- `### Removed` — features that were removed
- `### Fixed` — bug fixes
- `### Security` — vulnerability fixes
## Code Standards
- **PHP**: PSR-12, tabs for indentation
+1 -1
View File
@@ -19,7 +19,7 @@
DEFGROUP: mokoconsulting-tech.MokoWaaSBrand
INGROUP: MokoStandards.Governance
REPO: https://github.com/mokoconsulting-tech/MokoWaaSBrand
VERSION: 02.32.03
VERSION: 02.34.08
PATH: /GOVERNANCE.md
BRIEF: Project governance rules, roles, and decision process for MokoWaaSBrand
-->
+1 -1
View File
@@ -15,7 +15,7 @@
INGROUP: MokoWaaS.Documentation
REPO: https://github.com/mokoconsulting-tech/mokowaas
PATH: ./LICENSE.md
VERSION: 02.32.03
VERSION: 02.34.08
BRIEF: Project license (GPL-3.0-or-later)
-->
GNU GENERAL PUBLIC LICENSE
+1 -1
View File
@@ -9,7 +9,7 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
VERSION: 02.32.03
VERSION: 02.34.08
PATH: /README.md
BRIEF: MokoWaaS platform plugin for Joomla
-->
+1 -1
View File
@@ -23,7 +23,7 @@ DEFGROUP: [PROJECT_NAME]
INGROUP: [PROJECT_NAME].Documentation
REPO: [REPOSITORY_URL]
PATH: /SECURITY.md
VERSION: 02.32.03
VERSION: 02.34.08
BRIEF: Security vulnerability reporting and handling policy
-->
+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.03
VERSION: 02.34.08
PATH: /docs/guides/
BRIEF: Build and packaging guide for the MokoWaaS system plugin
NOTE: Defines environment setup, repository layout, packaging rules, and release preparation
-->
# MokoWaaS Build Guide (VERSION: 02.32.03)
# MokoWaaS Build Guide (VERSION: 02.34.08)
## 1. Purpose
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Guides
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.32.03
VERSION: 02.34.08
PATH: /docs/guides/configuration-guide.md
BRIEF: Configuration guide for the MokoWaaS system plugin
NOTE: Defines plugin parameters, expected behaviors, and recommended defaults
-->
# MokoWaaS Configuration Guide (VERSION: 02.32.03)
# MokoWaaS Configuration Guide (VERSION: 02.34.08)
## 1. Objective
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Guides
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.32.03
VERSION: 02.34.08
PATH: /docs/guides/installation-guide.md
BRIEF: Installation guide for the MokoWaaS system plugin
NOTE: First document in the guide set
-->
# MokoWaaS Installation Guide (VERSION: 02.32.03)
# MokoWaaS Installation Guide (VERSION: 02.34.08)
## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Guides
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.32.03
VERSION: 02.34.08
PATH: /docs/guides/operations-guide.md
BRIEF: Operational guide for administering and managing the MokoWaaS system plugin
NOTE: Defines lifecycle, responsibilities, and operational behaviors
-->
# MokoWaaS Operations Guide (VERSION: 02.32.03)
# MokoWaaS Operations Guide (VERSION: 02.34.08)
## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Guides
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.32.03
VERSION: 02.34.08
PATH: /docs/guides/rollback-and-recovery-guide.md
BRIEF: Rollback and recovery guide for restoring stable operation after plugin related incidents
NOTE: Completes the core guide set for WaaS plugin governance
-->
# MokoWaaS Rollback and Recovery Guide (VERSION: 02.32.03)
# MokoWaaS Rollback and Recovery Guide (VERSION: 02.34.08)
## Introduction
+2 -2
View File
@@ -7,13 +7,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Guides
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.32.03
VERSION: 02.34.08
PATH: /docs/guides/testing-guide.md
BRIEF: Testing guide for MokoWaaS v02.01.08
NOTE: Covers manual test procedures for language overrides, install/uninstall, and configuration
-->
# MokoWaaS Testing Guide (VERSION: 02.32.03)
# MokoWaaS Testing Guide (VERSION: 02.34.08)
## 1. Prerequisites
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Guides
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.32.03
VERSION: 02.34.08
PATH: /docs/guides/troubleshooting-guide.md
BRIEF: Troubleshooting guide for diagnosing and resolving issues related to the MokoWaaS plugin
NOTE: Designed for administrators and WaaS operations teams
-->
# MokoWaaS Troubleshooting Guide (VERSION: 02.32.03)
# MokoWaaS Troubleshooting Guide (VERSION: 02.34.08)
## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Guides
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.32.03
VERSION: 02.34.08
PATH: /docs/guides/upgrade-and-versioning-guide.md
BRIEF: Guide for updating, versioning, and maintaining the MokoWaaS plugin
NOTE: Defines release flow, version rules, and upgrade validation
-->
# MokoWaaS Upgrade and Versioning Guide (VERSION: 02.32.03)
# MokoWaaS Upgrade and Versioning Guide (VERSION: 02.34.08)
## Introduction
+2 -2
View File
@@ -10,13 +10,13 @@
DEFGROUP: Joomla.Plugin
INGROUP: MokoWaaS.Documentation
REPO: https://github.com/mokoconsulting-tech/mokowaas
VERSION: 02.32.03
VERSION: 02.34.08
PATH: /docs/index.md
BRIEF: Master index of all documentation for the MokoWaaS plugin
NOTE: Automatically maintained index for all guide canvases
-->
# MokoWaaS Documentation Index (VERSION: 02.32.03)
# MokoWaaS Documentation Index (VERSION: 02.34.08)
## Introduction
+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.03
VERSION: 02.34.08
BRIEF: Baseline documentation for the MokoWaaS system plugin
NOTE: Foundational reference for internal and external stakeholders
-->
# MokoWaaS Plugin Overview (VERSION: 02.32.03)
# MokoWaaS Plugin Overview (VERSION: 02.34.08)
## Introduction
+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.03
VERSION: 02.34.08
BRIEF: How this extension's Joomla update server file (update.xml) is managed
-->
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<access component="com_mokowaas">
<section name="component">
<action name="core.admin" title="JACTION_ADMIN" description="JACTION_ADMIN_COMPONENT_DESC" />
<action name="core.manage" title="JACTION_MANAGE" description="JACTION_MANAGE_COMPONENT_DESC" />
<action name="mokowaas.dashboard" title="COM_MOKOWAAS_ACL_DASHBOARD" description="COM_MOKOWAAS_ACL_DASHBOARD_DESC" />
<action name="mokowaas.extensions" title="COM_MOKOWAAS_ACL_EXTENSIONS" description="COM_MOKOWAAS_ACL_EXTENSIONS_DESC" />
<action name="mokowaas.htaccess" title="COM_MOKOWAAS_ACL_HTACCESS" description="COM_MOKOWAAS_ACL_HTACCESS_DESC" />
<action name="mokowaas.tickets" title="COM_MOKOWAAS_ACL_TICKETS" description="COM_MOKOWAAS_ACL_TICKETS_DESC" />
<action name="mokowaas.tickets.create" title="COM_MOKOWAAS_ACL_TICKETS_CREATE" description="COM_MOKOWAAS_ACL_TICKETS_CREATE_DESC" />
<action name="mokowaas.tickets.assign" title="COM_MOKOWAAS_ACL_TICKETS_ASSIGN" description="COM_MOKOWAAS_ACL_TICKETS_ASSIGN_DESC" />
<action name="mokowaas.plugins.toggle" title="COM_MOKOWAAS_ACL_PLUGINS_TOGGLE" description="COM_MOKOWAAS_ACL_PLUGINS_TOGGLE_DESC" />
<action name="mokowaas.cache" title="COM_MOKOWAAS_ACL_CACHE" description="COM_MOKOWAAS_ACL_CACHE_DESC" />
</section>
</access>
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<config>
<fieldset name="notifications" label="Email Notifications" description="Configure email recipients for ticket and security notifications.">
<field name="admin_emails" type="text" default=""
label="Admin Email Addresses"
description="Comma-separated email addresses to receive all notifications."
hint="admin@example.com, support@example.com" />
<field name="admin_user_ids" type="text" default=""
label="Admin User IDs"
description="Comma-separated Joomla user IDs to receive notifications."
hint="320, 321" />
<field name="security_alerts" type="radio" default="1"
label="Security Alerts"
description="Send email alerts for WAF blocks and admin logins."
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
<fieldset name="helpdesk" label="Helpdesk Settings" description="Default helpdesk behavior.">
<field name="default_category" type="sql" default=""
label="Default Ticket Category"
description="Category assigned to tickets without a selection."
query="SELECT id AS value, title AS text FROM #__mokowaas_ticket_categories WHERE published = 1 ORDER BY ordering" />
<field name="autoclose_days" type="number" default="7"
label="Auto-Close After (days)"
description="Resolved tickets are auto-closed after this many days. 0 = disabled." />
<field name="kb_search_enabled" type="radio" default="1"
label="KB Search on Ticket Forms"
description="Show knowledge base search before ticket submission."
class="btn-group btn-group-yesno">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
<fieldset name="permissions" label="COM_MOKOWAAS_ACL_TITLE"
description="COM_MOKOWAAS_ACL_DESC">
<field name="rules" type="rules"
label="COM_MOKOWAAS_ACL_TITLE"
validate="rules"
filter="rules"
component="com_mokowaas"
section="component" />
</fieldset>
</config>
@@ -16,3 +16,26 @@ COM_MOKOWAAS_CONFIGURE="Configure"
COM_MOKOWAAS_TOGGLE_SUCCESS="Plugin state updated."
COM_MOKOWAAS_TOGGLE_FAIL="Failed to update plugin state."
COM_MOKOWAAS_CACHE_CLEARED="Cache cleared successfully."
COM_MOKOWAAS_EXTENSIONS_TITLE="Moko Extensions"
COM_MOKOWAAS_EXTENSIONS_INFO="Install Moko Consulting Joomla packages from the official release server. Updates are handled through Joomla's native System > Update mechanism — each package registers its own update server."
COM_MOKOWAAS_EXTENSIONS_LINK="Moko Extensions"
COM_MOKOWAAS_HTACCESS_TITLE=".htaccess Maker"
COM_MOKOWAAS_TICKETS_TITLE="Helpdesk"
; ACL
COM_MOKOWAAS_ACL_DASHBOARD="View Dashboard"
COM_MOKOWAAS_ACL_DASHBOARD_DESC="Allow viewing the MokoWaaS control panel dashboard."
COM_MOKOWAAS_ACL_EXTENSIONS="Manage Extensions"
COM_MOKOWAAS_ACL_EXTENSIONS_DESC="Allow installing and uninstalling Moko extensions."
COM_MOKOWAAS_ACL_HTACCESS="Manage .htaccess"
COM_MOKOWAAS_ACL_HTACCESS_DESC="Allow editing and saving the .htaccess configuration."
COM_MOKOWAAS_ACL_TICKETS="View Tickets"
COM_MOKOWAAS_ACL_TICKETS_DESC="Allow viewing helpdesk tickets."
COM_MOKOWAAS_ACL_TICKETS_CREATE="Create Tickets"
COM_MOKOWAAS_ACL_TICKETS_CREATE_DESC="Allow creating new helpdesk tickets."
COM_MOKOWAAS_ACL_TICKETS_ASSIGN="Assign Tickets"
COM_MOKOWAAS_ACL_TICKETS_ASSIGN_DESC="Allow assigning tickets to other users."
COM_MOKOWAAS_ACL_PLUGINS_TOGGLE="Toggle Plugins"
COM_MOKOWAAS_ACL_PLUGINS_TOGGLE_DESC="Allow enabling and disabling MokoWaaS feature plugins."
COM_MOKOWAAS_ACL_CACHE="Clear Cache"
COM_MOKOWAAS_ACL_CACHE_DESC="Allow clearing the Joomla cache from the dashboard."
@@ -5,3 +5,15 @@
COM_MOKOWAAS="MokoWaaS"
COM_MOKOWAAS_DESCRIPTION="MokoWaaS admin dashboard and REST API. Control panel for managing site features, health monitoring, and remote management."
COM_MOKOWAAS_DASHBOARD_TITLE="MokoWaaS Control Panel"
COM_MOKOWAAS_MENU_DASHBOARD="Dashboard"
COM_MOKOWAAS_MENU_EXTENSIONS="Moko Extensions"
COM_MOKOWAAS_MENU_PLUGINS="Feature Plugins"
COM_MOKOWAAS_MENU_UPDATES="Joomla Updates"
COM_MOKOWAAS_MENU_CHECKIN="Global Check-in"
COM_MOKOWAAS_MENU_TICKETS="Helpdesk"
COM_MOKOWAAS_MENU_HTACCESS=".htaccess Maker"
COM_MOKOWAAS_MENU_PRIVACY="Privacy Guard"
COM_MOKOWAAS_MENU_WAFLOG="WAF Log"
COM_MOKOWAAS_MENU_DATABASE="Database Tools"
COM_MOKOWAAS_MENU_CLEANUP="Cache Cleanup"
COM_MOKOWAAS_MENU_CACHE="Cache Management"
@@ -0,0 +1,135 @@
--
-- MokoWaaS Helpdesk Tables
--
CREATE TABLE IF NOT EXISTS `#__mokowaas_ticket_categories` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`title` VARCHAR(255) NOT NULL,
`alias` VARCHAR(255) NOT NULL DEFAULT '',
`description` TEXT,
`auto_assign_user` INT DEFAULT NULL,
`sla_response_minutes` INT UNSIGNED NOT NULL DEFAULT 480,
`sla_resolution_minutes` INT UNSIGNED NOT NULL DEFAULT 2880,
`ordering` INT NOT NULL DEFAULT 0,
`published` TINYINT NOT NULL DEFAULT 1,
PRIMARY KEY (`id`),
KEY `idx_alias` (`alias`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokowaas_tickets` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`subject` VARCHAR(512) NOT NULL,
`body` TEXT NOT NULL,
`status` ENUM('open','in_progress','waiting','resolved','closed') NOT NULL DEFAULT 'open',
`priority` ENUM('low','normal','high','urgent') NOT NULL DEFAULT 'normal',
`category_id` INT UNSIGNED DEFAULT NULL,
`created_by` INT NOT NULL DEFAULT 0,
`assigned_to` INT DEFAULT NULL,
`created` DATETIME NOT NULL,
`modified` DATETIME DEFAULT NULL,
`resolved` DATETIME DEFAULT NULL,
`closed` DATETIME DEFAULT NULL,
`sla_response_due` DATETIME DEFAULT NULL,
`sla_resolution_due` DATETIME DEFAULT NULL,
`sla_responded` TINYINT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_status` (`status`),
KEY `idx_priority` (`priority`),
KEY `idx_assigned` (`assigned_to`),
KEY `idx_category` (`category_id`),
KEY `idx_created` (`created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokowaas_ticket_replies` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`ticket_id` INT UNSIGNED NOT NULL,
`user_id` INT NOT NULL DEFAULT 0,
`body` TEXT NOT NULL,
`is_internal` TINYINT NOT NULL DEFAULT 0,
`created` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_ticket` (`ticket_id`),
KEY `idx_created` (`created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokowaas_ticket_canned` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`title` VARCHAR(255) NOT NULL,
`body` TEXT NOT NULL,
`category_id` INT UNSIGNED DEFAULT NULL,
`ordering` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokowaas_ticket_automation` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`title` VARCHAR(255) NOT NULL,
`trigger_event` VARCHAR(50) NOT NULL DEFAULT 'ticket_created',
`conditions` TEXT NOT NULL DEFAULT '[]',
`actions` TEXT NOT NULL DEFAULT '[]',
`enabled` TINYINT NOT NULL DEFAULT 1,
`ordering` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Default automation rules
INSERT IGNORE INTO `#__mokowaas_ticket_automation` (`id`, `title`, `trigger_event`, `conditions`, `actions`, `enabled`, `ordering`) VALUES
(1, 'Auto-close resolved tickets after 7 days', 'scheduled', '[{"field":"status","op":"eq","value":"resolved"},{"field":"age_hours","op":"gt","value":"168"}]', '[{"type":"set_status","value":"closed"},{"type":"add_note","value":"Auto-closed after 7 days with no response."}]', 1, 1),
(2, 'Escalate urgent tickets with no response in 1 hour', 'scheduled', '[{"field":"priority","op":"eq","value":"urgent"},{"field":"sla_responded","op":"eq","value":"0"},{"field":"age_hours","op":"gt","value":"1"}]', '[{"type":"add_note","value":"SLA BREACH: Urgent ticket has no staff response after 1 hour."}]', 1, 2),
(3, 'Notify on high priority ticket creation', 'ticket_created', '[{"field":"priority","op":"in","value":"high,urgent"}]', '[{"type":"add_note","value":"High/urgent ticket created — requires immediate attention."}]', 1, 3);
-- Default categories
INSERT IGNORE INTO `#__mokowaas_ticket_categories` (`id`, `title`, `alias`, `description`, `sla_response_minutes`, `sla_resolution_minutes`, `ordering`) VALUES
(1, 'General Support', 'general-support', 'General questions and assistance', 480, 2880, 1),
(2, 'Bug Report', 'bug-report', 'Report a software bug or issue', 240, 1440, 2),
(3, 'Feature Request', 'feature-request', 'Request a new feature or enhancement', 1440, 10080, 3),
(4, 'Billing', 'billing', 'Billing, invoicing, and payment questions', 240, 1440, 4),
(5, 'Urgent / Outage', 'urgent-outage', 'Site down or critical issue', 60, 240, 5);
--
-- Privacy Guard Tables
--
CREATE TABLE IF NOT EXISTS `#__mokowaas_consent_log` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` INT NOT NULL,
`category` VARCHAR(50) NOT NULL,
`action` ENUM('granted','revoked') NOT NULL,
`ip_address` VARCHAR(45) NOT NULL DEFAULT '',
`created` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_user` (`user_id`),
KEY `idx_category` (`category`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokowaas_data_requests` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` INT NOT NULL,
`type` ENUM('export','delete','anonymize') NOT NULL,
`status` ENUM('pending','processing','completed','denied') NOT NULL DEFAULT 'pending',
`notes` TEXT,
`processed_by` INT DEFAULT NULL,
`created` DATETIME NOT NULL,
`processed` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_user` (`user_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokowaas_retention_policies` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`content_type` VARCHAR(100) NOT NULL,
`retention_days` INT UNSIGNED NOT NULL DEFAULT 365,
`action` ENUM('anonymize','delete','archive') NOT NULL DEFAULT 'anonymize',
`enabled` TINYINT NOT NULL DEFAULT 1,
`description` VARCHAR(255) NOT NULL DEFAULT '',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Default retention policies
INSERT IGNORE INTO `#__mokowaas_retention_policies` (`id`, `content_type`, `retention_days`, `action`, `enabled`, `description`) VALUES
(1, 'action_logs', 90, 'delete', 1, 'Delete action log entries older than 90 days'),
(2, 'waf_logs', 30, 'delete', 1, 'Delete WAF block logs older than 30 days'),
(3, 'sessions', 7, 'delete', 1, 'Purge expired sessions older than 7 days'),
(4, 'inactive_users', 730, 'anonymize', 0, 'Anonymize users inactive for 2 years (disabled by default)'),
(5, 'closed_tickets', 365, 'anonymize', 0, 'Anonymize closed tickets older than 1 year (disabled by default)');
@@ -20,72 +20,687 @@ class DisplayController extends BaseController
{
protected $default_view = 'dashboard';
/**
* ACL map: view name => required permission.
*/
private const VIEW_ACL = [
'dashboard' => 'mokowaas.dashboard',
'extensions' => 'mokowaas.extensions',
'htaccess' => 'mokowaas.htaccess',
'tickets' => 'mokowaas.tickets',
'ticket' => 'mokowaas.tickets',
'privacy' => 'core.admin',
'waflog' => 'core.admin',
'categories' => 'mokowaas.tickets',
'canned' => 'mokowaas.tickets',
'automation' => 'core.admin',
'database' => 'core.admin',
'cleanup' => 'mokowaas.cache',
];
public function display($cachable = false, $urlparams = [])
{
$view = $this->input->get('view', $this->default_view);
$acl = self::VIEW_ACL[$view] ?? 'core.manage';
if (!$this->checkAcl($acl))
{
Factory::getApplication()->enqueueMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
Factory::getApplication()->redirect(Route::_('index.php', false));
return;
}
return parent::display($cachable, $urlparams);
}
/**
* Toggle a MokoWaaS feature plugin on or off.
*
* Expects POST with extension_id and enabled (0 or 1).
* Returns JSON response for AJAX calls.
*/
// ==================================================================
// Plugin toggle
// ==================================================================
public function togglePlugin()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.plugins.toggle'))
{
$this->jsonForbidden();
return;
}
$app = Factory::getApplication();
$input = $app->getInput();
$model = $this->getModel('Dashboard');
$user = $app->getIdentity();
if (!$user->authorise('core.manage', 'com_plugins'))
{
$app->setHeader('Content-Type', 'application/json');
echo json_encode(['success' => false, 'message' => Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN')]);
$app->close();
}
$result = $model->togglePlugin(
$app->getInput()->getInt('extension_id', 0),
$app->getInput()->getInt('enabled', 0)
);
$extensionId = $input->getInt('extension_id', 0);
$enabled = $input->getInt('enabled', 0);
if (!$extensionId)
{
$app->setHeader('Content-Type', 'application/json');
echo json_encode(['success' => false, 'message' => 'Missing extension_id']);
$app->close();
}
$model = $this->getModel('Dashboard');
$result = $model->togglePlugin($extensionId, $enabled);
$app->setHeader('Content-Type', 'application/json');
echo json_encode($result);
$app->close();
$this->jsonResponse($result);
}
/**
* Clear the Joomla cache.
*/
// ==================================================================
// Cache
// ==================================================================
public function clearCache()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
$app = Factory::getApplication();
$user = $app->getIdentity();
if (!$user->authorise('core.admin'))
if (!$this->checkAcl('mokowaas.cache'))
{
$app->setHeader('Content-Type', 'application/json');
echo json_encode(['success' => false, 'message' => Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN')]);
$app->close();
$this->jsonForbidden();
return;
}
$model = $this->getModel('Dashboard');
$result = $model->clearCache();
$this->jsonResponse($this->getModel('Dashboard')->clearCache());
}
// ==================================================================
// Extensions
// ==================================================================
public function installExtension()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.extensions'))
{
$this->jsonForbidden();
return;
}
$downloadUrl = Factory::getApplication()->getInput()->getString('download_url', '');
if (empty($downloadUrl))
{
$this->jsonResponse(['success' => false, 'message' => 'Missing download URL.']);
return;
}
$this->jsonResponse($this->getModel('Extensions')->installFromUrl($downloadUrl));
}
// ==================================================================
// .htaccess
// ==================================================================
public function saveHtaccess()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.htaccess'))
{
$this->jsonForbidden();
return;
}
$app = Factory::getApplication();
$input = $app->getInput();
$model = $this->getModel('Htaccess');
$options = [];
foreach ($input->getArray() as $key => $value)
{
if (str_starts_with($key, 'opt_'))
{
$options[substr($key, 4)] = $value;
}
}
if (!empty($options))
{
$model->saveOptions($options);
}
$this->jsonResponse($model->saveHtaccess($input->getRaw('content', '')));
}
public function generateHtaccess()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.htaccess'))
{
$this->jsonForbidden();
return;
}
$model = $this->getModel('Htaccess');
$options = Factory::getApplication()->getInput()->getArray();
$model->saveOptions($options);
$app = Factory::getApplication();
$app->setHeader('Content-Type', 'application/json');
echo json_encode($result);
echo json_encode([
'htaccess' => $model->generateHtaccess($options),
'nginx' => $model->generateNginx($options),
]);
$app->close();
}
// ==================================================================
// Tickets
// ==================================================================
public function createTicket()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.tickets.create'))
{
$this->jsonForbidden();
return;
}
$input = Factory::getApplication()->getInput();
$this->jsonResponse($this->getModel('Tickets')->createTicket([
'subject' => $input->getString('subject', ''),
'body' => $input->getRaw('body', ''),
'priority' => $input->getString('priority', 'normal'),
'category_id' => $input->getInt('category_id', 0),
]));
}
public function addTicketReply()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.tickets'))
{
$this->jsonForbidden();
return;
}
$input = Factory::getApplication()->getInput();
$this->jsonResponse($this->getModel('Tickets')->addReply(
$input->getInt('ticket_id', 0),
$input->getRaw('body', ''),
(bool) $input->getInt('is_internal', 0)
));
}
public function updateTicketStatus()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.tickets'))
{
$this->jsonForbidden();
return;
}
$input = Factory::getApplication()->getInput();
$this->jsonResponse($this->getModel('Tickets')->updateStatus(
$input->getInt('ticket_id', 0),
$input->getString('status', '')
));
}
// ==================================================================
// KB Search
// ==================================================================
public function searchKb()
{
$query = Factory::getApplication()->getInput()->getString('q', '');
if (strlen($query) < 3)
{
$this->jsonResponse(['results' => []]);
}
try
{
$db = Factory::getDbo();
$escaped = $db->quote('%' . $db->escape($query, true) . '%');
$results = $db->setQuery(
$db->getQuery(true)
->select([$db->quoteName('l.title'), $db->quoteName('l.url'), $db->quoteName('l.description')])
->from($db->quoteName('#__finder_links', 'l'))
->where($db->quoteName('l.published') . ' = 1')
->where('(' . $db->quoteName('l.title') . ' LIKE ' . $escaped
. ' OR ' . $db->quoteName('l.description') . ' LIKE ' . $escaped . ')')
->order($db->quoteName('l.title') . ' ASC')
->setLimit(8)
)->loadObjectList() ?: [];
foreach ($results as $r)
{
$r->description = mb_substr(strip_tags($r->description ?? ''), 0, 150);
}
$this->jsonResponse(['results' => $results]);
}
catch (\Throwable $e)
{
$this->jsonResponse(['results' => []]);
}
}
// ==================================================================
// Maintenance (#127, #128)
// ==================================================================
public function optimizeDb()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
$model = new \Moko\Component\MokoWaaS\Administrator\Model\MaintenanceModel();
$this->jsonResponse($model->optimizeTables());
}
public function repairDb()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
$model = new \Moko\Component\MokoWaaS\Administrator\Model\MaintenanceModel();
$this->jsonResponse($model->repairTables());
}
public function purgeSessions()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); return; }
$model = new \Moko\Component\MokoWaaS\Administrator\Model\MaintenanceModel();
$this->jsonResponse($model->purgeSessions());
}
public function cleanDirectory()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.cache')) { $this->jsonForbidden(); return; }
$dirKey = Factory::getApplication()->getInput()->getString('dir_key', '');
$model = new \Moko\Component\MokoWaaS\Administrator\Model\MaintenanceModel();
$this->jsonResponse($model->cleanDirectory($dirKey));
}
// ==================================================================
// Helpdesk CRUD (#137, #138, #139)
// ==================================================================
public function saveCategory()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.tickets')) { $this->jsonForbidden(); }
$input = Factory::getApplication()->getInput();
$db = Factory::getDbo();
$id = $input->getInt('id', 0);
$data = (object) [
'title' => $input->getString('title', ''),
'alias' => \Joomla\CMS\Filter\OutputFilter::stringURLSafe($input->getString('title', '')),
'sla_response_minutes' => $input->getInt('sla_response_minutes', 480),
'sla_resolution_minutes' => $input->getInt('sla_resolution_minutes', 2880),
'auto_assign_user' => $input->getInt('auto_assign_user', 0) ?: null,
'published' => $input->getInt('published', 1),
];
if ($id) {
$data->id = $id;
$db->updateObject('#__mokowaas_ticket_categories', $data, 'id');
} else {
$data->ordering = 0;
$db->insertObject('#__mokowaas_ticket_categories', $data, 'id');
}
$this->jsonResponse(['success' => true, 'message' => 'Category saved.', 'id' => (int) $data->id]);
}
public function deleteCategory()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.tickets')) { $this->jsonForbidden(); }
$db = Factory::getDbo();
$db->setQuery($db->getQuery(true)->delete('#__mokowaas_ticket_categories')->where('id = ' . Factory::getApplication()->getInput()->getInt('id', 0)))->execute();
$this->jsonResponse(['success' => true, 'message' => 'Category deleted.']);
}
public function saveCanned()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.tickets')) { $this->jsonForbidden(); }
$input = Factory::getApplication()->getInput();
$db = Factory::getDbo();
$data = (object) [
'title' => $input->getString('title', ''),
'body' => $input->getRaw('body', ''),
'category_id' => $input->getInt('category_id', 0) ?: null,
'ordering' => 0,
];
$id = $input->getInt('id', 0);
if ($id) { $data->id = $id; $db->updateObject('#__mokowaas_ticket_canned', $data, 'id'); }
else { $db->insertObject('#__mokowaas_ticket_canned', $data, 'id'); }
$this->jsonResponse(['success' => true, 'message' => 'Canned response saved.', 'id' => (int) $data->id]);
}
public function deleteCanned()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.tickets')) { $this->jsonForbidden(); }
$db = Factory::getDbo();
$db->setQuery($db->getQuery(true)->delete('#__mokowaas_ticket_canned')->where('id = ' . Factory::getApplication()->getInput()->getInt('id', 0)))->execute();
$this->jsonResponse(['success' => true, 'message' => 'Canned response deleted.']);
}
public function saveAutomation()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); }
$input = Factory::getApplication()->getInput();
$db = Factory::getDbo();
$data = (object) [
'title' => $input->getString('title', ''),
'trigger_event' => $input->getString('trigger_event', 'ticket_created'),
'conditions' => $input->getRaw('conditions', '[]'),
'actions' => $input->getRaw('actions', '[]'),
'enabled' => 1,
'ordering' => 0,
];
$id = $input->getInt('id', 0);
if ($id) { $data->id = $id; $db->updateObject('#__mokowaas_ticket_automation', $data, 'id'); }
else { $db->insertObject('#__mokowaas_ticket_automation', $data, 'id'); }
$this->jsonResponse(['success' => true, 'message' => 'Rule saved.', 'id' => (int) $data->id]);
}
public function deleteAutomation()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); }
$db = Factory::getDbo();
$db->setQuery($db->getQuery(true)->delete('#__mokowaas_ticket_automation')->where('id = ' . Factory::getApplication()->getInput()->getInt('id', 0)))->execute();
$this->jsonResponse(['success' => true, 'message' => 'Rule deleted.']);
}
public function toggleAutomation()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin')) { $this->jsonForbidden(); }
$input = Factory::getApplication()->getInput();
$db = Factory::getDbo();
$db->setQuery($db->getQuery(true)->update('#__mokowaas_ticket_automation')
->set('enabled = ' . $input->getInt('enabled', 0))
->where('id = ' . $input->getInt('id', 0)))->execute();
$this->jsonResponse(['success' => true, 'message' => 'Rule updated.']);
}
// ==================================================================
// Settings Import/Export (#132)
// ==================================================================
public function exportSettings()
{
Session::checkToken('get') or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin'))
{
$this->jsonForbidden();
return;
}
$db = Factory::getDbo();
$settings = [];
// Export all MokoWaaS plugin params
$plugins = ['mokowaas', 'mokowaas_firewall', 'mokowaas_tenant', 'mokowaas_devtools', 'mokowaas_offline'];
foreach ($plugins as $element)
{
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote($element))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
);
$settings['plugins'][$element] = json_decode($db->loadResult() ?? '{}', true);
}
// Export component params
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
);
$settings['component'] = json_decode($db->loadResult() ?? '{}', true);
$settings['exported'] = gmdate('Y-m-d\TH:i:s\Z');
$settings['site'] = Factory::getConfig()->get('sitename', '');
$this->jsonResponse(['success' => true, 'settings' => $settings]);
}
public function importSettings()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin'))
{
$this->jsonForbidden();
return;
}
$json = Factory::getApplication()->getInput()->getRaw('settings_json', '');
$data = json_decode($json, true);
if (empty($data) || empty($data['plugins']))
{
$this->jsonResponse(['success' => false, 'message' => 'Invalid settings JSON.']);
return;
}
$db = Factory::getDbo();
$count = 0;
foreach ($data['plugins'] ?? [] as $element => $params)
{
if (!is_array($params))
{
continue;
}
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($params)))
->where($db->quoteName('element') . ' = ' . $db->quote($element))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
)->execute();
$count++;
}
if (!empty($data['component']) && is_array($data['component']))
{
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($data['component'])))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
)->execute();
$count++;
}
$this->jsonResponse(['success' => true, 'message' => "Imported settings for {$count} extensions."]);
}
// ==================================================================
// WAF Log
// ==================================================================
public function purgeWafLog()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin'))
{
$this->jsonForbidden();
return;
}
$days = Factory::getApplication()->getInput()->getInt('days', 30);
$model = new \Moko\Component\MokoWaaS\Administrator\Model\WaflogModel();
$this->jsonResponse($model->purgeLogs($days));
}
public function banIpFromLog()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin'))
{
$this->jsonForbidden();
return;
}
$ip = Factory::getApplication()->getInput()->getString('ip', '');
$model = new \Moko\Component\MokoWaaS\Administrator\Model\WaflogModel();
$this->jsonResponse($model->banIp($ip));
}
// ==================================================================
// Privacy Guard
// ==================================================================
public function processDataRequest()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin'))
{
$this->jsonForbidden();
return;
}
$input = Factory::getApplication()->getInput();
$model = new \Moko\Component\MokoWaaS\Administrator\Model\PrivacyModel();
$action = $input->getString('action', 'deny');
if ($action === 'create')
{
$result = $model->createRequest(
$input->getInt('user_id', 0),
$input->getString('type', 'export')
);
$this->jsonResponse($result);
return;
}
if ($action === 'approve' && !$input->getInt('request_id', 0) && $input->getInt('user_id', 0))
{
// Auto-process: create then immediately approve
$result = $model->createRequest(
$input->getInt('user_id', 0),
$input->getString('type', 'export')
);
if ($result['success'] && !empty($result['id']))
{
$result = $model->processRequest((int) $result['id'], 'approve');
}
$this->jsonResponse($result);
return;
}
$this->jsonResponse($model->processRequest(
$input->getInt('request_id', 0),
$action
));
}
public function exportUserData()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin'))
{
$this->jsonForbidden();
return;
}
$model = new \Moko\Component\MokoWaaS\Administrator\Model\PrivacyModel();
$this->jsonResponse($model->exportUserData(
Factory::getApplication()->getInput()->getInt('user_id', 0)
));
}
// ==================================================================
// Importers
// ==================================================================
public function importAts()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('mokowaas.tickets'))
{
$this->jsonForbidden();
return;
}
$this->jsonResponse($this->getModel('Import')->importAts());
}
public function importAdminTools()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
if (!$this->checkAcl('core.admin'))
{
$this->jsonForbidden();
return;
}
$this->jsonResponse($this->getModel('Import')->importAdminTools());
}
// ==================================================================
// Helpers
// ==================================================================
/**
* Check a MokoWaaS ACL permission for the current user.
*/
private function checkAcl(string $action): bool
{
$user = Factory::getApplication()->getIdentity();
// Super admins always pass
if ($user->authorise('core.admin', 'com_mokowaas'))
{
return true;
}
return $user->authorise($action, 'com_mokowaas');
}
/**
* Send a JSON response and close.
*/
private function jsonResponse(array $data): void
{
$app = Factory::getApplication();
$app->setHeader('Content-Type', 'application/json');
echo json_encode($data);
$app->close();
}
/**
* Send a 403 JSON response and close.
*/
private function jsonForbidden(): void
{
$this->jsonResponse(['success' => false, 'message' => Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN')]);
return;
}
}
@@ -22,39 +22,60 @@ class DashboardModel extends BaseDatabaseModel
*/
private const PLUGIN_META = [
'mokowaas' => [
'icon' => 'icon-shield-alt',
'category' => 'core',
'label' => 'Core — Branding & Identity',
'description' => 'White-label branding, master user enforcement, emergency access, and plugin protection.',
'protected' => true,
'icon' => 'icon-shield-alt',
'category' => 'core',
'label' => 'Core — Branding & Identity',
'description' => 'White-label branding, master user enforcement, emergency access, and plugin protection.',
'protected' => true,
'configure_only' => false,
],
'mokowaas_firewall' => [
'icon' => 'icon-lock',
'category' => 'security',
'label' => 'Firewall',
'description' => 'HTTPS enforcement, trusted IPs, session timeout, upload restrictions, and password policy.',
'protected' => false,
'icon' => 'icon-lock',
'category' => 'security',
'label' => 'Firewall',
'description' => 'Web Application Firewall — SQLi, XSS, RFI, DFI shields, IP blocklist, admin secret URL, file protection.',
'protected' => false,
'configure_only' => false,
],
'mokowaas_tenant' => [
'icon' => 'icon-users',
'category' => 'security',
'label' => 'Tenant Restrictions',
'description' => 'Installer, sysinfo, config, and template access restrictions for non-master users.',
'protected' => false,
'icon' => 'icon-users',
'category' => 'security',
'label' => 'Tenant Restrictions',
'description' => 'Installer, sysinfo, config, and template access restrictions for non-master users.',
'protected' => false,
'configure_only' => false,
],
'mokowaas_offline' => [
'icon' => 'icon-globe',
'category' => 'security',
'label' => 'Offline Bypass',
'description' => 'Keep selected pages (TOS, Privacy Policy) accessible during offline mode.',
'protected' => false,
'configure_only' => true,
],
'mokowaas_devtools' => [
'icon' => 'icon-wrench',
'category' => 'tools',
'label' => 'Developer Tools',
'description' => 'Dev mode, hit counter reset, content version cleanup.',
'protected' => false,
'icon' => 'icon-wrench',
'category' => 'tools',
'label' => 'Developer Tools',
'description' => 'Dev mode, hit counter reset, content version cleanup. Features are controlled inside the plugin settings.',
'protected' => false,
'configure_only' => true,
],
'mokowaas_monitor' => [
'icon' => 'icon-heartbeat',
'category' => 'monitoring',
'label' => 'Health Monitor',
'description' => 'Site health checks, Grafana heartbeat integration, and diagnostics.',
'protected' => false,
'mokowaasdemo' => [
'icon' => 'icon-undo',
'category' => 'content',
'label' => 'Demo Reset Task',
'description' => 'Scheduled demo site reset with content snapshots.',
'protected' => false,
'configure_only' => true,
],
'mokowaassync' => [
'icon' => 'icon-sync',
'category' => 'content',
'label' => 'Content Sync Task',
'description' => 'Scheduled content synchronisation to remote MokoWaaS sites.',
'protected' => false,
'configure_only' => true,
],
];
@@ -97,7 +118,8 @@ class DashboardModel extends BaseDatabaseModel
'(' . $db->quoteName('type') . ' = ' . $db->quote('plugin')
. ' AND ' . $db->quoteName('folder') . ' = ' . $db->quote('system')
. ' AND (' . $db->quoteName('element') . ' = ' . $db->quote('mokowaas')
. ' OR ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mokowaas\_%') . '))'
. ' OR ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mokowaas\_%') . ')'
. ' AND ' . $db->quoteName('element') . ' != ' . $db->quote('mokowaas_monitor') . ')'
// Webservices plugins
. ' OR (' . $db->quoteName('type') . ' = ' . $db->quote('plugin')
. ' AND ' . $db->quoteName('folder') . ' = ' . $db->quote('webservices')
@@ -120,8 +142,10 @@ class DashboardModel extends BaseDatabaseModel
$manifest = json_decode($row->manifest_cache ?? '{}');
$version = $manifest->version ?? '';
// Build a lookup key: system plugins use element, others use folder_element
$metaKey = $row->element;
// Only system plugins and task plugins match PLUGIN_META by element
$metaKey = ($row->folder === 'system' || $row->folder === 'task')
? $row->element
: $row->folder . '_' . $row->element;
$meta = self::PLUGIN_META[$metaKey] ?? null;
@@ -135,19 +159,20 @@ class DashboardModel extends BaseDatabaseModel
$categoryInfo = self::CATEGORIES[$categoryKey] ?? self::CATEGORIES['tools'];
$plugins[] = (object) [
'extension_id' => (int) $row->extension_id,
'name' => $meta['label'] ?? $row->name,
'element' => $row->element,
'folder' => $row->folder,
'type' => $row->type,
'enabled' => (int) $row->enabled,
'protected' => (int) $row->protected || ($meta['protected'] ?? false),
'version' => $version,
'icon' => $meta['icon'] ?? 'icon-puzzle-piece',
'category' => $categoryKey,
'extension_id' => (int) $row->extension_id,
'name' => $meta['label'] ?? $row->name,
'element' => $row->element,
'folder' => $row->folder,
'type' => $row->type,
'enabled' => (int) $row->enabled,
'protected' => (bool) ($meta['protected'] ?? false),
'configure_only' => (bool) ($meta['configure_only'] ?? false),
'version' => $version,
'icon' => $meta['icon'] ?? 'icon-puzzle-piece',
'category' => $categoryKey,
'categoryLabel' => $categoryInfo['label'],
'categoryBadge' => $categoryInfo['badge'],
'description' => $meta['description'] ?? '',
'description' => $meta['description'] ?? '',
];
}
@@ -242,13 +267,18 @@ class DashboardModel extends BaseDatabaseModel
{
try
{
$app = Factory::getApplication();
$app->get('cache_handler', 'file');
// Clear site and admin caches
// Use Joomla's native cache API — same as com_cache
$cache = Factory::getContainer()->get(\Joomla\CMS\Cache\CacheControllerFactoryInterface::class);
Factory::getCache('', '')->gc();
Factory::getCache('', '', 'administrator')->gc();
$cache->createCacheController('', ['defaultgroup' => ''])->cache->clean('');
// Also clean admin cache
$conf = Factory::getApplication()->get('cache_handler', 'file');
$options = [
'defaultgroup' => '',
'cachebase' => JPATH_ADMINISTRATOR . '/cache',
'storage' => $conf,
];
$cache->createCacheController('', $options)->cache->clean('');
// Clear opcache if available
if (\function_exists('opcache_reset'))
@@ -256,7 +286,7 @@ class DashboardModel extends BaseDatabaseModel
\opcache_reset();
}
return ['success' => true, 'message' => 'Cache cleared successfully.'];
return ['success' => true, 'message' => 'All cache cleared successfully.'];
}
catch (\Throwable $e)
{
@@ -302,4 +332,204 @@ class DashboardModel extends BaseDatabaseModel
return $meta;
}
/**
* Get recent admin login attempts from action logs.
*/
public function getRecentLogins(int $limit = 10): array
{
try
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select([
$db->quoteName('a.message'),
$db->quoteName('a.log_date'),
$db->quoteName('a.ip_address'),
$db->quoteName('u.username'),
])
->from($db->quoteName('#__action_logs', 'a'))
->leftJoin($db->quoteName('#__users', 'u') . ' ON ' . $db->quoteName('u.id') . ' = ' . $db->quoteName('a.user_id'))
->where($db->quoteName('a.message_language_key') . ' LIKE ' . $db->quote('%LOGIN%'))
->order($db->quoteName('a.log_date') . ' DESC')
->setLimit($limit);
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
catch (\Throwable $e)
{
return [];
}
}
/**
* Get pending extension updates.
*/
public function getPendingUpdates(): array
{
try
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select([
$db->quoteName('u.name'),
$db->quoteName('u.version'),
$db->quoteName('u.type'),
$db->quoteName('e.manifest_cache'),
])
->from($db->quoteName('#__updates', 'u'))
->leftJoin($db->quoteName('#__extensions', 'e') . ' ON ' . $db->quoteName('e.extension_id') . ' = ' . $db->quoteName('u.extension_id'))
->where($db->quoteName('u.extension_id') . ' != 0')
->order($db->quoteName('u.name') . ' ASC');
$db->setQuery($query);
$rows = $db->loadObjectList() ?: [];
foreach ($rows as $row)
{
$mc = json_decode($row->manifest_cache ?? '{}');
$row->current_version = $mc->version ?? '';
}
return $rows;
}
catch (\Throwable $e)
{
return [];
}
}
/**
* Get checked-out items count and details.
*/
public function getCheckedOutItems(): array
{
try
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select([
$db->quoteName('c.title'),
$db->quoteName('c.checked_out'),
$db->quoteName('c.checked_out_time'),
$db->quoteName('u.username'),
])
->from($db->quoteName('#__content', 'c'))
->leftJoin($db->quoteName('#__users', 'u') . ' ON ' . $db->quoteName('u.id') . ' = ' . $db->quoteName('c.checked_out'))
->where($db->quoteName('c.checked_out') . ' IS NOT NULL')
->where($db->quoteName('c.checked_out') . ' != 0')
->order($db->quoteName('c.checked_out_time') . ' DESC')
->setLimit(10);
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
catch (\Throwable $e)
{
return [];
}
}
/**
* Get recent WAF blocks from the log table.
*/
public function getRecentWafBlocks(int $limit = 10): array
{
try
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_waf_log'))
->order($db->quoteName('created') . ' DESC')
->setLimit($limit);
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
catch (\Throwable $e)
{
return [];
}
}
/**
* WAF blocks per day for the last 14 days.
*/
public function getWafBlocksByDay(int $days = 14): array
{
try
{
$db = $this->getDatabase();
$db->setQuery(
"SELECT DATE(" . $db->quoteName('created') . ") AS day, COUNT(*) AS total"
. " FROM " . $db->quoteName('#__mokowaas_waf_log')
. " WHERE " . $db->quoteName('created') . " >= DATE_SUB(NOW(), INTERVAL $days DAY)"
. " GROUP BY day ORDER BY day"
);
$rows = $db->loadObjectList() ?: [];
// Fill in missing days with zero
$result = [];
$date = new \DateTime("-{$days} days");
$now = new \DateTime('now');
$map = [];
foreach ($rows as $r)
{
$map[$r->day] = (int) $r->total;
}
while ($date <= $now)
{
$key = $date->format('Y-m-d');
$result[] = (object) ['day' => $date->format('M d'), 'total' => $map[$key] ?? 0];
$date->modify('+1 day');
}
return $result;
}
catch (\Throwable $e)
{
return [];
}
}
/**
* Admin logins per day for the last 14 days.
*/
public function getLoginsByDay(int $days = 14): array
{
try
{
$db = $this->getDatabase();
$db->setQuery(
"SELECT DATE(" . $db->quoteName('log_date') . ") AS day, COUNT(*) AS total"
. " FROM " . $db->quoteName('#__action_logs')
. " WHERE " . $db->quoteName('message_language_key') . " = 'PLG_ACTIONLOG_JOOMLA_USER_LOGGED_IN'"
. " AND " . $db->quoteName('log_date') . " >= DATE_SUB(NOW(), INTERVAL $days DAY)"
. " GROUP BY day ORDER BY day"
);
$rows = $db->loadObjectList() ?: [];
$result = [];
$date = new \DateTime("-{$days} days");
$now = new \DateTime('now');
$map = [];
foreach ($rows as $r)
{
$map[$r->day] = (int) $r->total;
}
while ($date <= $now)
{
$key = $date->format('Y-m-d');
$result[] = (object) ['day' => $date->format('M d'), 'total' => $map[$key] ?? 0];
$date->modify('+1 day');
}
return $result;
}
catch (\Throwable $e)
{
return [];
}
}
}
@@ -0,0 +1,315 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
/**
* Extension manager model — fetches Moko Consulting Joomla packages
* from the Gitea API and checks local install status.
*
* @since 02.32.00
*/
class ExtensionsModel extends BaseDatabaseModel
{
/**
* Curated catalog of Moko Consulting Joomla packages.
* Each entry maps a Gitea repo name to local extension metadata.
*/
private const CATALOG = [
'MokoWaaS' => [
'label' => 'MokoWaaS',
'description' => 'Admin dashboard, security firewall, tenant restrictions, health monitoring, and REST API.',
'element' => 'pkg_mokowaas',
'type' => 'package',
'icon' => 'icon-shield-alt',
'category' => 'Platform',
'article' => 'https://mokoconsulting.tech/support/products/mokowaas-platform',
'protected' => true,
],
'MokoOnyx' => [
'label' => 'MokoOnyx',
'description' => 'Modern Joomla site template with dark mode, custom layouts, and MokoWaaS integration.',
'element' => 'mokoonyx',
'type' => 'template',
'icon' => 'icon-paint-brush',
'category' => 'Templates',
'article' => 'https://mokoconsulting.tech/support/products/mokoonyx-template',
'protected' => false,
],
'MokoJoomTOS' => [
'label' => 'MokoJoomTOS',
'description' => 'Terms of Service and privacy policy component with consent tracking.',
'element' => 'com_mokojoomtos',
'type' => 'component',
'icon' => 'icon-file-contract',
'category' => 'Components',
'article' => 'https://mokoconsulting.tech/support/products/mokojoomtos',
'protected' => false,
],
'MokoJoomHero' => [
'label' => 'MokoJoomHero',
'description' => 'Random hero image module from a configurable folder.',
'element' => 'mod_mokojoomhero',
'type' => 'module',
'icon' => 'icon-image',
'category' => 'Modules',
'article' => 'https://mokoconsulting.tech/support/products/mokojoomhero',
'protected' => false,
],
'MokoWaaSAnnounce' => [
'label' => 'MokoWaaS Announce',
'description' => 'Centralized announcement system via admin module.',
'element' => 'mod_mokowaas_announce',
'type' => 'module',
'icon' => 'icon-bullhorn',
'category' => 'Modules',
'article' => 'https://mokoconsulting.tech/support/products/mokowaas-announce',
'protected' => false,
],
'MokoDPCalendarAPI' => [
'label' => 'DPCalendar API',
'description' => 'Web Services plugin exposing DPCalendar events and calendars via REST API.',
'element' => 'mokodpcalendarapi',
'type' => 'plugin',
'icon' => 'icon-calendar',
'category' => 'Plugins',
'article' => 'https://mokoconsulting.tech/support/products/mokodpcalendarapi',
'protected' => false,
],
'MokoGalleryCalendar' => [
'label' => 'Gallery Calendar',
'description' => 'JoomGallery and DPCalendar integration — link galleries to events.',
'element' => 'mokogallerycalendar',
'type' => 'plugin',
'icon' => 'icon-images',
'category' => 'Plugins',
'article' => 'https://mokoconsulting.tech/support/products/mokogallerycalendar',
'protected' => false,
],
'MokoJoomOpenGraph' => [
'label' => 'MokoJoomOpenGraph',
'description' => 'Open Graph meta tags for articles, categories, and pages. Controls Facebook, Twitter, and LinkedIn link previews.',
'element' => 'pkg_mokoog',
'type' => 'package',
'icon' => 'icon-share-alt',
'category' => 'Components',
'article' => 'https://mokoconsulting.tech/support/products/mokojoomopengraph',
'protected' => false,
],
];
private const GITEA_URL = 'https://git.mokoconsulting.tech';
private const GITEA_ORG = 'MokoConsulting';
/**
* Get the full catalog with install status and release info.
*
* @return array
*/
public function getCatalog(): array
{
$installed = $this->getInstalledVersions();
$packages = [];
foreach (self::CATALOG as $repo => $meta)
{
$release = $this->fetchLatestRelease($repo);
$localVersion = $installed[$meta['element']] ?? null;
$remoteVersion = $release['version'] ?? '';
$downloadUrl = $release['download_url'] ?? '';
$status = ($localVersion !== null) ? 'installed' : 'not_installed';
// Get extension_id for uninstall link
$extensionId = $this->getExtensionId($meta['element']);
$packages[] = (object) [
'repo' => $repo,
'label' => $meta['label'],
'description' => $meta['description'],
'element' => $meta['element'],
'type' => $meta['type'],
'icon' => $meta['icon'],
'category' => $meta['category'],
'local_version' => $localVersion ?? '',
'remote_version' => $remoteVersion,
'download_url' => $downloadUrl,
'status' => $status,
'article_url' => $meta['article'] ?? '',
'protected' => $meta['protected'] ?? false,
'extension_id' => $extensionId,
];
}
return $packages;
}
/**
* Install an extension from a remote ZIP URL.
*
* @param string $url The download URL.
*
* @return array Result with success, message, and extension info.
*/
public function installFromUrl(string $url): array
{
$tmpPath = Factory::getConfig()->get('tmp_path', JPATH_ROOT . '/tmp');
$tmpFile = $tmpPath . '/mokowaas_install_' . md5($url) . '.zip';
try
{
// Download
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 120);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$data = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error || $code !== 200 || empty($data))
{
return ['success' => false, 'message' => 'Download failed: ' . ($error ?: "HTTP {$code}")];
}
file_put_contents($tmpFile, $data);
// Install via Joomla Installer
$installer = new \Joomla\CMS\Installer\Installer();
$result = $installer->install($tmpFile);
@unlink($tmpFile);
if (!$result)
{
return ['success' => false, 'message' => 'Installation failed.'];
}
return [
'success' => true,
'message' => 'Installed successfully.',
];
}
catch (\Throwable $e)
{
@unlink($tmpFile);
return ['success' => false, 'message' => 'Error: ' . $e->getMessage()];
}
}
/**
* Get installed versions of all Moko extensions.
*
* @return array element => version
*/
private function getInstalledVersions(): array
{
$db = $this->getDatabase();
$elements = [];
foreach (self::CATALOG as $meta)
{
$elements[] = $db->quote($meta['element']);
}
$query = $db->getQuery(true)
->select([$db->quoteName('element'), $db->quoteName('manifest_cache')])
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' IN (' . implode(',', $elements) . ')');
$db->setQuery($query);
$rows = $db->loadObjectList() ?: [];
$versions = [];
foreach ($rows as $row)
{
$mc = json_decode($row->manifest_cache ?? '{}');
$versions[$row->element] = $mc->version ?? '0.0.0';
}
return $versions;
}
/**
* Fetch the latest release from Gitea for a repo.
*
* @param string $repo Repository name.
*
* @return array [version, download_url] or empty.
*/
private function fetchLatestRelease(string $repo): array
{
$url = self::GITEA_URL . '/api/v1/repos/' . self::GITEA_ORG . '/' . $repo . '/releases?limit=1';
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Accept: application/json']);
$response = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200 || empty($response))
{
return [];
}
$releases = json_decode($response, true);
if (empty($releases[0]))
{
return [];
}
$release = $releases[0];
$version = $release['tag_name'] ?? '';
// Find the first .zip asset
$downloadUrl = '';
foreach ($release['assets'] ?? [] as $asset)
{
if (str_ends_with(strtolower($asset['name'] ?? ''), '.zip'))
{
$downloadUrl = $asset['browser_download_url'] ?? '';
break;
}
}
return [
'version' => $version,
'download_url' => $downloadUrl,
];
}
/**
* Get the extension_id for an element (for uninstall links).
*/
private function getExtensionId(string $element): int
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select($db->quoteName('extension_id'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote($element))
->setLimit(1);
$db->setQuery($query);
return (int) $db->loadResult();
}
}
@@ -0,0 +1,522 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Joomla\Registry\Registry;
/**
* .htaccess / NginX configuration generator.
*
* @since 02.32.00
*/
class HtaccessModel extends BaseDatabaseModel
{
private const DEFAULTS = [
// Security
'disable_directory_listing' => 1,
'block_sensitive_files' => 1,
'block_php_in_uploads' => 1,
'disable_server_signature' => 1,
'prevent_clickjacking' => 1,
'prevent_mime_sniffing' => 1,
'xss_protection' => 1,
'disable_trace_track' => 1,
'referrer_policy' => 'strict-origin-when-cross-origin',
'hsts_enabled' => 0,
'hsts_max_age' => 31536000,
'hsts_subdomains' => 0,
'csp_enabled' => 0,
'csp_value' => '',
'permissions_policy' => 0,
'permissions_value' => '',
// Performance
'enable_gzip' => 1,
'enable_expires' => 1,
'expires_html' => 3600,
'expires_css_js' => 2592000,
'expires_images' => 31536000,
'etag_control' => 0,
// SEO
'www_redirect' => 'off',
'redirect_index_php' => 1,
'force_trailing_slash' => 0,
// Custom
'custom_rules' => '',
];
/**
* Get saved options or defaults.
*/
public function getOptions(): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'));
$db->setQuery($query);
$params = new Registry($db->loadResult() ?? '{}');
$htaccess = $params->get('htaccess', null);
if ($htaccess)
{
return array_merge(self::DEFAULTS, (array) json_decode(json_encode($htaccess), true));
}
return self::DEFAULTS;
}
/**
* Save options to component params.
*/
public function saveOptions(array $options): array
{
try
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'));
$db->setQuery($query);
$params = new Registry($db->loadResult() ?? '{}');
$clean = [];
foreach (self::DEFAULTS as $key => $default)
{
$clean[$key] = $options[$key] ?? $default;
}
$params->set('htaccess', $clean);
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('params') . ' = ' . $db->quote($params->toString()))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
)->execute();
return ['success' => true, 'message' => 'Options saved.'];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Save failed: ' . $e->getMessage()];
}
}
/**
* Read the current .htaccess file.
*/
public function readCurrentHtaccess(): string
{
$path = JPATH_ROOT . '/.htaccess';
return file_exists($path) ? file_get_contents($path) : '';
}
/**
* Write .htaccess to disk with backup.
*/
public function saveHtaccess(string $content): array
{
$path = JPATH_ROOT . '/.htaccess';
$backup = JPATH_ROOT . '/.htaccess.mokowaas.bak';
try
{
// Backup existing
if (file_exists($path))
{
copy($path, $backup);
}
$result = file_put_contents($path, $content);
if ($result === false)
{
// Restore backup
if (file_exists($backup))
{
copy($backup, $path);
}
return ['success' => false, 'message' => '.htaccess is not writable.'];
}
return ['success' => true, 'message' => '.htaccess saved. Backup at .htaccess.mokowaas.bak'];
}
catch (\Throwable $e)
{
if (file_exists($backup))
{
@copy($backup, $path);
}
return ['success' => false, 'message' => 'Write failed: ' . $e->getMessage()];
}
}
/**
* Generate .htaccess content from options.
*/
public function generateHtaccess(array $opts): string
{
$lines = [];
$lines[] = '##';
$lines[] = '## MokoWaaS Generated .htaccess';
$lines[] = '## Generated: ' . gmdate('Y-m-d H:i:s') . ' UTC';
$lines[] = '## DO NOT EDIT — regenerate from MokoWaaS > .htaccess Maker';
$lines[] = '##';
$lines[] = '';
// --- Security ---
if (!empty($opts['disable_directory_listing']))
{
$lines[] = '## Disable directory listing';
$lines[] = 'Options -Indexes';
$lines[] = '';
}
if (!empty($opts['disable_server_signature']))
{
$lines[] = '## Hide server signature';
$lines[] = 'ServerSignature Off';
$lines[] = '<IfModule mod_headers.c>';
$lines[] = ' Header unset X-Powered-By';
$lines[] = ' Header unset Server';
$lines[] = '</IfModule>';
$lines[] = '';
}
if (!empty($opts['block_sensitive_files']))
{
$lines[] = '## Block access to sensitive files';
$lines[] = '<FilesMatch "^(htaccess\.txt|web\.config\.txt|configuration\.php-dist|README\.txt|LICENSE\.txt|joomla\.xml|robots\.txt\.dist)$">';
$lines[] = ' <IfModule mod_authz_core.c>';
$lines[] = ' Require all denied';
$lines[] = ' </IfModule>';
$lines[] = '</FilesMatch>';
$lines[] = '';
}
if (!empty($opts['block_php_in_uploads']))
{
$lines[] = '## Block PHP execution in upload directories';
$dirs = ['images', 'media', 'tmp', 'cache', 'logs'];
foreach ($dirs as $dir)
{
$lines[] = '<Directory "' . $dir . '">';
$lines[] = ' <FilesMatch "\.php$">';
$lines[] = ' <IfModule mod_authz_core.c>';
$lines[] = ' Require all denied';
$lines[] = ' </IfModule>';
$lines[] = ' </FilesMatch>';
$lines[] = '</Directory>';
}
$lines[] = '';
}
if (!empty($opts['disable_trace_track']))
{
$lines[] = '## Disable TRACE and TRACK methods';
$lines[] = '<IfModule mod_rewrite.c>';
$lines[] = ' RewriteEngine On';
$lines[] = ' RewriteCond %{REQUEST_METHOD} ^(TRACE|TRACK)';
$lines[] = ' RewriteRule .* - [F]';
$lines[] = '</IfModule>';
$lines[] = '';
}
// Security headers
$headers = [];
if (!empty($opts['prevent_clickjacking']))
{
$headers[] = ' Header always set X-Frame-Options "SAMEORIGIN"';
}
if (!empty($opts['prevent_mime_sniffing']))
{
$headers[] = ' Header always set X-Content-Type-Options "nosniff"';
}
if (!empty($opts['xss_protection']))
{
$headers[] = ' Header always set X-XSS-Protection "1; mode=block"';
}
$referrer = $opts['referrer_policy'] ?? '';
if (!empty($referrer) && $referrer !== 'off')
{
$headers[] = ' Header always set Referrer-Policy "' . $referrer . '"';
}
if (!empty($opts['hsts_enabled']))
{
$maxAge = (int) ($opts['hsts_max_age'] ?? 31536000);
$hsts = 'max-age=' . $maxAge;
if (!empty($opts['hsts_subdomains']))
{
$hsts .= '; includeSubDomains';
}
$headers[] = ' Header always set Strict-Transport-Security "' . $hsts . '"';
}
if (!empty($opts['csp_enabled']) && !empty($opts['csp_value']))
{
$headers[] = ' Header always set Content-Security-Policy "' . str_replace('"', '', $opts['csp_value']) . '"';
}
if (!empty($opts['permissions_policy']) && !empty($opts['permissions_value']))
{
$headers[] = ' Header always set Permissions-Policy "' . str_replace('"', '', $opts['permissions_value']) . '"';
}
if (!empty($headers))
{
$lines[] = '## Security headers';
$lines[] = '<IfModule mod_headers.c>';
$lines = array_merge($lines, $headers);
$lines[] = '</IfModule>';
$lines[] = '';
}
// --- Performance ---
if (!empty($opts['enable_gzip']))
{
$lines[] = '## GZip compression';
$lines[] = '<IfModule mod_deflate.c>';
$lines[] = ' AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css';
$lines[] = ' AddOutputFilterByType DEFLATE text/javascript application/javascript application/x-javascript';
$lines[] = ' AddOutputFilterByType DEFLATE application/json application/xml application/rss+xml';
$lines[] = ' AddOutputFilterByType DEFLATE image/svg+xml application/font-woff application/font-woff2';
$lines[] = '</IfModule>';
$lines[] = '';
}
if (!empty($opts['enable_expires']))
{
$html = (int) ($opts['expires_html'] ?? 3600);
$cssJs = (int) ($opts['expires_css_js'] ?? 2592000);
$images = (int) ($opts['expires_images'] ?? 31536000);
$lines[] = '## Browser caching';
$lines[] = '<IfModule mod_expires.c>';
$lines[] = ' ExpiresActive On';
$lines[] = ' ExpiresDefault "access plus ' . $html . ' seconds"';
$lines[] = ' ExpiresByType text/html "access plus ' . $html . ' seconds"';
$lines[] = ' ExpiresByType text/css "access plus ' . $cssJs . ' seconds"';
$lines[] = ' ExpiresByType text/javascript "access plus ' . $cssJs . ' seconds"';
$lines[] = ' ExpiresByType application/javascript "access plus ' . $cssJs . ' seconds"';
$lines[] = ' ExpiresByType image/jpeg "access plus ' . $images . ' seconds"';
$lines[] = ' ExpiresByType image/png "access plus ' . $images . ' seconds"';
$lines[] = ' ExpiresByType image/gif "access plus ' . $images . ' seconds"';
$lines[] = ' ExpiresByType image/webp "access plus ' . $images . ' seconds"';
$lines[] = ' ExpiresByType image/svg+xml "access plus ' . $images . ' seconds"';
$lines[] = ' ExpiresByType font/woff2 "access plus ' . $images . ' seconds"';
$lines[] = '</IfModule>';
$lines[] = '';
}
if (!empty($opts['etag_control']))
{
$lines[] = '## Disable ETags (for load-balanced environments)';
$lines[] = '<IfModule mod_headers.c>';
$lines[] = ' Header unset ETag';
$lines[] = '</IfModule>';
$lines[] = 'FileETag None';
$lines[] = '';
}
// --- SEO / Redirects ---
$wwwRedirect = $opts['www_redirect'] ?? 'off';
if ($wwwRedirect !== 'off' || !empty($opts['redirect_index_php']) || !empty($opts['force_trailing_slash']))
{
$lines[] = '## SEO redirects';
$lines[] = '<IfModule mod_rewrite.c>';
$lines[] = ' RewriteEngine On';
if ($wwwRedirect === 'www')
{
$lines[] = '';
$lines[] = ' ## Force www';
$lines[] = ' RewriteCond %{HTTP_HOST} !^www\. [NC]';
$lines[] = ' RewriteRule ^(.*)$ https://www.%{HTTP_HOST}/$1 [R=301,L]';
}
elseif ($wwwRedirect === 'non-www')
{
$lines[] = '';
$lines[] = ' ## Force non-www';
$lines[] = ' RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]';
$lines[] = ' RewriteRule ^(.*)$ https://%1/$1 [R=301,L]';
}
if (!empty($opts['redirect_index_php']))
{
$lines[] = '';
$lines[] = ' ## Redirect /index.php to root';
$lines[] = ' RewriteCond %{THE_REQUEST} ^[A-Z]{3,}\s/+index\.php\s [NC]';
$lines[] = ' RewriteRule ^index\.php/?(.*)$ /$1 [R=301,L]';
}
if (!empty($opts['force_trailing_slash']))
{
$lines[] = '';
$lines[] = ' ## Force trailing slash';
$lines[] = ' RewriteCond %{REQUEST_FILENAME} !-f';
$lines[] = ' RewriteCond %{REQUEST_URI} !(.*)/$';
$lines[] = ' RewriteRule ^(.*)$ /$1/ [R=301,L]';
}
$lines[] = '</IfModule>';
$lines[] = '';
}
// --- Custom rules ---
$custom = trim($opts['custom_rules'] ?? '');
if (!empty($custom))
{
$lines[] = '## Custom rules';
$lines[] = $custom;
$lines[] = '';
}
return implode("\n", $lines);
}
/**
* Generate equivalent NginX configuration snippet.
*/
public function generateNginx(array $opts): string
{
$lines = [];
$lines[] = '## MokoWaaS Generated NginX Configuration';
$lines[] = '## Add these directives inside your server { } block';
$lines[] = '';
if (!empty($opts['disable_directory_listing']))
{
$lines[] = '# Disable directory listing';
$lines[] = 'autoindex off;';
$lines[] = '';
}
if (!empty($opts['disable_server_signature']))
{
$lines[] = '# Hide server version';
$lines[] = 'server_tokens off;';
$lines[] = '';
}
if (!empty($opts['block_sensitive_files']))
{
$lines[] = '# Block sensitive files';
$lines[] = 'location ~* (htaccess\.txt|web\.config\.txt|configuration\.php-dist|README\.txt|LICENSE\.txt)$ {';
$lines[] = ' deny all;';
$lines[] = '}';
$lines[] = '';
}
if (!empty($opts['block_php_in_uploads']))
{
$lines[] = '# Block PHP in upload directories';
$lines[] = 'location ~* ^/(images|media|tmp|cache|logs)/.*\.php$ {';
$lines[] = ' deny all;';
$lines[] = '}';
$lines[] = '';
}
// Headers
$hdrs = [];
if (!empty($opts['prevent_clickjacking']))
{
$hdrs[] = 'add_header X-Frame-Options "SAMEORIGIN" always;';
}
if (!empty($opts['prevent_mime_sniffing']))
{
$hdrs[] = 'add_header X-Content-Type-Options "nosniff" always;';
}
if (!empty($opts['xss_protection']))
{
$hdrs[] = 'add_header X-XSS-Protection "1; mode=block" always;';
}
$referrer = $opts['referrer_policy'] ?? '';
if (!empty($referrer) && $referrer !== 'off')
{
$hdrs[] = 'add_header Referrer-Policy "' . $referrer . '" always;';
}
if (!empty($opts['hsts_enabled']))
{
$maxAge = (int) ($opts['hsts_max_age'] ?? 31536000);
$hsts = 'max-age=' . $maxAge;
if (!empty($opts['hsts_subdomains']))
{
$hsts .= '; includeSubDomains';
}
$hdrs[] = 'add_header Strict-Transport-Security "' . $hsts . '" always;';
}
if (!empty($hdrs))
{
$lines[] = '# Security headers';
$lines = array_merge($lines, $hdrs);
$lines[] = '';
}
if (!empty($opts['enable_gzip']))
{
$lines[] = '# GZip compression';
$lines[] = 'gzip on;';
$lines[] = 'gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;';
$lines[] = 'gzip_min_length 256;';
$lines[] = '';
}
if (!empty($opts['enable_expires']))
{
$cssJs = (int) ($opts['expires_css_js'] ?? 2592000);
$images = (int) ($opts['expires_images'] ?? 31536000);
$lines[] = '# Browser caching';
$lines[] = 'location ~* \.(css|js)$ {';
$lines[] = ' expires ' . round($cssJs / 86400) . 'd;';
$lines[] = '}';
$lines[] = 'location ~* \.(jpg|jpeg|png|gif|webp|svg|ico|woff2)$ {';
$lines[] = ' expires ' . round($images / 86400) . 'd;';
$lines[] = '}';
$lines[] = '';
}
return implode("\n", $lines);
}
}
@@ -0,0 +1,688 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Log\Log;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Joomla\Registry\Registry;
/**
* Importer for migrating from Akeeba Admin Tools to MokoWaaS.
*
* Reads Admin Tools WAF config, htaccess settings, IP blocklists,
* and security headers — maps them to MokoWaaS firewall plugin params
* and htaccess maker options.
*
* @since 02.32.00
*/
class ImportModel extends BaseDatabaseModel
{
/**
* Check if Admin Tools data is available for import.
* Returns null if already imported or no data found.
*/
public function checkAdminToolsAvailable(): ?object
{
if ($this->wasImported('admintools'))
{
return null;
}
$db = $this->getDatabase();
try
{
$result = (object) [
'component' => false,
'waf_config' => false,
'storage' => false,
'ip_blocks' => 0,
];
// Check component
$db->setQuery("SELECT COUNT(*) FROM #__extensions WHERE element = 'com_admintools' AND type = 'component'");
$result->component = (int) $db->loadResult() > 0;
// Check WAF config table
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_wafconfig%'));
if ($db->loadResult())
{
$result->waf_config = true;
$db->setQuery('SELECT COUNT(*) FROM #__admintools_wafconfig');
$result->waf_settings = (int) $db->loadResult();
}
// Check storage table
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_storage%'));
if ($db->loadResult())
{
$result->storage = true;
}
// Check IP blocklist
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_ipblock%'));
if ($db->loadResult())
{
$db->setQuery('SELECT COUNT(*) FROM #__admintools_ipblock');
$result->ip_blocks = (int) $db->loadResult();
}
// Only available if at least one data source exists
if (!$result->component && !$result->waf_config && !$result->storage)
{
return null;
}
return $result;
}
catch (\Throwable $e)
{
return null;
}
}
/**
* Import Admin Tools settings into MokoWaaS.
*/
public function importAdminTools(): array
{
$db = $this->getDatabase();
$results = ['firewall' => 0, 'htaccess' => 0, 'ip_blocks' => 0, 'disabled' => false];
try
{
// ============================================================
// 1. Import WAF Config → Firewall plugin params
// ============================================================
$wafSettings = $this->readWafConfig($db);
$firewallParams = $this->mapWafToFirewall($wafSettings);
if (!empty($firewallParams))
{
$this->mergePluginParams('mokowaas_firewall', 'system', $firewallParams);
$results['firewall'] = \count($firewallParams);
}
// ============================================================
// 2. Import htaccess settings → component htaccess options
// ============================================================
$htaccessSettings = $this->readHtaccessConfig($db);
$htaccessOptions = $this->mapToHtaccess($htaccessSettings, $wafSettings);
if (!empty($htaccessOptions))
{
$this->mergeComponentHtaccessOptions($htaccessOptions);
$results['htaccess'] = \count($htaccessOptions);
}
// ============================================================
// 3. Import IP blocklist → Firewall IP deny list
// ============================================================
$ipBlocks = $this->readIpBlocklist($db);
if (!empty($ipBlocks))
{
$this->mergeIpBlocklist($ipBlocks);
$results['ip_blocks'] = \count($ipBlocks);
}
// ============================================================
// 4. Disable Admin Tools
// ============================================================
$this->disableAdminTools($db);
$results['disabled'] = true;
$this->markImported('admintools');
return [
'success' => true,
'message' => \sprintf(
'Imported %d firewall settings, %d htaccess options, %d blocked IPs from Admin Tools. Admin Tools has been disabled.',
$results['firewall'], $results['htaccess'], $results['ip_blocks']
),
'counts' => $results,
];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Import failed: ' . $e->getMessage()];
}
}
/**
* Read WAF config from #__admintools_wafconfig.
*/
private function readWafConfig($db): array
{
try
{
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_wafconfig%'));
if (!$db->loadResult())
{
return [];
}
$db->setQuery('SELECT * FROM #__admintools_wafconfig');
$rows = $db->loadObjectList() ?: [];
$config = [];
foreach ($rows as $row)
{
$key = $row->key ?? $row->option ?? '';
if (!empty($key))
{
$config[$key] = $row->value ?? '';
}
}
return $config;
}
catch (\Throwable $e)
{
return [];
}
}
/**
* Read htaccess/server config from #__admintools_storage.
*/
private function readHtaccessConfig($db): array
{
try
{
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_storage%'));
if (!$db->loadResult())
{
return [];
}
$db->setQuery('SELECT * FROM #__admintools_storage');
$rows = $db->loadObjectList() ?: [];
$config = [];
foreach ($rows as $row)
{
$key = $row->key ?? '';
if (!empty($key))
{
$config[$key] = $row->value ?? '';
}
}
return $config;
}
catch (\Throwable $e)
{
return [];
}
}
/**
* Read IP blocklist from #__admintools_ipblock.
*/
private function readIpBlocklist($db): array
{
try
{
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%admintools_ipblock%'));
if (!$db->loadResult())
{
return [];
}
$db->setQuery('SELECT ip FROM #__admintools_ipblock');
return $db->loadColumn() ?: [];
}
catch (\Throwable $e)
{
return [];
}
}
/**
* Map Admin Tools WAF config to MokoWaaS firewall plugin params.
*/
private function mapWafToFirewall(array $waf): array
{
$params = [];
// WAF shields
if (isset($waf['sqlishield']))
{
$params['waf_sqli'] = (int) $waf['sqlishield'] ? 1 : 0;
}
if (isset($waf['antispam']))
{
$params['waf_xss'] = (int) $waf['antispam'] ? 1 : 0;
}
if (isset($waf['muashield']))
{
$params['waf_mua'] = (int) $waf['muashield'] ? 1 : 0;
}
if (isset($waf['rfishield']))
{
$params['waf_rfi'] = (int) $waf['rfishield'] ? 1 : 0;
}
if (isset($waf['dfishield']))
{
$params['waf_dfi'] = (int) $waf['dfishield'] ? 1 : 0;
}
if (isset($waf['uploadshield']))
{
// Map to our block_direct_php
$params['block_direct_php'] = (int) $waf['uploadshield'] ? 1 : 0;
}
// Admin secret URL
if (!empty($waf['adminpw']))
{
$params['admin_secret'] = $waf['adminpw'];
}
// Block frontend super user login
if (isset($waf['nofesalogin']))
{
$params['block_frontend_superuser'] = (int) $waf['nofesalogin'] ? 1 : 0;
}
// Session timeout
if (!empty($waf['sessionshield']) && !empty($waf['session_timeout']))
{
$params['admin_session_timeout'] = (int) $waf['session_timeout'];
}
// Template switch blocking
if (isset($waf['tmpl']))
{
$params['block_template_switch'] = (int) $waf['tmpl'] ? 1 : 0;
}
// Blocked sensitive files
if (isset($waf['hogfiles']))
{
$params['block_sensitive_files'] = (int) $waf['hogfiles'] ? 1 : 0;
}
return $params;
}
/**
* Map Admin Tools config to MokoWaaS htaccess maker options.
*/
private function mapToHtaccess(array $storage, array $waf): array
{
$opts = [];
// Server signature
if (isset($waf['serversignature']) || isset($storage['serversignature']))
{
$opts['disable_server_signature'] = 1;
}
// Clickjacking
if (isset($waf['clickjacking']) || isset($storage['xframeoptions']))
{
$opts['prevent_clickjacking'] = 1;
}
// HSTS
if (!empty($storage['hstsheader']) || !empty($waf['hstsheader']))
{
$opts['hsts_enabled'] = 1;
if (!empty($storage['hstsmaxage']))
{
$opts['hsts_max_age'] = (int) $storage['hstsmaxage'];
}
}
// GZip
if (isset($storage['gzipcompression']))
{
$opts['enable_gzip'] = (int) $storage['gzipcompression'] ? 1 : 0;
}
// Expiration
if (isset($storage['exptime']))
{
$opts['enable_expires'] = (int) $storage['exptime'] ? 1 : 0;
}
// ETag
if (isset($storage['etagtype']))
{
$opts['etag_control'] = ($storage['etagtype'] === 'none') ? 1 : 0;
}
// Redirect www / non-www
if (!empty($storage['wwwredir']))
{
$map = ['www' => 'www', 'nowww' => 'non-www'];
$opts['www_redirect'] = $map[$storage['wwwredir']] ?? 'off';
}
// Directory listing
if (isset($storage['nodirlisting']))
{
$opts['disable_directory_listing'] = (int) $storage['nodirlisting'] ? 1 : 0;
}
// Block PHP in uploads
if (isset($storage['phpuploadexec']))
{
$opts['block_php_in_uploads'] = (int) $storage['phpuploadexec'] ? 1 : 0;
}
// Sensitive files
if (isset($storage['hogfiles']))
{
$opts['block_sensitive_files'] = (int) $storage['hogfiles'] ? 1 : 0;
}
return $opts;
}
/**
* Merge params into a plugin's existing params.
*/
private function mergePluginParams(string $element, string $folder, array $newParams): void
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote($element))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote($folder));
$db->setQuery($query);
$current = new Registry($db->loadResult() ?? '{}');
foreach ($newParams as $key => $value)
{
$current->set($key, $value);
}
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('params') . ' = ' . $db->quote($current->toString()))
->where($db->quoteName('element') . ' = ' . $db->quote($element))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote($folder))
)->execute();
}
/**
* Merge htaccess options into the component params.
*/
private function mergeComponentHtaccessOptions(array $options): void
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'));
$db->setQuery($query);
$params = new Registry($db->loadResult() ?? '{}');
$htaccess = (array) json_decode(json_encode($params->get('htaccess', new \stdClass())), true);
foreach ($options as $key => $value)
{
$htaccess[$key] = $value;
}
$params->set('htaccess', $htaccess);
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('params') . ' = ' . $db->quote($params->toString()))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
)->execute();
}
/**
* Merge imported IPs into the firewall IP blocklist.
*/
private function mergeIpBlocklist(array $ips): void
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall'))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('system'));
$db->setQuery($query);
$params = new Registry($db->loadResult() ?? '{}');
$blocklist = json_decode($params->get('ip_blocklist', '[]'), true) ?: [];
$existingIps = array_column($blocklist, 'ip');
foreach ($ips as $ip)
{
$ip = trim($ip);
if (empty($ip) || \in_array($ip, $existingIps, true))
{
continue;
}
$blocklist[] = [
'ip' => $ip,
'enabled' => '1',
'label' => 'Imported from Admin Tools',
];
}
$params->set('ip_blocklist', json_encode($blocklist));
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('params') . ' = ' . $db->quote($params->toString()))
->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall'))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
)->execute();
}
/**
* Disable Admin Tools component and plugins.
*/
private function disableAdminTools($db): void
{
// Disable component
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('enabled') . ' = 0')
->where($db->quoteName('element') . ' = ' . $db->quote('com_admintools'))
)->execute();
// Disable all Admin Tools plugins
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('enabled') . ' = 0')
->where($db->quoteName('element') . ' LIKE ' . $db->quote('admintools%'))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
)->execute();
Log::add('Admin Tools component and plugins disabled after MokoWaaS import', Log::INFO, 'mokowaas');
}
// ==================================================================
// Akeeba Ticket System Import
// ==================================================================
/**
* Check if ATS tables exist.
* Returns null if already imported or no data found.
*/
public function checkAtsAvailable(): ?object
{
if ($this->wasImported('ats'))
{
return null;
}
$db = $this->getDatabase();
try
{
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%ats_tickets%'));
if (!$db->loadResult())
{
return null;
}
$db->setQuery('SELECT COUNT(*) FROM #__ats_tickets');
$tickets = (int) $db->loadResult();
$db->setQuery('SELECT COUNT(*) FROM #__ats_posts');
$posts = (int) $db->loadResult();
return (object) ['tickets' => $tickets, 'posts' => $posts];
}
catch (\Throwable $e)
{
return null;
}
}
/**
* Import from Akeeba Ticket System and disable it.
*/
public function importAts(): array
{
// Delegate to TicketsModel for the actual import
$ticketsModel = new TicketsModel();
$result = $ticketsModel->importFromAts();
if (!$result['success'])
{
return $result;
}
// Disable ATS after successful import
try
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('enabled') . ' = 0')
->where($db->quoteName('element') . ' = ' . $db->quote('com_ats'))
)->execute();
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('enabled') . ' = 0')
->where($db->quoteName('element') . ' LIKE ' . $db->quote('ats%'))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
)->execute();
$result['message'] .= ' Akeeba Ticket System has been disabled.';
Log::add('Akeeba Ticket System disabled after MokoWaaS import', Log::INFO, 'mokowaas');
}
catch (\Throwable $e)
{
$result['message'] .= ' Warning: could not disable ATS: ' . $e->getMessage();
}
$this->markImported('ats');
return $result;
}
// ==================================================================
// Import markers (stored in component params)
// ==================================================================
private function wasImported(string $key): bool
{
try
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
);
$params = new Registry($db->loadResult() ?? '{}');
return (bool) $params->get('imported_' . $key, false);
}
catch (\Throwable $e)
{
return false;
}
}
private function markImported(string $key): void
{
try
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
);
$params = new Registry($db->loadResult() ?? '{}');
$params->set('imported_' . $key, 1);
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('params') . ' = ' . $db->quote($params->toString()))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
)->execute();
}
catch (\Throwable $e)
{
Log::add('Import marker error: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
}
}
}
@@ -0,0 +1,251 @@
<?php
namespace Moko\Component\MokoWaaS\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
class MaintenanceModel extends BaseDatabaseModel
{
/**
* Get database table status (size, rows, engine, overhead).
*/
public function getTableStatus(): array
{
$db = $this->getDatabase();
$prefix = $db->getPrefix();
$db->setQuery('SHOW TABLE STATUS');
$tables = $db->loadObjectList() ?: [];
$results = [];
$totalSize = 0;
$totalOverhead = 0;
foreach ($tables as $t)
{
$sizeMb = round(($t->Data_length + $t->Index_length) / 1048576, 2);
$overheadKb = round(($t->Data_free ?? 0) / 1024, 1);
$totalSize += $sizeMb;
$totalOverhead += $overheadKb;
$results[] = (object) [
'name' => $t->Name,
'rows' => (int) $t->Rows,
'engine' => $t->Engine,
'size_mb' => $sizeMb,
'overhead_kb' => $overheadKb,
'is_moko' => str_contains($t->Name, 'mokowaas'),
];
}
usort($results, fn($a, $b) => $b->size_mb <=> $a->size_mb);
return ['tables' => $results, 'total_size_mb' => round($totalSize, 2), 'total_overhead_kb' => round($totalOverhead, 1), 'count' => \count($results)];
}
/**
* Optimize all tables or specific ones.
*/
public function optimizeTables(array $tableNames = []): array
{
$db = $this->getDatabase();
$count = 0;
try
{
if (empty($tableNames))
{
$db->setQuery('SHOW TABLE STATUS WHERE Data_free > 0');
$tables = $db->loadObjectList() ?: [];
$tableNames = array_column($tables, 'Name');
}
foreach ($tableNames as $name)
{
$db->setQuery('OPTIMIZE TABLE ' . $db->quoteName($name));
$db->execute();
$count++;
}
return ['success' => true, 'message' => "Optimized {$count} tables."];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Optimize failed: ' . $e->getMessage()];
}
}
/**
* Repair all tables.
*/
public function repairTables(): array
{
$db = $this->getDatabase();
try
{
$db->setQuery('SHOW TABLE STATUS');
$tables = $db->loadObjectList() ?: [];
$count = 0;
foreach ($tables as $t)
{
if ($t->Engine === 'InnoDB' || $t->Engine === 'MyISAM')
{
$db->setQuery('REPAIR TABLE ' . $db->quoteName($t->Name));
$db->execute();
$count++;
}
}
return ['success' => true, 'message' => "Repaired {$count} tables."];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Repair failed: ' . $e->getMessage()];
}
}
/**
* Purge expired sessions.
*/
public function purgeSessions(): array
{
try
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__session'))
->where($db->quoteName('time') . ' < ' . (time() - 86400))
)->execute();
return ['success' => true, 'message' => 'Expired sessions purged. ' . $db->getAffectedRows() . ' removed.'];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => $e->getMessage()];
}
}
// ==================================================================
// Temp/Cache Cleanup (#128)
// ==================================================================
/**
* Get directory sizes for cleanup.
*/
public function getCleanupInfo(): array
{
$dirs = [
['path' => JPATH_ROOT . '/cache', 'label' => 'Site Cache'],
['path' => JPATH_ADMINISTRATOR . '/cache', 'label' => 'Admin Cache'],
['path' => JPATH_ROOT . '/tmp', 'label' => 'Temp Directory'],
['path' => JPATH_ADMINISTRATOR . '/logs', 'label' => 'Log Files'],
];
$results = [];
foreach ($dirs as $dir)
{
$size = 0;
$files = 0;
if (is_dir($dir['path']))
{
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir['path'], \RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $file)
{
if ($file->isFile())
{
$size += $file->getSize();
$files++;
}
}
}
$results[] = (object) [
'label' => $dir['label'],
'path' => $dir['path'],
'size_mb' => round($size / 1048576, 2),
'files' => $files,
'writable' => is_writable($dir['path']),
];
}
return $results;
}
/**
* Clean a specific directory.
*/
public function cleanDirectory(string $dirKey): array
{
$allowed = [
'site_cache' => JPATH_ROOT . '/cache',
'admin_cache' => JPATH_ADMINISTRATOR . '/cache',
'tmp' => JPATH_ROOT . '/tmp',
'logs' => JPATH_ADMINISTRATOR . '/logs',
];
if (!isset($allowed[$dirKey]))
{
return ['success' => false, 'message' => 'Invalid directory.'];
}
$dir = $allowed[$dirKey];
if (!is_dir($dir))
{
return ['success' => false, 'message' => 'Directory not found.'];
}
$count = 0;
try
{
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $item)
{
// Keep index.html and .htaccess files
$name = $item->getFilename();
if ($name === 'index.html' || $name === '.htaccess')
{
continue;
}
if ($item->isDir())
{
@rmdir($item->getPathname());
}
else
{
@unlink($item->getPathname());
$count++;
}
}
// Also clear opcache
if (\function_exists('opcache_reset'))
{
\opcache_reset();
}
return ['success' => true, 'message' => "Cleaned {$count} files from {$dirKey}."];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Cleanup failed: ' . $e->getMessage()];
}
}
}
@@ -0,0 +1,612 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Log\Log;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
class PrivacyModel extends BaseDatabaseModel
{
/**
* Get all pending data requests.
*/
public function getDataRequests(string $filterStatus = ''): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select([
$db->quoteName('r') . '.*',
$db->quoteName('u.name', 'user_name'),
$db->quoteName('u.email', 'user_email'),
$db->quoteName('u.username'),
$db->quoteName('p.name', 'processed_by_name'),
])
->from($db->quoteName('#__mokowaas_data_requests', 'r'))
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id')
->leftJoin($db->quoteName('#__users', 'p') . ' ON p.id = r.processed_by');
if ($filterStatus)
{
$query->where($db->quoteName('r.status') . ' = ' . $db->quote($filterStatus));
}
$query->order($db->quoteName('r.created') . ' DESC')->setLimit(50);
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
/**
* Create a data request (from admin or user self-service).
*/
public function createRequest(int $userId, string $type, string $notes = ''): array
{
$validTypes = ['export', 'delete', 'anonymize'];
if (!\in_array($type, $validTypes, true))
{
return ['success' => false, 'message' => 'Invalid request type.'];
}
try
{
$db = $this->getDatabase();
$row = (object) [
'user_id' => $userId,
'type' => $type,
'status' => 'pending',
'notes' => $notes,
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokowaas_data_requests', $row, 'id');
return ['success' => true, 'message' => ucfirst($type) . ' request #' . $row->id . ' created.', 'id' => (int) $row->id];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()];
}
}
/**
* Process a data request (approve and execute).
*/
public function processRequest(int $requestId, string $action): array
{
$db = $this->getDatabase();
try
{
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_data_requests'))
->where($db->quoteName('id') . ' = ' . $requestId)
);
$request = $db->loadObject();
if (!$request)
{
return ['success' => false, 'message' => 'Request not found.'];
}
if ($action === 'deny')
{
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__mokowaas_data_requests'))
->set($db->quoteName('status') . ' = ' . $db->quote('denied'))
->set($db->quoteName('processed_by') . ' = ' . (int) Factory::getApplication()->getIdentity()->id)
->set($db->quoteName('processed') . ' = ' . $db->quote(Factory::getDate()->toSql()))
->where($db->quoteName('id') . ' = ' . $requestId)
)->execute();
return ['success' => true, 'message' => 'Request denied.'];
}
// Mark as processing
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__mokowaas_data_requests'))
->set($db->quoteName('status') . ' = ' . $db->quote('processing'))
->where($db->quoteName('id') . ' = ' . $requestId)
)->execute();
// Execute the request
$result = null;
switch ($request->type)
{
case 'export':
$result = $this->exportUserData((int) $request->user_id);
break;
case 'delete':
$result = $this->deleteUserData((int) $request->user_id);
break;
case 'anonymize':
$result = $this->anonymizeUserData((int) $request->user_id);
break;
}
// Mark completed
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__mokowaas_data_requests'))
->set($db->quoteName('status') . ' = ' . $db->quote('completed'))
->set($db->quoteName('processed_by') . ' = ' . (int) Factory::getApplication()->getIdentity()->id)
->set($db->quoteName('processed') . ' = ' . $db->quote(Factory::getDate()->toSql()))
->where($db->quoteName('id') . ' = ' . $requestId)
)->execute();
return $result ?? ['success' => true, 'message' => 'Request processed.'];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Processing failed: ' . $e->getMessage()];
}
}
/**
* Export all data for a user as a structured array.
*/
public function exportUserData(int $userId): array
{
$db = $this->getDatabase();
$data = ['user_id' => $userId, 'exported' => gmdate('Y-m-d\TH:i:s\Z')];
try
{
// User profile
$db->setQuery(
$db->getQuery(true)
->select(['id', 'name', 'username', 'email', 'registerDate', 'lastvisitDate', 'params'])
->from($db->quoteName('#__users'))
->where($db->quoteName('id') . ' = ' . $userId)
);
$data['profile'] = $db->loadObject();
// Content (articles)
$db->setQuery(
$db->getQuery(true)
->select(['id', 'title', 'alias', 'created', 'modified', 'hits'])
->from($db->quoteName('#__content'))
->where($db->quoteName('created_by') . ' = ' . $userId)
);
$data['articles'] = $db->loadObjectList() ?: [];
// Action logs
$db->setQuery(
$db->getQuery(true)
->select(['message', 'log_date', 'ip_address'])
->from($db->quoteName('#__action_logs'))
->where($db->quoteName('user_id') . ' = ' . $userId)
->order('log_date DESC')
->setLimit(100)
);
$data['action_logs'] = $db->loadObjectList() ?: [];
// Support tickets
$db->setQuery(
$db->getQuery(true)
->select(['id', 'subject', 'body', 'status', 'priority', 'created'])
->from($db->quoteName('#__mokowaas_tickets'))
->where($db->quoteName('created_by') . ' = ' . $userId)
);
$data['tickets'] = $db->loadObjectList() ?: [];
// Ticket replies
$db->setQuery(
$db->getQuery(true)
->select(['r.id', 'r.ticket_id', 'r.body', 'r.created'])
->from($db->quoteName('#__mokowaas_ticket_replies', 'r'))
->where($db->quoteName('r.user_id') . ' = ' . $userId)
);
$data['ticket_replies'] = $db->loadObjectList() ?: [];
// Consent log
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_consent_log'))
->where($db->quoteName('user_id') . ' = ' . $userId)
->order('created ASC')
);
$data['consent_history'] = $db->loadObjectList() ?: [];
// Community Builder profile (if table exists)
try
{
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%comprofiler%'));
if ($db->loadResult())
{
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName('#__comprofiler'))
->where($db->quoteName('user_id') . ' = ' . $userId)
);
$data['community_builder'] = $db->loadObject();
}
}
catch (\Throwable $e) {}
return ['success' => true, 'message' => 'Data exported.', 'data' => $data];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Export failed: ' . $e->getMessage()];
}
}
/**
* Anonymize a user's data (GDPR right to be forgotten — soft).
*/
public function anonymizeUserData(int $userId): array
{
$db = $this->getDatabase();
$now = Factory::getDate()->toSql();
$anon = 'Anonymous User #' . $userId;
try
{
// Anonymize user record
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__users'))
->set([
$db->quoteName('name') . ' = ' . $db->quote($anon),
$db->quoteName('username') . ' = ' . $db->quote('anon_' . $userId),
$db->quoteName('email') . ' = ' . $db->quote('anon_' . $userId . '@deleted.local'),
$db->quoteName('password') . ' = ' . $db->quote(''),
$db->quoteName('block') . ' = 1',
$db->quoteName('params') . ' = ' . $db->quote('{}'),
])
->where($db->quoteName('id') . ' = ' . $userId)
)->execute();
// Anonymize article authorship
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__content'))
->set($db->quoteName('created_by_alias') . ' = ' . $db->quote($anon))
->where($db->quoteName('created_by') . ' = ' . $userId)
)->execute();
// Delete action logs
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__action_logs'))
->where($db->quoteName('user_id') . ' = ' . $userId)
)->execute();
// Anonymize ticket replies
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__mokowaas_ticket_replies'))
->set($db->quoteName('body') . ' = ' . $db->quote('[Content removed per data request]'))
->where($db->quoteName('user_id') . ' = ' . $userId)
)->execute();
// Community Builder
try
{
$db->setQuery('SHOW TABLES LIKE ' . $db->quote('%comprofiler%'));
if ($db->loadResult())
{
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__comprofiler'))
->set([
$db->quoteName('firstname') . ' = ' . $db->quote('Anonymous'),
$db->quoteName('lastname') . ' = ' . $db->quote('User'),
$db->quoteName('middlename') . ' = ' . $db->quote(''),
])
->where($db->quoteName('user_id') . ' = ' . $userId)
)->execute();
}
}
catch (\Throwable $e) {}
// Clear Joomla user profile fields (#7)
try
{
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__user_profiles'))
->where($db->quoteName('user_id') . ' = ' . $userId)
)->execute();
}
catch (\Throwable $e) {}
// Clear contact details if linked
try
{
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__contact_details'))
->where($db->quoteName('user_id') . ' = ' . $userId)
)->execute();
}
catch (\Throwable $e) {}
// Log the anonymization
$this->logConsent($userId, 'account_anonymized', 'granted');
return ['success' => true, 'message' => 'User #' . $userId . ' data anonymized.'];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Anonymization failed: ' . $e->getMessage()];
}
}
/**
* Delete a user's data completely (hard delete).
*/
public function deleteUserData(int $userId): array
{
$result = $this->anonymizeUserData($userId);
if (!$result['success'])
{
return $result;
}
$db = $this->getDatabase();
try
{
// Delete tickets and replies
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('id'))
->from($db->quoteName('#__mokowaas_tickets'))
->where($db->quoteName('created_by') . ' = ' . $userId)
);
$ticketIds = $db->loadColumn() ?: [];
if (!empty($ticketIds))
{
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokowaas_ticket_replies'))
->where($db->quoteName('ticket_id') . ' IN (' . implode(',', $ticketIds) . ')')
)->execute();
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokowaas_tickets'))
->where($db->quoteName('created_by') . ' = ' . $userId)
)->execute();
}
// Delete consent log
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokowaas_consent_log'))
->where($db->quoteName('user_id') . ' = ' . $userId)
)->execute();
// Delete user record
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__users'))
->where($db->quoteName('id') . ' = ' . $userId)
)->execute();
return ['success' => true, 'message' => 'User #' . $userId . ' data permanently deleted.'];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Deletion failed: ' . $e->getMessage()];
}
}
// ==================================================================
// Consent Management
// ==================================================================
/**
* Get consent status for a user.
*/
public function getUserConsent(int $userId): array
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_consent_log'))
->where($db->quoteName('user_id') . ' = ' . $userId)
->order($db->quoteName('created') . ' DESC')
);
return $db->loadObjectList() ?: [];
}
/**
* Record a consent action.
*/
public function logConsent(int $userId, string $category, string $action): void
{
$db = $this->getDatabase();
$row = (object) [
'user_id' => $userId,
'category' => $category,
'action' => $action === 'revoked' ? 'revoked' : 'granted',
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? '',
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokowaas_consent_log', $row, 'id');
}
// ==================================================================
// Retention Policy Enforcement
// ==================================================================
/**
* Get all retention policies.
*/
public function getRetentionPolicies(): array
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_retention_policies'))
->order($db->quoteName('id') . ' ASC')
);
return $db->loadObjectList() ?: [];
}
/**
* Run retention policy enforcement (called by scheduled task).
*/
public function enforceRetentionPolicies(): array
{
$db = $this->getDatabase();
$results = ['policies_run' => 0, 'items_affected' => 0];
$policies = $this->getRetentionPolicies();
foreach ($policies as $policy)
{
if (!(int) $policy->enabled)
{
continue;
}
$cutoff = Factory::getDate('-' . (int) $policy->retention_days . ' days')->toSql();
$count = 0;
try
{
switch ($policy->content_type)
{
case 'action_logs':
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__action_logs'))
->where($db->quoteName('log_date') . ' < ' . $db->quote($cutoff))
)->execute();
$count = $db->getAffectedRows();
break;
case 'waf_logs':
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokowaas_waf_log'))
->where($db->quoteName('created') . ' < ' . $db->quote($cutoff))
)->execute();
$count = $db->getAffectedRows();
break;
case 'sessions':
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__session'))
->where($db->quoteName('time') . ' < ' . (int) strtotime($cutoff))
)->execute();
$count = $db->getAffectedRows();
break;
case 'closed_tickets':
if ($policy->action === 'anonymize')
{
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__mokowaas_tickets'))
->set($db->quoteName('body') . ' = ' . $db->quote('[Removed per retention policy]'))
->where($db->quoteName('status') . ' = ' . $db->quote('closed'))
->where($db->quoteName('closed') . ' < ' . $db->quote($cutoff))
->where($db->quoteName('body') . ' != ' . $db->quote('[Removed per retention policy]'))
)->execute();
$count = $db->getAffectedRows();
}
break;
case 'inactive_users':
if ($policy->action === 'anonymize')
{
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('id'))
->from($db->quoteName('#__users'))
->where($db->quoteName('lastvisitDate') . ' < ' . $db->quote($cutoff))
->where($db->quoteName('lastvisitDate') . ' != ' . $db->quote('0000-00-00 00:00:00'))
->where($db->quoteName('block') . ' = 0')
->where($db->quoteName('username') . ' NOT LIKE ' . $db->quote('anon_%'))
);
$userIds = $db->loadColumn() ?: [];
foreach ($userIds as $uid)
{
$this->anonymizeUserData((int) $uid);
$count++;
}
}
break;
}
if ($count > 0)
{
$results['policies_run']++;
$results['items_affected'] += $count;
Log::add(\sprintf('Retention: %s — %d items affected', $policy->content_type, $count), Log::INFO, 'mokowaas');
}
}
catch (\Throwable $e)
{
Log::add('Retention policy error (' . $policy->content_type . '): ' . $e->getMessage(), Log::WARNING, 'mokowaas');
}
}
return $results;
}
/**
* Get privacy dashboard summary counts.
*/
public function getDashboardSummary(): object
{
$db = $this->getDatabase();
$summary = (object) [
'pending_requests' => 0,
'total_requests' => 0,
'consent_entries' => 0,
'policies_active' => 0,
];
try
{
$db->setQuery('SELECT COUNT(*) FROM #__mokowaas_data_requests WHERE status = ' . $db->quote('pending'));
$summary->pending_requests = (int) $db->loadResult();
$db->setQuery('SELECT COUNT(*) FROM #__mokowaas_data_requests');
$summary->total_requests = (int) $db->loadResult();
$db->setQuery('SELECT COUNT(*) FROM #__mokowaas_consent_log');
$summary->consent_entries = (int) $db->loadResult();
$db->setQuery('SELECT COUNT(*) FROM #__mokowaas_retention_policies WHERE enabled = 1');
$summary->policies_active = (int) $db->loadResult();
}
catch (\Throwable $e) {}
return $summary;
}
}
@@ -0,0 +1,945 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Moko\Component\MokoWaaS\Administrator\Service\NotificationService;
class TicketsModel extends BaseDatabaseModel
{
/**
* Get ticket list with filters.
*/
public function getTickets(array $filters = []): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select([
$db->quoteName('t.id'),
$db->quoteName('t.subject'),
$db->quoteName('t.status'),
$db->quoteName('t.priority'),
$db->quoteName('t.created'),
$db->quoteName('t.modified'),
$db->quoteName('t.sla_response_due'),
$db->quoteName('t.sla_resolution_due'),
$db->quoteName('t.sla_responded'),
$db->quoteName('c.title', 'category_title'),
$db->quoteName('u.name', 'created_by_name'),
$db->quoteName('a.name', 'assigned_to_name'),
])
->from($db->quoteName('#__mokowaas_tickets', 't'))
->leftJoin($db->quoteName('#__mokowaas_ticket_categories', 'c') . ' ON c.id = t.category_id')
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to');
if (!empty($filters['status']))
{
$query->where($db->quoteName('t.status') . ' = ' . $db->quote($filters['status']));
}
if (!empty($filters['priority']))
{
$query->where($db->quoteName('t.priority') . ' = ' . $db->quote($filters['priority']));
}
if (!empty($filters['assigned_to']))
{
$query->where($db->quoteName('t.assigned_to') . ' = ' . (int) $filters['assigned_to']);
}
if (!empty($filters['category_id']))
{
$query->where($db->quoteName('t.category_id') . ' = ' . (int) $filters['category_id']);
}
$query->order($db->quoteName('t.created') . ' DESC');
$query->setLimit(50);
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
/**
* Get a single ticket with all replies.
*/
public function getTicket(int $id): ?object
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select([
$db->quoteName('t') . '.*',
$db->quoteName('c.title', 'category_title'),
$db->quoteName('u.name', 'created_by_name'),
$db->quoteName('u.email', 'created_by_email'),
$db->quoteName('a.name', 'assigned_to_name'),
])
->from($db->quoteName('#__mokowaas_tickets', 't'))
->leftJoin($db->quoteName('#__mokowaas_ticket_categories', 'c') . ' ON c.id = t.category_id')
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to')
->where($db->quoteName('t.id') . ' = ' . $id);
$db->setQuery($query);
$ticket = $db->loadObject();
if (!$ticket)
{
return null;
}
// Load replies
$query = $db->getQuery(true)
->select([
$db->quoteName('r') . '.*',
$db->quoteName('u.name', 'user_name'),
])
->from($db->quoteName('#__mokowaas_ticket_replies', 'r'))
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id')
->where($db->quoteName('r.ticket_id') . ' = ' . $id)
->order($db->quoteName('r.created') . ' ASC');
$db->setQuery($query);
$ticket->replies = $db->loadObjectList() ?: [];
// Reply count
$ticket->reply_count = \count($ticket->replies);
return $ticket;
}
/**
* Create a new ticket.
*/
public function createTicket(array $data): array
{
try
{
$db = $this->getDatabase();
$user = Factory::getApplication()->getIdentity();
$now = Factory::getDate()->toSql();
$ticket = (object) [
'subject' => $data['subject'] ?? '',
'body' => $data['body'] ?? '',
'status' => 'open',
'priority' => $data['priority'] ?? 'normal',
'category_id' => (int) ($data['category_id'] ?? 0) ?: null,
'created_by' => $user->id,
'assigned_to' => (int) ($data['assigned_to'] ?? 0) ?: null,
'created' => $now,
'modified' => $now,
];
// Auto-assign from category
if (!$ticket->assigned_to && $ticket->category_id)
{
$query = $db->getQuery(true)
->select($db->quoteName('auto_assign_user'))
->from($db->quoteName('#__mokowaas_ticket_categories'))
->where($db->quoteName('id') . ' = ' . (int) $ticket->category_id);
$db->setQuery($query);
$autoAssign = (int) $db->loadResult();
if ($autoAssign)
{
$ticket->assigned_to = $autoAssign;
}
}
// SLA deadlines from category
if ($ticket->category_id)
{
$query = $db->getQuery(true)
->select([$db->quoteName('sla_response_minutes'), $db->quoteName('sla_resolution_minutes')])
->from($db->quoteName('#__mokowaas_ticket_categories'))
->where($db->quoteName('id') . ' = ' . (int) $ticket->category_id);
$db->setQuery($query);
$sla = $db->loadObject();
if ($sla)
{
$ticket->sla_response_due = Factory::getDate($now)->modify('+' . (int) $sla->sla_response_minutes . ' minutes')->toSql();
$ticket->sla_resolution_due = Factory::getDate($now)->modify('+' . (int) $sla->sla_resolution_minutes . ' minutes')->toSql();
}
}
$db->insertObject('#__mokowaas_tickets', $ticket, 'id');
// Run automation + notifications
$this->runAutomation('ticket_created', (int) $ticket->id);
NotificationService::notify('ticket_created', $this->getTicket((int) $ticket->id));
return ['success' => true, 'message' => 'Ticket #' . $ticket->id . ' created.', 'id' => (int) $ticket->id];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()];
}
}
/**
* Add a reply to a ticket.
*/
public function addReply(int $ticketId, string $body, bool $isInternal = false): array
{
try
{
$db = $this->getDatabase();
$user = Factory::getApplication()->getIdentity();
$now = Factory::getDate()->toSql();
$reply = (object) [
'ticket_id' => $ticketId,
'user_id' => $user->id,
'body' => $body,
'is_internal' => $isInternal ? 1 : 0,
'created' => $now,
];
$db->insertObject('#__mokowaas_ticket_replies', $reply, 'id');
// Mark SLA as responded only for staff replies (not customer self-replies)
$ticket = $this->getTicket($ticketId);
$isStaffReply = $ticket && (int) $user->id !== (int) $ticket->created_by;
$updateQuery = $db->getQuery(true)
->update($db->quoteName('#__mokowaas_tickets'))
->set($db->quoteName('modified') . ' = ' . $db->quote($now))
->where($db->quoteName('id') . ' = ' . $ticketId);
if ($isStaffReply)
{
$updateQuery->set($db->quoteName('sla_responded') . ' = 1')
->where($db->quoteName('sla_responded') . ' = 0');
}
$db->setQuery($updateQuery)->execute();
// Run automation + notifications (skip internal notes)
$this->runAutomation('ticket_replied', $ticketId);
if (!$isInternal)
{
NotificationService::notify('ticket_replied', $this->getTicket($ticketId), ['reply_body' => $body]);
}
return ['success' => true, 'message' => 'Reply added.'];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()];
}
}
/**
* Update ticket status.
*/
public function updateStatus(int $ticketId, string $status): array
{
$valid = ['open', 'in_progress', 'waiting', 'resolved', 'closed'];
if (!\in_array($status, $valid, true))
{
return ['success' => false, 'message' => 'Invalid status.'];
}
try
{
$db = $this->getDatabase();
$now = Factory::getDate()->toSql();
// Capture old status for notification
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('status'))
->from($db->quoteName('#__mokowaas_tickets'))
->where($db->quoteName('id') . ' = ' . $ticketId)
);
$oldStatus = $db->loadResult() ?? '';
$sets = [
$db->quoteName('status') . ' = ' . $db->quote($status),
$db->quoteName('modified') . ' = ' . $db->quote($now),
];
if ($status === 'resolved')
{
$sets[] = $db->quoteName('resolved') . ' = ' . $db->quote($now);
}
if ($status === 'closed')
{
$sets[] = $db->quoteName('closed') . ' = ' . $db->quote($now);
}
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__mokowaas_tickets'))
->set($sets)
->where($db->quoteName('id') . ' = ' . $ticketId)
)->execute();
// Run automation + notifications
$this->runAutomation('status_changed', $ticketId);
NotificationService::notify('status_changed', $this->getTicket($ticketId), ['old_status' => $oldStatus]);
return ['success' => true, 'message' => 'Status updated to ' . $status . '.'];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Failed: ' . $e->getMessage()];
}
}
/**
* Get all ticket categories.
*/
public function getCategories(): array
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_ticket_categories'))
->where($db->quoteName('published') . ' = 1')
->order($db->quoteName('ordering') . ' ASC')
);
return $db->loadObjectList() ?: [];
}
/**
* Get canned responses, optionally filtered by category.
*/
public function getCannedResponses(int $categoryId = 0): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_ticket_canned'))
->order($db->quoteName('ordering') . ' ASC');
if ($categoryId)
{
$query->where('(' . $db->quoteName('category_id') . ' = ' . $categoryId
. ' OR ' . $db->quoteName('category_id') . ' IS NULL)');
}
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
/**
* Get ticket counts by status for dashboard.
*/
public function getStatusCounts(): object
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select([$db->quoteName('status'), 'COUNT(*) AS ' . $db->quoteName('cnt')])
->from($db->quoteName('#__mokowaas_tickets'))
->group($db->quoteName('status'))
);
$rows = $db->loadObjectList('status') ?: [];
return (object) [
'open' => (int) ($rows['open']->cnt ?? 0),
'in_progress' => (int) ($rows['in_progress']->cnt ?? 0),
'waiting' => (int) ($rows['waiting']->cnt ?? 0),
'resolved' => (int) ($rows['resolved']->cnt ?? 0),
'closed' => (int) ($rows['closed']->cnt ?? 0),
'total' => array_sum(array_map(fn($r) => (int) $r->cnt, $rows)),
];
}
/**
* Get overdue tickets (SLA breached).
*/
public function getOverdueTickets(): array
{
$db = $this->getDatabase();
$now = Factory::getDate()->toSql();
$query = $db->getQuery(true)
->select([$db->quoteName('id'), $db->quoteName('subject'), $db->quoteName('priority'),
$db->quoteName('sla_response_due'), $db->quoteName('sla_resolution_due'), $db->quoteName('sla_responded')])
->from($db->quoteName('#__mokowaas_tickets'))
->where($db->quoteName('status') . ' NOT IN (' . $db->quote('resolved') . ',' . $db->quote('closed') . ')')
->where('((' . $db->quoteName('sla_response_due') . ' < ' . $db->quote($now) . ' AND ' . $db->quoteName('sla_responded') . ' = 0)'
. ' OR ' . $db->quoteName('sla_resolution_due') . ' < ' . $db->quote($now) . ')')
->order($db->quoteName('sla_resolution_due') . ' ASC');
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
// ==================================================================
// Automation Engine
// ==================================================================
/**
* Run automation rules for a specific trigger event against a ticket.
*
* @param string $event trigger_event: ticket_created, ticket_replied, status_changed, scheduled
* @param int $ticketId The ticket to evaluate
*/
public function runAutomation(string $event, int $ticketId): void
{
try
{
$db = $this->getDatabase();
// Load enabled rules for this event
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_ticket_automation'))
->where($db->quoteName('trigger_event') . ' = ' . $db->quote($event))
->where($db->quoteName('enabled') . ' = 1')
->order($db->quoteName('ordering') . ' ASC');
$db->setQuery($query);
$rules = $db->loadObjectList() ?: [];
if (empty($rules))
{
return;
}
// Load the ticket
$ticket = $this->getTicket($ticketId);
if (!$ticket)
{
return;
}
// Calculate age in hours
$ticket->age_hours = (time() - strtotime($ticket->created)) / 3600;
foreach ($rules as $rule)
{
$conditions = json_decode($rule->conditions, true) ?: [];
$actions = json_decode($rule->actions, true) ?: [];
if ($this->evaluateConditions($conditions, $ticket))
{
$this->executeActions($actions, $ticketId, $ticket);
}
}
}
catch (\Throwable $e)
{
\Joomla\CMS\Log\Log::add('Automation error: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokowaas');
}
}
/**
* Run all scheduled automation rules against all open tickets.
*/
public function runScheduledAutomation(): array
{
$db = $this->getDatabase();
$results = ['evaluated' => 0, 'acted' => 0];
// Load scheduled rules
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_ticket_automation'))
->where($db->quoteName('trigger_event') . ' = ' . $db->quote('scheduled'))
->where($db->quoteName('enabled') . ' = 1')
->order($db->quoteName('ordering') . ' ASC');
$db->setQuery($query);
$rules = $db->loadObjectList() ?: [];
if (empty($rules))
{
return $results;
}
// Load all non-closed tickets
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_tickets'))
->where($db->quoteName('status') . ' != ' . $db->quote('closed'));
$db->setQuery($query);
$tickets = $db->loadObjectList() ?: [];
foreach ($tickets as $ticket)
{
$ticket->age_hours = (time() - strtotime($ticket->created)) / 3600;
$ticket->replies = [];
$results['evaluated']++;
foreach ($rules as $rule)
{
$conditions = json_decode($rule->conditions, true) ?: [];
$actions = json_decode($rule->actions, true) ?: [];
if ($this->evaluateConditions($conditions, $ticket))
{
$this->executeActions($actions, (int) $ticket->id, $ticket);
$results['acted']++;
}
}
}
return $results;
}
/**
* Evaluate a set of conditions against a ticket (all must match).
*/
private function evaluateConditions(array $conditions, object $ticket): bool
{
foreach ($conditions as $cond)
{
$field = $cond['field'] ?? '';
$op = $cond['op'] ?? 'eq';
$value = $cond['value'] ?? '';
$ticketValue = $ticket->{$field} ?? null;
if ($ticketValue === null)
{
return false;
}
switch ($op)
{
case 'eq':
if ((string) $ticketValue !== (string) $value) return false;
break;
case 'neq':
if ((string) $ticketValue === (string) $value) return false;
break;
case 'gt':
if ((float) $ticketValue <= (float) $value) return false;
break;
case 'lt':
if ((float) $ticketValue >= (float) $value) return false;
break;
case 'in':
$list = array_map('trim', explode(',', $value));
if (!\in_array((string) $ticketValue, $list, true)) return false;
break;
case 'not_in':
$list = array_map('trim', explode(',', $value));
if (\in_array((string) $ticketValue, $list, true)) return false;
break;
default:
return false;
}
}
return true;
}
/**
* Execute a set of actions on a ticket.
*/
private function executeActions(array $actions, int $ticketId, object $ticket): void
{
$db = $this->getDatabase();
$now = Factory::getDate()->toSql();
foreach ($actions as $action)
{
$type = $action['type'] ?? '';
$value = $action['value'] ?? '';
switch ($type)
{
case 'set_status':
$this->updateStatus($ticketId, $value);
break;
case 'set_priority':
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__mokowaas_tickets'))
->set($db->quoteName('priority') . ' = ' . $db->quote($value))
->set($db->quoteName('modified') . ' = ' . $db->quote($now))
->where($db->quoteName('id') . ' = ' . $ticketId)
)->execute();
break;
case 'assign':
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__mokowaas_tickets'))
->set($db->quoteName('assigned_to') . ' = ' . (int) $value)
->set($db->quoteName('modified') . ' = ' . $db->quote($now))
->where($db->quoteName('id') . ' = ' . $ticketId)
)->execute();
break;
case 'add_note':
$reply = (object) [
'ticket_id' => $ticketId,
'user_id' => 0,
'body' => $value,
'is_internal' => 1,
'created' => $now,
];
$db->insertObject('#__mokowaas_ticket_replies', $reply, 'id');
break;
case 'send_email':
// value = email address or comma-separated list
$emails = array_filter(array_map('trim', explode(',', $value)));
foreach ($emails as $email)
{
try
{
$mailer = Factory::getMailer();
$mailer->addRecipient($email);
$mailer->setSubject('[Ticket #' . $ticketId . '] Automation Alert');
$mailer->setBody('Automation rule triggered for ticket #' . $ticketId . ': ' . ($ticket->subject ?? ''));
$mailer->isHtml(false);
$mailer->Send();
}
catch (\Throwable $e)
{
\Joomla\CMS\Log\Log::add('Automation email failed: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokowaas');
}
}
break;
case 'create_ticket':
// value = JSON: {"subject":"...","body":"...","category_id":1,"priority":"normal","behavior":"append"}
$ticketData = json_decode($value, true) ?: [];
$behavior = $ticketData['behavior'] ?? 'append';
$userId = (int) ($ticket->created_by ?? 0);
$catId = (int) ($ticketData['category_id'] ?? 0);
if ($behavior === 'append' && $userId > 0)
{
// Check for existing open ticket from this user in this category
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('id'))
->from($db->quoteName('#__mokowaas_tickets'))
->where($db->quoteName('created_by') . ' = ' . $userId)
->where($db->quoteName('status') . ' NOT IN (' . $db->quote('resolved') . ',' . $db->quote('closed') . ')')
->where($catId ? $db->quoteName('category_id') . ' = ' . $catId : '1=1')
->order($db->quoteName('created') . ' DESC')
->setLimit(1)
);
$existingId = (int) $db->loadResult();
if ($existingId)
{
$this->addReply($existingId, $ticketData['body'] ?? 'Automation event', true);
break;
}
}
elseif ($behavior === 'skip_if_open' && $userId > 0)
{
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokowaas_tickets'))
->where($db->quoteName('created_by') . ' = ' . $userId)
->where($db->quoteName('status') . ' NOT IN (' . $db->quote('resolved') . ',' . $db->quote('closed') . ')')
);
if ((int) $db->loadResult() > 0)
{
break;
}
}
// Create new ticket
$this->createTicket([
'subject' => $ticketData['subject'] ?? 'Automation: ' . ($ticket->subject ?? 'System event'),
'body' => $ticketData['body'] ?? '',
'priority' => $ticketData['priority'] ?? 'normal',
'category_id' => $catId,
]);
break;
}
}
}
/**
* Run automation for a system event (not tied to a specific ticket).
* Creates a virtual ticket context from event data.
*/
public function runSystemEventAutomation(string $event, array $eventData = []): void
{
try
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_ticket_automation'))
->where($db->quoteName('trigger_event') . ' = ' . $db->quote($event))
->where($db->quoteName('enabled') . ' = 1')
->order($db->quoteName('ordering') . ' ASC');
$db->setQuery($query);
$rules = $db->loadObjectList() ?: [];
if (empty($rules))
{
return;
}
// Build a virtual ticket-like object from event data
$context = (object) array_merge([
'id' => 0,
'subject' => $eventData['subject'] ?? $event,
'body' => $eventData['body'] ?? '',
'status' => 'open',
'priority' => $eventData['priority'] ?? 'normal',
'created_by' => $eventData['user_id'] ?? 0,
'created' => gmdate('Y-m-d H:i:s'),
'age_hours' => 0,
], $eventData);
foreach ($rules as $rule)
{
$conditions = json_decode($rule->conditions, true) ?: [];
$actions = json_decode($rule->actions, true) ?: [];
if (empty($conditions) || $this->evaluateConditions($conditions, $context))
{
$this->executeActions($actions, 0, $context);
}
}
}
catch (\Throwable $e)
{
\Joomla\CMS\Log\Log::add('System event automation error: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokowaas');
}
}
/**
* Get all automation rules.
*/
public function getAutomationRules(): array
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_ticket_automation'))
->order($db->quoteName('ordering') . ' ASC')
);
return $db->loadObjectList() ?: [];
}
// ==================================================================
// Akeeba Ticket System Importer
// ==================================================================
/**
* Check if ATS tables exist and return counts.
*/
public function checkAtsAvailable(): ?object
{
$db = $this->getDatabase();
try
{
$db->setQuery('SELECT COUNT(*) FROM #__ats_tickets');
$tickets = (int) $db->loadResult();
$db->setQuery('SELECT COUNT(*) FROM #__ats_posts');
$posts = (int) $db->loadResult();
$db->setQuery('SELECT COUNT(*) FROM #__ats_cannedreplies');
$canned = (int) $db->loadResult();
return (object) ['tickets' => $tickets, 'posts' => $posts, 'canned' => $canned];
}
catch (\Throwable $e)
{
return null;
}
}
/**
* Import tickets, replies, and canned responses from Akeeba Ticket System.
*/
public function importFromAts(): array
{
$db = $this->getDatabase();
$results = ['tickets' => 0, 'replies' => 0, 'canned' => 0, 'errors' => []];
try
{
// Status mapping: ATS → MokoWaaS
$statusMap = [
'O' => 'open', // Open
'P' => 'in_progress', // Pending (staff action needed)
'C' => 'closed', // Closed
];
// Numeric statuses 1-99 are custom — map to open
for ($i = 1; $i <= 99; $i++)
{
$statusMap[(string) $i] = 'open';
}
// Priority mapping: ATS uses 1-5, we use enum
$priorityMap = [
1 => 'low',
2 => 'low',
3 => 'normal',
4 => 'high',
5 => 'urgent',
];
// Category mapping: ATS uses Joomla categories, map catid to our category
// Default all to General Support (1) — admin can reassign later
$defaultCategory = 1;
// Import canned replies first
$db->setQuery('SELECT * FROM #__ats_cannedreplies WHERE enabled = 1 ORDER BY ordering');
$atsCanned = $db->loadObjectList() ?: [];
foreach ($atsCanned as $c)
{
$exists = $db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from('#__mokowaas_ticket_canned')
->where($db->quoteName('title') . ' = ' . $db->quote($c->title))
)->loadResult();
if ((int) $exists > 0)
{
continue;
}
$row = (object) [
'title' => $c->title,
'body' => strip_tags($c->reply ?? ''),
'category_id' => null,
'ordering' => (int) ($c->ordering ?? 0),
];
$db->insertObject('#__mokowaas_ticket_canned', $row, 'id');
$results['canned']++;
}
// Import tickets
$db->setQuery('SELECT * FROM #__ats_tickets ORDER BY id');
$atsTickets = $db->loadObjectList() ?: [];
$ticketIdMap = []; // ATS id → MokoWaaS id
foreach ($atsTickets as $t)
{
// Skip if already imported (check by subject + created_by + created)
$exists = $db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from('#__mokowaas_tickets')
->where($db->quoteName('subject') . ' = ' . $db->quote($t->title))
->where($db->quoteName('created_by') . ' = ' . (int) $t->created_by)
)->loadResult();
if ((int) $exists > 0)
{
continue;
}
$status = $statusMap[$t->status] ?? 'open';
$priority = $priorityMap[(int) $t->priority] ?? 'normal';
$row = (object) [
'subject' => $t->title,
'body' => '',
'status' => $status,
'priority' => $priority,
'category_id' => $defaultCategory,
'created_by' => (int) $t->created_by,
'assigned_to' => (int) $t->assigned_to ?: null,
'created' => $t->created ?: Factory::getDate()->toSql(),
'modified' => $t->modified,
'resolved' => $status === 'closed' ? ($t->modified ?: $t->created) : null,
'closed' => $status === 'closed' ? ($t->modified ?: $t->created) : null,
'sla_responded' => 1,
];
$db->insertObject('#__mokowaas_tickets', $row, 'id');
$ticketIdMap[(int) $t->id] = (int) $row->id;
$results['tickets']++;
}
// Import posts (replies)
$db->setQuery('SELECT * FROM #__ats_posts ORDER BY id');
$atsPosts = $db->loadObjectList() ?: [];
foreach ($atsPosts as $p)
{
$newTicketId = $ticketIdMap[(int) $p->ticket_id] ?? null;
if (!$newTicketId)
{
continue;
}
// First post of a ticket is usually the ticket body — update the ticket
if (empty($results['first_post_' . $p->ticket_id]))
{
$results['first_post_' . $p->ticket_id] = true;
$body = strip_tags($p->content_html ?? '');
$db->setQuery(
$db->getQuery(true)
->update('#__mokowaas_tickets')
->set($db->quoteName('body') . ' = ' . $db->quote($body))
->where($db->quoteName('id') . ' = ' . $newTicketId)
)->execute();
continue;
}
$row = (object) [
'ticket_id' => $newTicketId,
'user_id' => (int) $p->created_by,
'body' => strip_tags($p->content_html ?? ''),
'is_internal' => 0,
'created' => $p->created ?: Factory::getDate()->toSql(),
];
$db->insertObject('#__mokowaas_ticket_replies', $row, 'id');
$results['replies']++;
}
// Clean up temp tracking keys
foreach (array_keys($results) as $k)
{
if (str_starts_with($k, 'first_post_'))
{
unset($results[$k]);
}
}
return [
'success' => true,
'message' => sprintf(
'Imported %d tickets, %d replies, %d canned responses from Akeeba Ticket System.',
$results['tickets'], $results['replies'], $results['canned']
),
'counts' => $results,
];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Import failed: ' . $e->getMessage()];
}
}
}
@@ -0,0 +1,215 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
class WaflogModel extends BaseDatabaseModel
{
/**
* Get WAF log entries with filters and pagination.
*/
public function getLogs(array $filters = [], int $limit = 50, int $offset = 0): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_waf_log'));
if (!empty($filters['rule']))
{
$query->where($db->quoteName('rule') . ' = ' . $db->quote($filters['rule']));
}
if (!empty($filters['ip']))
{
$query->where($db->quoteName('ip') . ' LIKE ' . $db->quote('%' . $db->escape($filters['ip'], true) . '%'));
}
if (!empty($filters['search']))
{
$search = $db->quote('%' . $db->escape($filters['search'], true) . '%');
$query->where('(' . $db->quoteName('uri') . ' LIKE ' . $search
. ' OR ' . $db->quoteName('detail') . ' LIKE ' . $search
. ' OR ' . $db->quoteName('user_agent') . ' LIKE ' . $search . ')');
}
if (!empty($filters['date_from']))
{
$query->where($db->quoteName('created') . ' >= ' . $db->quote($filters['date_from'] . ' 00:00:00'));
}
if (!empty($filters['date_to']))
{
$query->where($db->quoteName('created') . ' <= ' . $db->quote($filters['date_to'] . ' 23:59:59'));
}
$query->order($db->quoteName('created') . ' DESC');
$query->setLimit($limit, $offset);
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
/**
* Get total count for pagination.
*/
public function getTotal(array $filters = []): int
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokowaas_waf_log'));
if (!empty($filters['rule']))
{
$query->where($db->quoteName('rule') . ' = ' . $db->quote($filters['rule']));
}
if (!empty($filters['ip']))
{
$query->where($db->quoteName('ip') . ' LIKE ' . $db->quote('%' . $db->escape($filters['ip'], true) . '%'));
}
$db->setQuery($query);
return (int) $db->loadResult();
}
/**
* Get block counts grouped by rule for the summary bar.
*/
public function getRuleCounts(): array
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select([$db->quoteName('rule'), 'COUNT(*) AS ' . $db->quoteName('cnt')])
->from($db->quoteName('#__mokowaas_waf_log'))
->group($db->quoteName('rule'))
->order($db->quoteName('cnt') . ' DESC')
);
return $db->loadObjectList() ?: [];
}
/**
* Get top blocked IPs.
*/
public function getTopIps(int $limit = 10): array
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select([$db->quoteName('ip'), 'COUNT(*) AS ' . $db->quoteName('cnt'),
'MAX(' . $db->quoteName('created') . ') AS ' . $db->quoteName('last_seen')])
->from($db->quoteName('#__mokowaas_waf_log'))
->group($db->quoteName('ip'))
->order($db->quoteName('cnt') . ' DESC')
->setLimit($limit)
);
return $db->loadObjectList() ?: [];
}
/**
* Get distinct rule names for the filter dropdown.
*/
public function getRuleNames(): array
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select('DISTINCT ' . $db->quoteName('rule'))
->from($db->quoteName('#__mokowaas_waf_log'))
->order($db->quoteName('rule') . ' ASC')
);
return $db->loadColumn() ?: [];
}
/**
* Delete logs older than N days.
*/
public function purgeLogs(int $days): array
{
try
{
$db = $this->getDatabase();
$cutoff = Factory::getDate('-' . $days . ' days')->toSql();
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokowaas_waf_log'))
->where($db->quoteName('created') . ' < ' . $db->quote($cutoff))
)->execute();
$count = $db->getAffectedRows();
return ['success' => true, 'message' => "Purged {$count} log entries older than {$days} days."];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Purge failed: ' . $e->getMessage()];
}
}
/**
* Add an IP to the firewall blocklist.
*/
public function banIp(string $ip, string $reason = 'Banned from WAF log'): array
{
try
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall'))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('system'));
$db->setQuery($query);
$params = new \Joomla\Registry\Registry($db->loadResult() ?? '{}');
$blocklist = json_decode($params->get('ip_blocklist', '[]'), true) ?: [];
// Check if already blocked
foreach ($blocklist as $entry)
{
if (($entry['ip'] ?? '') === $ip)
{
return ['success' => false, 'message' => $ip . ' is already blocked.'];
}
}
$blocklist[] = ['ip' => $ip, 'enabled' => '1', 'label' => $reason];
$params->set('ip_blocklist', json_encode($blocklist));
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('params') . ' = ' . $db->quote($params->toString()))
->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas_firewall'))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
)->execute();
return ['success' => true, 'message' => $ip . ' has been added to the IP blocklist.'];
}
catch (\Throwable $e)
{
return ['success' => false, 'message' => 'Ban failed: ' . $e->getMessage()];
}
}
}
@@ -0,0 +1,416 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\Service;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Uri\Uri;
/**
* Helpdesk email notification service.
*
* Sends emails for ticket events to Joomla users (by ID) and/or
* raw email addresses. Uses Joomla's configured mailer.
*
* @since 02.32.00
*/
class NotificationService
{
/**
* Send a ticket notification email.
*
* @param string $event Event name (ticket_created, ticket_replied, status_changed, ticket_assigned)
* @param object $ticket Ticket object with id, subject, status, priority, created_by, assigned_to
* @param array $extra Extra context (reply body, old status, etc.)
*/
public static function notify(string $event, object $ticket, array $extra = []): void
{
try
{
$recipients = self::getRecipients($event, $ticket);
if (empty($recipients))
{
return;
}
$subject = self::buildSubject($event, $ticket);
$body = self::buildBody($event, $ticket, $extra);
$mailer = Factory::getMailer();
$mailer->isHtml(false);
$mailer->setSubject($subject);
$mailer->setBody($body);
foreach ($recipients as $email)
{
$email = trim($email);
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL))
{
continue;
}
try
{
$mailer->clearAddresses();
$mailer->addRecipient($email);
$mailer->Send();
}
catch (\Throwable $e)
{
Log::add('Notification send failed to ' . $email . ': ' . $e->getMessage(), Log::WARNING, 'mokowaas');
}
}
}
catch (\Throwable $e)
{
Log::add('Notification error: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
}
}
/**
* Determine recipients based on event type and ticket data.
*/
private static function getRecipients(string $event, object $ticket): array
{
$emails = [];
// Get notification config from component params
$config = self::getNotificationConfig();
// Always notify configured admin emails
$adminEmails = array_filter(array_map('trim', explode(',', $config['admin_emails'] ?? '')));
$emails = array_merge($emails, $adminEmails);
// Always notify configured admin user IDs
$adminUserIds = array_filter(array_map('intval', explode(',', $config['admin_user_ids'] ?? '')));
foreach ($adminUserIds as $uid)
{
$email = self::getUserEmail($uid);
if ($email)
{
$emails[] = $email;
}
}
switch ($event)
{
case 'ticket_created':
// Notify assigned user if any
if (!empty($ticket->assigned_to))
{
$email = self::getUserEmail((int) $ticket->assigned_to);
if ($email)
{
$emails[] = $email;
}
}
break;
case 'ticket_replied':
// Notify ticket creator (customer gets notified of staff reply)
if (!empty($ticket->created_by))
{
$email = self::getUserEmail((int) $ticket->created_by);
if ($email)
{
$emails[] = $email;
}
}
// Notify assigned user
if (!empty($ticket->assigned_to))
{
$email = self::getUserEmail((int) $ticket->assigned_to);
if ($email)
{
$emails[] = $email;
}
}
break;
case 'status_changed':
// Notify ticket creator
if (!empty($ticket->created_by))
{
$email = self::getUserEmail((int) $ticket->created_by);
if ($email)
{
$emails[] = $email;
}
}
break;
case 'ticket_assigned':
// Notify newly assigned user
if (!empty($ticket->assigned_to))
{
$email = self::getUserEmail((int) $ticket->assigned_to);
if ($email)
{
$emails[] = $email;
}
}
break;
}
return array_unique($emails);
}
/**
* Build email subject line.
*/
private static function buildSubject(string $event, object $ticket): string
{
$siteName = Factory::getConfig()->get('sitename', 'Support');
$prefix = '[' . $siteName . ' #' . $ticket->id . '] ';
switch ($event)
{
case 'ticket_created':
return $prefix . 'New Ticket: ' . ($ticket->subject ?? '');
case 'ticket_replied':
return $prefix . 'Reply: ' . ($ticket->subject ?? '');
case 'status_changed':
return $prefix . 'Status Changed: ' . ($ticket->subject ?? '');
case 'ticket_assigned':
return $prefix . 'Assigned: ' . ($ticket->subject ?? '');
default:
return $prefix . ($ticket->subject ?? '');
}
}
/**
* Build email body.
*/
private static function buildBody(string $event, object $ticket, array $extra): string
{
$siteName = Factory::getConfig()->get('sitename', 'Support');
$siteUrl = rtrim(Uri::root(), '/');
$ticketUrl = $siteUrl . '/index.php?option=com_mokowaas&view=ticket&id=' . $ticket->id;
$lines = [];
$lines[] = $siteName . ' Support';
$lines[] = str_repeat('-', 40);
$lines[] = '';
switch ($event)
{
case 'ticket_created':
$lines[] = 'A new support ticket has been created.';
$lines[] = '';
$lines[] = 'Subject: ' . ($ticket->subject ?? '');
$lines[] = 'Priority: ' . ucfirst($ticket->priority ?? 'normal');
$lines[] = 'Category: ' . ($ticket->category_title ?? 'General');
$lines[] = '';
if (!empty($ticket->body))
{
$lines[] = 'Description:';
$lines[] = strip_tags($ticket->body);
$lines[] = '';
}
break;
case 'ticket_replied':
$lines[] = 'A new reply has been added to your ticket.';
$lines[] = '';
$lines[] = 'Subject: ' . ($ticket->subject ?? '');
$lines[] = 'Status: ' . ucwords(str_replace('_', ' ', $ticket->status ?? ''));
$lines[] = '';
if (!empty($extra['reply_body']))
{
$lines[] = 'Reply:';
$lines[] = strip_tags($extra['reply_body']);
$lines[] = '';
}
break;
case 'status_changed':
$lines[] = 'Your ticket status has been updated.';
$lines[] = '';
$lines[] = 'Subject: ' . ($ticket->subject ?? '');
$lines[] = 'New Status: ' . ucwords(str_replace('_', ' ', $ticket->status ?? ''));
if (!empty($extra['old_status']))
{
$lines[] = 'Old Status: ' . ucwords(str_replace('_', ' ', $extra['old_status']));
}
$lines[] = '';
break;
case 'ticket_assigned':
$lines[] = 'A ticket has been assigned to you.';
$lines[] = '';
$lines[] = 'Subject: ' . ($ticket->subject ?? '');
$lines[] = 'Priority: ' . ucfirst($ticket->priority ?? 'normal');
$lines[] = '';
break;
}
$lines[] = 'View ticket: ' . $ticketUrl;
$lines[] = '';
$lines[] = '-- ';
$lines[] = $siteName . ' | Powered by MokoWaaS';
return implode("\n", $lines);
}
/**
* Get email address for a Joomla user ID.
*/
private static function getUserEmail(int $userId): ?string
{
if ($userId <= 0)
{
return null;
}
try
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('email'))
->from($db->quoteName('#__users'))
->where($db->quoteName('id') . ' = ' . $userId)
);
return $db->loadResult() ?: null;
}
catch (\Throwable $e)
{
return null;
}
}
/**
* Get notification configuration from component params.
*/
private static function getNotificationConfig(): array
{
try
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
);
$params = json_decode($db->loadResult() ?? '{}', true);
return $params['notifications'] ?? [];
}
catch (\Throwable $e)
{
return [];
}
}
// ==================================================================
// Security Event Notifications (#131)
// ==================================================================
/**
* Send a security alert to admin emails.
*/
public static function securityAlert(string $event, string $subject, string $body): void
{
try
{
$config = self::getNotificationConfig();
$enabled = $config['security_alerts'] ?? '1';
if (!$enabled)
{
return;
}
$adminEmails = array_filter(array_map('trim', explode(',', $config['admin_emails'] ?? '')));
$adminUserIds = array_filter(array_map('intval', explode(',', $config['admin_user_ids'] ?? '')));
$recipients = $adminEmails;
foreach ($adminUserIds as $uid)
{
$email = self::getUserEmail($uid);
if ($email)
{
$recipients[] = $email;
}
}
$recipients = array_unique($recipients);
if (empty($recipients))
{
return;
}
$siteName = Factory::getConfig()->get('sitename', 'Site');
$fullSubject = '[' . $siteName . ' Security] ' . $subject;
$lines = [
$siteName . ' Security Alert',
str_repeat('-', 40),
'',
'Event: ' . $event,
'Time: ' . gmdate('Y-m-d H:i:s') . ' UTC',
'',
$body,
'',
'-- ',
$siteName . ' | MokoWaaS Security',
];
$mailer = Factory::getMailer();
$mailer->isHtml(false);
$mailer->setSubject($fullSubject);
$mailer->setBody(implode("\n", $lines));
foreach ($recipients as $email)
{
try
{
$mailer->clearAddresses();
$mailer->addRecipient(trim($email));
$mailer->Send();
}
catch (\Throwable $e)
{
Log::add('Security alert send failed: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
}
}
}
catch (\Throwable $e)
{
Log::add('Security alert error: ' . $e->getMessage(), Log::WARNING, 'mokowaas');
}
}
}
@@ -0,0 +1,27 @@
<?php
namespace Moko\Component\MokoWaaS\Administrator\View\Automation;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $rules = [];
public function display($tpl = null)
{
$model = new \Moko\Component\MokoWaaS\Administrator\Model\TicketsModel();
$this->rules = $model->getAutomationRules();
ToolbarHelper::title('Automation Rules', 'cogs');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas&view=tickets');
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
parent::display($tpl);
}
}
@@ -0,0 +1,33 @@
<?php
namespace Moko\Component\MokoWaaS\Administrator\View\Canned;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $responses = [];
protected $categories = [];
public function display($tpl = null)
{
$db = Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
$db->setQuery('SELECT * FROM #__mokowaas_ticket_canned ORDER BY ordering ASC');
$this->responses = $db->loadObjectList() ?: [];
$db->setQuery('SELECT id, title FROM #__mokowaas_ticket_categories WHERE published = 1 ORDER BY ordering');
$this->categories = $db->loadObjectList() ?: [];
ToolbarHelper::title('Canned Responses', 'comment');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas&view=tickets');
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
parent::display($tpl);
}
}
@@ -0,0 +1,41 @@
<?php
namespace Moko\Component\MokoWaaS\Administrator\View\Categories;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $categories = [];
protected $users = [];
public function display($tpl = null)
{
$db = Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
$db->setQuery('SELECT * FROM #__mokowaas_ticket_categories ORDER BY ordering ASC');
$this->categories = $db->loadObjectList() ?: [];
// Get admin users for auto-assign dropdown
$db->setQuery(
$db->getQuery(true)
->select([$db->quoteName('id'), $db->quoteName('name')])
->from($db->quoteName('#__users'))
->where($db->quoteName('block') . ' = 0')
->order($db->quoteName('name') . ' ASC')
->setLimit(100)
);
$this->users = $db->loadObjectList() ?: [];
ToolbarHelper::title('Ticket Categories', 'folder');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas&view=tickets');
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
parent::display($tpl);
}
}
@@ -0,0 +1,27 @@
<?php
namespace Moko\Component\MokoWaaS\Administrator\View\Cleanup;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $dirs = [];
public function display($tpl = null)
{
$model = new \Moko\Component\MokoWaaS\Administrator\Model\MaintenanceModel();
$this->dirs = $model->getCleanupInfo();
ToolbarHelper::title('Cache &amp; Temp Cleanup', 'trash');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas');
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
parent::display($tpl);
}
}
@@ -17,26 +17,43 @@ use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
/**
* @var array Discovered MokoWaaS feature plugins.
*/
protected $plugins = [];
/**
* @var object Site info (Joomla version, PHP version, etc.).
*/
protected $siteInfo;
protected $recentLogins = [];
protected $pendingUpdates = [];
protected $checkedOutItems = [];
protected $wafBlocks = [];
protected $wafChartData = [];
protected $loginChartData = [];
public function display($tpl = null)
{
$model = $this->getModel();
$this->plugins = $model->getFeaturePlugins();
$this->siteInfo = $model->getSiteInfo();
$this->plugins = $model->getFeaturePlugins();
$this->siteInfo = $model->getSiteInfo();
$this->recentLogins = $model->getRecentLogins(5);
$this->pendingUpdates = $model->getPendingUpdates();
$this->checkedOutItems = $model->getCheckedOutItems();
$this->wafBlocks = $model->getRecentWafBlocks(5);
$this->wafChartData = $model->getWafBlocksByDay(14);
$this->loginChartData = $model->getLoginsByDay(14);
// Check for importable Akeeba data
try
{
$importModel = new \Moko\Component\MokoWaaS\Administrator\Model\ImportModel();
$this->adminToolsAvailable = $importModel->checkAdminToolsAvailable();
$this->atsAvailable = $importModel->checkAtsAvailable();
}
catch (\Throwable $e)
{
$this->adminToolsAvailable = null;
$this->atsAvailable = null;
}
$this->addToolbar();
// Load dashboard assets
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
$wa->registerAndUseScript('com_mokowaas.dashboard', 'com_mokowaas/dashboard.js', [], ['defer' => true]);
@@ -0,0 +1,27 @@
<?php
namespace Moko\Component\MokoWaaS\Administrator\View\Database;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $tableData = [];
public function display($tpl = null)
{
$model = new \Moko\Component\MokoWaaS\Administrator\Model\MaintenanceModel();
$this->tableData = $model->getTableStatus();
ToolbarHelper::title('Database Tools', 'database');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas');
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
parent::display($tpl);
}
}
@@ -0,0 +1,41 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\View\Extensions;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $packages = [];
public function display($tpl = null)
{
$model = $this->getModel();
$this->packages = $model->getCatalog();
$this->addToolbar();
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
parent::display($tpl);
}
protected function addToolbar(): void
{
ToolbarHelper::title(Text::_('COM_MOKOWAAS_EXTENSIONS_TITLE'), 'puzzle-piece');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas');
}
}
@@ -0,0 +1,47 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\View\Htaccess;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $options = [];
protected $preview = '';
protected $nginxPreview = '';
protected $currentHtaccess = '';
public function display($tpl = null)
{
$model = $this->getModel();
$this->options = $model->getOptions();
$this->preview = $model->generateHtaccess($this->options);
$this->nginxPreview = $model->generateNginx($this->options);
$this->currentHtaccess = $model->readCurrentHtaccess();
$this->addToolbar();
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
parent::display($tpl);
}
protected function addToolbar(): void
{
ToolbarHelper::title(Text::_('COM_MOKOWAAS_HTACCESS_TITLE'), 'file-code');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas');
}
}
@@ -0,0 +1,39 @@
<?php
namespace Moko\Component\MokoWaaS\Administrator\View\Privacy;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $requests = [];
protected $policies = [];
protected $summary;
public function display($tpl = null)
{
$model = new \Moko\Component\MokoWaaS\Administrator\Model\PrivacyModel();
$filterStatus = Factory::getApplication()->getInput()->getString('filter_status', '');
$this->requests = $model->getDataRequests($filterStatus);
$this->policies = $model->getRetentionPolicies();
$this->summary = $model->getDashboardSummary();
$this->addToolbar();
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
parent::display($tpl);
}
protected function addToolbar(): void
{
ToolbarHelper::title('Privacy Guard', 'lock');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas');
}
}
@@ -0,0 +1,53 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\View\Ticket;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $ticket;
protected $cannedResponses = [];
public function display($tpl = null)
{
$model = $this->getModel('Tickets');
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$this->ticket = $model->getTicket($id);
$this->cannedResponses = $model->getCannedResponses((int) ($this->ticket->category_id ?? 0));
if (!$this->ticket)
{
Factory::getApplication()->enqueueMessage('Ticket not found.', 'error');
Factory::getApplication()->redirect('index.php?option=com_mokowaas&view=tickets');
return;
}
$this->addToolbar();
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
parent::display($tpl);
}
protected function addToolbar(): void
{
$title = $this->ticket ? 'Ticket #' . $this->ticket->id . ' — ' . $this->ticket->subject : 'Ticket';
ToolbarHelper::title($title, 'headphones');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas&view=tickets');
}
}
@@ -0,0 +1,56 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Administrator\View\Tickets;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $tickets = [];
protected $categories = [];
protected $statusCounts;
protected $overdue = [];
protected $atsAvailable = null;
public function display($tpl = null)
{
$model = $this->getModel();
$app = Factory::getApplication();
$filters = [
'status' => $app->getInput()->getString('filter_status', ''),
'priority' => $app->getInput()->getString('filter_priority', ''),
'category_id' => $app->getInput()->getInt('filter_category', 0),
];
$this->tickets = $model->getTickets($filters);
$this->categories = $model->getCategories();
$this->statusCounts = $model->getStatusCounts();
$this->overdue = $model->getOverdueTickets();
$this->atsAvailable = $model->checkAtsAvailable();
$this->addToolbar();
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
parent::display($tpl);
}
protected function addToolbar(): void
{
ToolbarHelper::title(Text::_('COM_MOKOWAAS_TICKETS_TITLE'), 'headphones');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas');
}
}
@@ -0,0 +1,55 @@
<?php
namespace Moko\Component\MokoWaaS\Administrator\View\Waflog;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $logs = [];
protected $ruleCounts = [];
protected $topIps = [];
protected $ruleNames = [];
protected $total = 0;
protected $filters = [];
public function display($tpl = null)
{
$model = new \Moko\Component\MokoWaaS\Administrator\Model\WaflogModel();
$input = Factory::getApplication()->getInput();
$this->filters = [
'rule' => $input->getString('filter_rule', ''),
'ip' => $input->getString('filter_ip', ''),
'search' => $input->getString('filter_search', ''),
'date_from' => $input->getString('filter_date_from', ''),
'date_to' => $input->getString('filter_date_to', ''),
];
$page = max(1, $input->getInt('page', 1));
$limit = 50;
$offset = ($page - 1) * $limit;
$this->logs = $model->getLogs($this->filters, $limit, $offset);
$this->total = $model->getTotal($this->filters);
$this->ruleCounts = $model->getRuleCounts();
$this->topIps = $model->getTopIps(10);
$this->ruleNames = $model->getRuleNames();
$this->addToolbar();
$wa = Factory::getApplication()->getDocument()->getWebAssetManager();
$wa->registerAndUseStyle('com_mokowaas.dashboard', 'com_mokowaas/dashboard.css');
parent::display($tpl);
}
protected function addToolbar(): void
{
ToolbarHelper::title('WAF Log Viewer', 'shield-alt');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokowaas');
}
}
@@ -0,0 +1,141 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$rules = $this->rules;
$token = Session::getFormToken();
$saveUrl = Route::_('index.php?option=com_mokowaas&task=display.saveAutomation&format=json');
$deleteUrl = Route::_('index.php?option=com_mokowaas&task=display.deleteAutomation&format=json');
$toggleUrl = Route::_('index.php?option=com_mokowaas&task=display.toggleAutomation&format=json');
$triggerLabels = ['ticket_created' => 'On Ticket Created', 'ticket_replied' => 'On Reply', 'status_changed' => 'On Status Change', 'scheduled' => 'Scheduled (Cron)'];
?>
<div id="mokowaas-automation">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4><?php echo count($rules); ?> Automation Rules</h4>
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newRuleModal">
<span class="icon-plus"></span> Add Rule
</button>
</div>
<?php foreach ($rules as $r): ?>
<?php $conditions = json_decode($r->conditions, true) ?: []; $actions = json_decode($r->actions, true) ?: []; ?>
<div class="card mb-2 <?php echo !$r->enabled ? 'opacity-50' : ''; ?>" data-id="<?php echo $r->id; ?>">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="d-flex align-items-center gap-2">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input rule-toggle" data-id="<?php echo $r->id; ?>" <?php echo $r->enabled ? 'checked' : ''; ?>>
</div>
<strong><?php echo htmlspecialchars($r->title); ?></strong>
<span class="badge bg-secondary"><?php echo $triggerLabels[$r->trigger_event] ?? $r->trigger_event; ?></span>
</div>
<div class="small text-muted mt-1">
<span class="text-primary">IF</span>
<?php foreach ($conditions as $i => $c): ?>
<?php echo $i > 0 ? ' AND ' : ''; ?><?php echo htmlspecialchars($c['field'] ?? ''); ?> <?php echo htmlspecialchars($c['op'] ?? ''); ?> <?php echo htmlspecialchars($c['value'] ?? ''); ?>
<?php endforeach; ?>
<span class="text-success ms-2">THEN</span>
<?php foreach ($actions as $a): ?>
<?php echo htmlspecialchars($a['type'] ?? ''); ?>=<?php echo htmlspecialchars(mb_substr($a['value'] ?? '', 0, 30)); ?>
<?php endforeach; ?>
</div>
</div>
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-rule" data-id="<?php echo $r->id; ?>">
<span class="icon-trash"></span>
</button>
</div>
</div>
</div>
<?php endforeach; ?>
<?php if (empty($rules)): ?>
<div class="alert alert-info">No automation rules. Click "Add Rule" to create one.</div>
<?php endif; ?>
</div>
<!-- New Rule Modal -->
<div class="modal fade" id="newRuleModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header"><h5>Add Automation Rule</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Title</label>
<input type="text" id="rule-title" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Trigger</label>
<select id="rule-trigger" class="form-select">
<?php foreach ($triggerLabels as $k => $v): ?><option value="<?php echo $k; ?>"><?php echo $v; ?></option><?php endforeach; ?>
</select>
</div>
<div class="mb-3">
<label class="form-label">Conditions (JSON)</label>
<textarea id="rule-conditions" class="form-control font-monospace" rows="3" placeholder='[{"field":"status","op":"eq","value":"resolved"}]'></textarea>
<small class="text-muted">Fields: status, priority, category_id, assigned_to, sla_responded, age_hours. Ops: eq, neq, gt, lt, in, not_in</small>
</div>
<div class="mb-3">
<label class="form-label">Actions (JSON)</label>
<textarea id="rule-actions" class="form-control font-monospace" rows="3" placeholder='[{"type":"set_status","value":"closed"}]'></textarea>
<small class="text-muted">Types: set_status, set_priority, assign, add_note, send_email</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="btn-save-rule"><span class="icon-save"></span> Save Rule</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var token = '<?php echo $token; ?>';
// Save new rule
document.getElementById('btn-save-rule').addEventListener('click', function() {
var fd = new FormData();
fd.append('id', '0');
fd.append('title', document.getElementById('rule-title').value);
fd.append('trigger_event', document.getElementById('rule-trigger').value);
fd.append('conditions', document.getElementById('rule-conditions').value || '[]');
fd.append('actions', document.getElementById('rule-actions').value || '[]');
fd.append(token, '1');
fetch('<?php echo $saveUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){ if (d.success) location.reload(); else Joomla.renderMessages({error:[d.message]}); });
});
// Toggle rule
document.querySelectorAll('.rule-toggle').forEach(function(cb) {
cb.addEventListener('change', function() {
var fd = new FormData();
fd.append('id', this.dataset.id);
fd.append('enabled', this.checked ? '1' : '0');
fd.append(token, '1');
fetch('<?php echo $toggleUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){ if (!d.success) Joomla.renderMessages({error:[d.message]}); });
});
});
// Delete rule
document.querySelectorAll('.btn-delete-rule').forEach(function(btn) {
btn.addEventListener('click', function() {
if (!confirm('Delete this rule?')) return;
var card = this.closest('.card');
var fd = new FormData();
fd.append('id', this.dataset.id);
fd.append(token, '1');
fetch('<?php echo $deleteUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){ if (d.success) card.remove(); else Joomla.renderMessages({error:[d.message]}); });
});
});
});
</script>
@@ -0,0 +1,107 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$responses = $this->responses;
$categories = $this->categories;
$token = Session::getFormToken();
$saveUrl = Route::_('index.php?option=com_mokowaas&task=display.saveCanned&format=json');
$deleteUrl = Route::_('index.php?option=com_mokowaas&task=display.deleteCanned&format=json');
?>
<div id="mokowaas-canned">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4><?php echo count($responses); ?> Canned Responses</h4>
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newCannedModal">
<span class="icon-plus"></span> Add Response
</button>
</div>
<?php foreach ($responses as $r): ?>
<div class="card mb-2" data-id="<?php echo $r->id; ?>">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-start">
<div>
<strong><?php echo htmlspecialchars($r->title); ?></strong>
<p class="text-muted small mb-0 mt-1"><?php echo htmlspecialchars(mb_substr($r->body, 0, 150)); ?></p>
</div>
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-canned" data-id="<?php echo $r->id; ?>">
<span class="icon-trash"></span>
</button>
</div>
</div>
</div>
<?php endforeach; ?>
<?php if (empty($responses)): ?>
<div class="alert alert-info">No canned responses yet. Click "Add Response" to create one.</div>
<?php endif; ?>
</div>
<!-- New Canned Modal -->
<div class="modal fade" id="newCannedModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header"><h5>Add Canned Response</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Title</label>
<input type="text" id="canned-title" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Category (optional)</label>
<select id="canned-category" class="form-select">
<option value="">All categories</option>
<?php foreach ($categories as $cat): ?>
<option value="<?php echo $cat->id; ?>"><?php echo htmlspecialchars($cat->title); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="mb-3">
<label class="form-label">Response Text</label>
<textarea id="canned-body" class="form-control" rows="6" required></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="btn-save-canned"><span class="icon-save"></span> Save</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var token = '<?php echo $token; ?>';
document.getElementById('btn-save-canned').addEventListener('click', function() {
var fd = new FormData();
fd.append('id', '0');
fd.append('title', document.getElementById('canned-title').value);
fd.append('body', document.getElementById('canned-body').value);
fd.append('category_id', document.getElementById('canned-category').value);
fd.append(token, '1');
fetch('<?php echo $saveUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) location.reload();
else Joomla.renderMessages({error:[d.message]});
});
});
document.querySelectorAll('.btn-delete-canned').forEach(function(btn) {
btn.addEventListener('click', function() {
if (!confirm('Delete this canned response?')) return;
var card = this.closest('.card');
var fd = new FormData();
fd.append('id', this.dataset.id);
fd.append(token, '1');
fetch('<?php echo $deleteUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){ if (d.success) card.remove(); else Joomla.renderMessages({error:[d.message]}); });
});
});
});
</script>
@@ -0,0 +1,126 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$categories = $this->categories;
$users = $this->users;
$token = Session::getFormToken();
$saveUrl = Route::_('index.php?option=com_mokowaas&task=display.saveCategory&format=json');
$deleteUrl = Route::_('index.php?option=com_mokowaas&task=display.deleteCategory&format=json');
?>
<div id="mokowaas-categories">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4><?php echo count($categories); ?> Categories</h4>
<button type="button" class="btn btn-primary btn-sm" id="btn-add-cat">
<span class="icon-plus"></span> Add Category
</button>
</div>
<div class="card">
<div class="table-responsive">
<table class="table table-striped mb-0" id="cat-table">
<thead><tr><th>Title</th><th>SLA Response</th><th>SLA Resolution</th><th>Auto-Assign</th><th>Active</th><th></th></tr></thead>
<tbody>
<?php foreach ($categories as $c): ?>
<tr data-id="<?php echo $c->id; ?>">
<td><input type="text" class="form-control form-control-sm cat-field" data-field="title" value="<?php echo htmlspecialchars($c->title); ?>"></td>
<td><input type="number" class="form-control form-control-sm cat-field" data-field="sla_response_minutes" value="<?php echo $c->sla_response_minutes; ?>" style="width:80px"> min</td>
<td><input type="number" class="form-control form-control-sm cat-field" data-field="sla_resolution_minutes" value="<?php echo $c->sla_resolution_minutes; ?>" style="width:80px"> min</td>
<td>
<select class="form-select form-select-sm cat-field" data-field="auto_assign_user">
<option value="">None</option>
<?php foreach ($users as $u): ?>
<option value="<?php echo $u->id; ?>" <?php echo (int)$c->auto_assign_user === (int)$u->id ? 'selected' : ''; ?>><?php echo htmlspecialchars($u->name); ?></option>
<?php endforeach; ?>
</select>
</td>
<td>
<input type="checkbox" class="form-check-input cat-field" data-field="published" <?php echo $c->published ? 'checked' : ''; ?>>
</td>
<td>
<button type="button" class="btn btn-sm btn-outline-success btn-save-cat" title="Save"><span class="icon-save"></span></button>
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-cat" title="Delete"><span class="icon-trash"></span></button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var token = '<?php echo $token; ?>';
// Save category
document.querySelectorAll('.btn-save-cat').forEach(function(btn) {
btn.addEventListener('click', function() {
var row = this.closest('tr');
var id = row.dataset.id || '0';
var fd = new FormData();
fd.append('id', id);
fd.append(token, '1');
row.querySelectorAll('.cat-field').forEach(function(f) {
fd.append(f.dataset.field, f.type === 'checkbox' ? (f.checked ? '1' : '0') : f.value);
});
fetch('<?php echo $saveUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) { Joomla.renderMessages({message:[d.message]}); if (d.id && id === '0') row.dataset.id = d.id; }
else Joomla.renderMessages({error:[d.message]});
});
});
});
// Delete category
document.querySelectorAll('.btn-delete-cat').forEach(function(btn) {
btn.addEventListener('click', function() {
if (!confirm('Delete this category?')) return;
var row = this.closest('tr');
var fd = new FormData();
fd.append('id', row.dataset.id);
fd.append(token, '1');
fetch('<?php echo $deleteUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) row.remove();
else Joomla.renderMessages({error:[d.message]});
});
});
});
// Add new row
document.getElementById('btn-add-cat').addEventListener('click', function() {
var tbody = document.querySelector('#cat-table tbody');
var tr = document.createElement('tr');
tr.dataset.id = '0';
tr.innerHTML = '<td><input type="text" class="form-control form-control-sm cat-field" data-field="title" value=""></td>'
+ '<td><input type="number" class="form-control form-control-sm cat-field" data-field="sla_response_minutes" value="480" style="width:80px"> min</td>'
+ '<td><input type="number" class="form-control form-control-sm cat-field" data-field="sla_resolution_minutes" value="2880" style="width:80px"> min</td>'
+ '<td><select class="form-select form-select-sm cat-field" data-field="auto_assign_user"><option value="">None</option><?php foreach ($users as $u): ?><option value="<?php echo $u->id; ?>"><?php echo htmlspecialchars($u->name); ?></option><?php endforeach; ?></select></td>'
+ '<td><input type="checkbox" class="form-check-input cat-field" data-field="published" checked></td>'
+ '<td><button type="button" class="btn btn-sm btn-outline-success btn-save-cat"><span class="icon-save"></span></button></td>';
tbody.appendChild(tr);
tr.querySelector('.btn-save-cat').addEventListener('click', function() {
var row = this.closest('tr');
var fd = new FormData();
fd.append('id', '0');
fd.append(token, '1');
row.querySelectorAll('.cat-field').forEach(function(f) {
fd.append(f.dataset.field, f.type === 'checkbox' ? (f.checked ? '1' : '0') : f.value);
});
fetch('<?php echo $saveUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) { Joomla.renderMessages({message:[d.message]}); location.reload(); }
else Joomla.renderMessages({error:[d.message]});
});
});
tr.querySelector('input').focus();
});
});
</script>
@@ -0,0 +1,63 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$dirs = $this->dirs;
$token = Session::getFormToken();
$cleanUrl = Route::_('index.php?option=com_mokowaas&task=display.cleanDirectory&format=json');
$dirKeys = ['site_cache', 'admin_cache', 'tmp', 'logs'];
$totalMb = 0;
$totalFiles = 0;
foreach ($dirs as $d) { $totalMb += $d->size_mb; $totalFiles += $d->files; }
?>
<div id="mokowaas-cleanup">
<div class="row g-3 mb-4">
<div class="col-6 col-md-3"><div class="card text-center p-3"><span class="fw-bold fs-3"><?php echo number_format($totalMb, 1); ?> MB</span><small class="text-muted">Total Size</small></div></div>
<div class="col-6 col-md-3"><div class="card text-center p-3"><span class="fw-bold fs-3"><?php echo number_format($totalFiles); ?></span><small class="text-muted">Total Files</small></div></div>
</div>
<div class="row g-3">
<?php foreach ($dirs as $i => $d): ?>
<div class="col-12 col-md-6 col-xl-3">
<div class="card h-100">
<div class="card-body text-center">
<h5><?php echo htmlspecialchars($d->label); ?></h5>
<p class="fs-3 fw-bold mb-1 <?php echo $d->size_mb > 50 ? 'text-warning' : ''; ?>"><?php echo number_format($d->size_mb, 1); ?> MB</p>
<p class="text-muted small"><?php echo number_format($d->files); ?> files</p>
<?php if (!$d->writable): ?>
<span class="badge bg-danger">Not writable</span>
<?php else: ?>
<button type="button" class="btn btn-outline-danger btn-clean" data-key="<?php echo $dirKeys[$i] ?? ''; ?>" data-label="<?php echo htmlspecialchars($d->label); ?>">
<span class="icon-trash"></span> Clean
</button>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<script>
document.querySelectorAll('.btn-clean').forEach(function(btn) {
btn.addEventListener('click', function() {
if (!confirm('Clean all files in ' + this.dataset.label + '?')) return;
var el = this;
el.disabled = true;
var fd = new FormData();
fd.append('dir_key', el.dataset.key);
fd.append('<?php echo $token; ?>', '1');
fetch('<?php echo $cleanUrl; ?>', {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) { Joomla.renderMessages({message:[d.message]}); setTimeout(function(){location.reload()},1500); }
else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; }
})
.catch(function(){ el.disabled = false; });
});
});
</script>
@@ -8,15 +8,22 @@
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
/** @var \Moko\Component\MokoWaaS\Administrator\View\Dashboard\HtmlView $this */
$siteInfo = $this->siteInfo;
$plugins = $this->plugins;
$token = Session::getFormToken();
$siteInfo = $this->siteInfo;
$plugins = $this->plugins;
$recentLogins = $this->recentLogins;
$pendingUpdates = $this->pendingUpdates;
$adminToolsAvail = $this->adminToolsAvailable ?? null;
$atsAvail = $this->atsAvailable ?? null;
$checkedOut = $this->checkedOutItems;
$wafBlocks = $this->wafBlocks;
$token = Session::getFormToken();
// Group plugins by category
$grouped = [];
@@ -25,7 +32,6 @@ foreach ($plugins as $plugin)
$grouped[$plugin->category][] = $plugin;
}
// Category display order
$categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
?>
@@ -54,101 +60,366 @@ $categoryOrder = ['core', 'security', 'monitoring', 'content', 'tools', 'api'];
<span class="mokowaas-info-value"><span class="badge bg-secondary"><?php echo $this->escape($siteInfo->db_type); ?></span></span>
</div>
<?php if ($siteInfo->debug): ?>
<div class="mokowaas-info-item">
<span class="badge bg-warning text-dark"><?php echo Text::_('COM_MOKOWAAS_DEBUG_ON'); ?></span>
</div>
<span class="badge bg-warning text-dark"><?php echo Text::_('COM_MOKOWAAS_DEBUG_ON'); ?></span>
<?php endif; ?>
<?php if ($siteInfo->offline): ?>
<div class="mokowaas-info-item">
<span class="badge bg-danger"><?php echo Text::_('COM_MOKOWAAS_OFFLINE'); ?></span>
</div>
<span class="badge bg-danger"><?php echo Text::_('COM_MOKOWAAS_OFFLINE'); ?></span>
<?php endif; ?>
<div class="mokowaas-info-item ms-auto">
<span class="icon-globe" aria-hidden="true"></span>
<code><?php echo $this->escape($_SERVER['REMOTE_ADDR'] ?? ''); ?></code>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="mokowaas-quick-actions mb-4">
<div class="d-flex flex-wrap gap-2">
<button type="button" class="btn btn-outline-primary btn-sm" id="mokowaas-btn-cache"
<?php if ($adminToolsAvail || $atsAvail): ?>
<!-- Akeeba Import Banner -->
<div class="alert alert-info d-flex flex-wrap align-items-center gap-3 mb-4">
<span class="icon-info-circle" style="font-size:1.25rem"></span>
<strong>Akeeba data detected — import into MokoWaaS:</strong>
<?php if ($adminToolsAvail): ?>
<button type="button" class="btn btn-sm btn-info" id="btn-import-admintools"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.importAdminTools&format=json'); ?>"
data-token="<?php echo $token; ?>">
<span class="icon-shield-alt"></span> Import Admin Tools Settings
</button>
<?php endif; ?>
<?php if ($atsAvail): ?>
<button type="button" class="btn btn-sm btn-info" id="btn-import-ats-dash"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.importAts&format=json'); ?>"
data-token="<?php echo $token; ?>">
<span class="icon-headphones"></span> Import Tickets (<?php echo $atsAvail->tickets; ?> tickets)
</button>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- Quick Actions (large buttons) -->
<div class="row g-3 mb-4">
<div class="col-6 col-md-4 col-xl-3">
<button type="button" class="btn btn-outline-primary w-100 py-3" id="mokowaas-btn-cache"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.clearCache&format=json'); ?>"
data-token="<?php echo $token; ?>">
<span class="icon-trash" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOWAAS_CLEAR_CACHE'); ?>
<span class="icon-bolt d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
Clear Cache
</button>
<a href="<?php echo Route::_('index.php?option=com_installer&view=update'); ?>" class="btn btn-outline-primary btn-sm">
<span class="icon-refresh" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOWAAS_CHECK_UPDATES'); ?>
</div>
<div class="col-6 col-md-4 col-xl-3">
<a href="<?php echo Route::_('index.php?option=com_installer&view=update'); ?>" class="btn btn-outline-primary w-100 py-3">
<span class="icon-refresh d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
Check Updates
</a>
</div>
<div class="col-6 col-md-4 col-xl-3">
<a href="<?php echo Route::_('index.php?option=com_mokowaas&view=extensions'); ?>" class="btn btn-outline-primary w-100 py-3">
<span class="icon-puzzle-piece d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
Moko Extensions
</a>
</div>
<div class="col-6 col-md-4 col-xl-3">
<a href="<?php echo Route::_('index.php?option=com_checkin'); ?>" class="btn btn-outline-secondary w-100 py-3">
<span class="icon-check-square d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
Global Check-in
</a>
</div>
<div class="col-6 col-md-4 col-xl-3">
<a href="<?php echo Route::_('index.php?option=com_actionlogs'); ?>" class="btn btn-outline-secondary w-100 py-3">
<span class="icon-list d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
View Logs
</a>
</div>
<div class="col-6 col-md-4 col-xl-3">
<a href="<?php echo Route::_('index.php?option=com_scheduler'); ?>" class="btn btn-outline-secondary w-100 py-3">
<span class="icon-clock d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
Scheduled Tasks
</a>
</div>
<div class="col-6 col-md-4 col-xl-3">
<?php
// Use MokoJoomCommunity if available, otherwise Joomla user manager
$useCB = file_exists(JPATH_ADMINISTRATOR . '/components/com_comprofiler/comprofiler.php');
$userUrl = $useCB
? Route::_('index.php?option=com_comprofiler&task=showusers')
: Route::_('index.php?option=com_users');
$userLabel = $useCB ? 'MokoJoomCommunity' : 'User Manager';
?>
<a href="<?php echo $userUrl; ?>" class="btn btn-outline-secondary w-100 py-3">
<span class="icon-users d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
<?php echo $userLabel; ?>
</a>
</div>
<div class="col-6 col-md-4 col-xl-3">
<a href="<?php echo Route::_('index.php?option=com_redirect'); ?>" class="btn btn-outline-secondary w-100 py-3">
<span class="icon-arrow-right d-block mb-1" aria-hidden="true" style="font-size:1.5rem"></span>
Redirects
</a>
</div>
</div>
<!-- Feature Plugin Grid -->
<?php foreach ($categoryOrder as $catKey): ?>
<?php if (empty($grouped[$catKey])) continue; ?>
<?php
$catPlugins = $grouped[$catKey];
$first = $catPlugins[0];
?>
<h3 class="mokowaas-category-heading mb-3">
<span class="badge <?php echo $this->escape($first->categoryBadge); ?>"><?php echo $this->escape($first->categoryLabel); ?></span>
</h3>
<div class="mokowaas-plugin-grid row g-3 mb-4">
<?php foreach ($catPlugins as $plugin): ?>
<div class="col-12 col-md-6 col-xl-4">
<div class="card mokowaas-plugin-card h-100 <?php echo $plugin->enabled ? '' : 'mokowaas-plugin-disabled'; ?>"
data-extension-id="<?php echo $plugin->extension_id; ?>">
<div class="card-body d-flex flex-column">
<div class="d-flex align-items-start justify-content-between mb-2">
<div class="d-flex align-items-center gap-2">
<span class="<?php echo $this->escape($plugin->icon); ?> mokowaas-plugin-icon" aria-hidden="true"></span>
<h5 class="card-title mb-0"><?php echo $this->escape($plugin->name); ?></h5>
</div>
<?php if ($plugin->version): ?>
<span class="badge bg-light text-dark"><?php echo $this->escape($plugin->version); ?></span>
<?php endif; ?>
</div>
<p class="card-text text-muted small flex-grow-1"><?php echo $this->escape($plugin->description); ?></p>
<div class="d-flex align-items-center justify-content-between mt-auto pt-2 border-top">
<div class="d-flex align-items-center gap-2">
<?php if ($plugin->protected): ?>
<span class="badge bg-dark" title="<?php echo Text::_('COM_MOKOWAAS_PROTECTED'); ?>"><?php echo Text::_('COM_MOKOWAAS_PROTECTED'); ?></span>
<?php else: ?>
<div class="form-check form-switch">
<input
type="checkbox"
class="form-check-input mokowaas-toggle"
role="switch"
id="toggle-<?php echo $plugin->extension_id; ?>"
data-extension-id="<?php echo $plugin->extension_id; ?>"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.togglePlugin&format=json'); ?>"
data-token="<?php echo $token; ?>"
<?php echo $plugin->enabled ? 'checked' : ''; ?>
>
<label class="form-check-label small" for="toggle-<?php echo $plugin->extension_id; ?>">
<?php echo $plugin->enabled ? Text::_('COM_MOKOWAAS_ENABLED') : Text::_('COM_MOKOWAAS_DISABLED'); ?>
</label>
<!-- Three-column layout: plugins left, tables right -->
<div class="row">
<!-- Left: Feature Plugin Grid (8 cols) -->
<div class="col-12 col-xl-8">
<?php foreach ($categoryOrder as $catKey): ?>
<?php if (empty($grouped[$catKey])) continue; ?>
<?php
$catPlugins = $grouped[$catKey];
$first = $catPlugins[0];
?>
<h3 class="mokowaas-category-heading mb-3">
<span class="badge <?php echo $this->escape($first->categoryBadge); ?>"><?php echo $this->escape($first->categoryLabel); ?></span>
</h3>
<div class="mokowaas-plugin-grid row g-3 mb-4">
<?php foreach ($catPlugins as $plugin): ?>
<div class="col-12 col-md-6">
<div class="card mokowaas-plugin-card h-100 <?php echo $plugin->enabled ? '' : 'mokowaas-plugin-disabled'; ?>"
data-extension-id="<?php echo $plugin->extension_id; ?>">
<div class="card-body d-flex flex-column">
<div class="d-flex align-items-start justify-content-between mb-2">
<div class="d-flex align-items-center gap-2">
<span class="<?php echo $this->escape($plugin->icon); ?> mokowaas-plugin-icon" aria-hidden="true"></span>
<h5 class="card-title mb-0"><?php echo $this->escape($plugin->name); ?></h5>
</div>
<?php endif; ?>
<?php if ($plugin->version): ?>
<span class="badge bg-light text-dark"><?php echo $this->escape($plugin->version); ?></span>
<?php endif; ?>
</div>
<p class="card-text text-muted text-muted flex-grow-1"><?php echo $this->escape($plugin->description); ?></p>
<div class="d-flex align-items-center justify-content-between mt-auto pt-2 border-top">
<?php if ($plugin->protected): ?>
<span class="badge bg-dark"><?php echo Text::_('COM_MOKOWAAS_PROTECTED'); ?></span>
<?php elseif ($plugin->configure_only): ?>
<span class="badge bg-<?php echo $plugin->enabled ? 'success' : 'secondary'; ?>">
<?php echo $plugin->enabled ? Text::_('COM_MOKOWAAS_ENABLED') : Text::_('COM_MOKOWAAS_DISABLED'); ?>
</span>
<?php else: ?>
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input mokowaas-toggle" role="switch"
id="toggle-<?php echo $plugin->extension_id; ?>"
data-extension-id="<?php echo $plugin->extension_id; ?>"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.togglePlugin&format=json'); ?>"
data-token="<?php echo $token; ?>"
<?php echo $plugin->enabled ? 'checked' : ''; ?>>
<label class="form-check-label" for="toggle-<?php echo $plugin->extension_id; ?>">
<?php echo $plugin->enabled ? Text::_('COM_MOKOWAAS_ENABLED') : Text::_('COM_MOKOWAAS_DISABLED'); ?>
</label>
</div>
<?php endif; ?>
<?php if ($plugin->type === 'plugin'): ?>
<a href="<?php echo Route::_('index.php?option=com_plugins&task=plugin.edit&extension_id=' . $plugin->extension_id); ?>" class="btn btn-sm btn-outline-secondary">
<span class="icon-cog" aria-hidden="true"></span> <?php echo Text::_('COM_MOKOWAAS_CONFIGURE'); ?>
</a>
<?php endif; ?>
</div>
</div>
<?php
// Build configure link
$configUrl = '';
if ($plugin->type === 'plugin')
{
$configUrl = Route::_('index.php?option=com_plugins&task=plugin.edit&extension_id=' . $plugin->extension_id);
}
?>
<?php if ($configUrl): ?>
<a href="<?php echo $configUrl; ?>" class="btn btn-sm btn-outline-secondary">
<span class="icon-cog" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOWAAS_CONFIGURE'); ?>
</a>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
<!-- Right: Charts & Information (4 cols) -->
<div class="col-12 col-xl-4" style="border-left:1px solid var(--gray-300, #dee2e6);padding-left:1.5rem;">
<!-- WAF Activity Chart -->
<div class="card mb-3">
<div class="card-header">
<strong><span class="icon-shield-alt" aria-hidden="true"></span> WAF Activity (14 days)</strong>
</div>
<div class="card-body py-2">
<canvas id="mokowaas-chart-waf" height="140"></canvas>
</div>
</div>
<!-- Login Activity Chart -->
<div class="card mb-3">
<div class="card-header">
<strong><span class="icon-user" aria-hidden="true"></span> Login Activity (14 days)</strong>
</div>
<div class="card-body py-2">
<canvas id="mokowaas-chart-logins" height="140"></canvas>
</div>
</div>
<!-- Pending Updates -->
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><span class="icon-refresh" aria-hidden="true"></span> Pending Updates</strong>
<span class="badge bg-<?php echo count($pendingUpdates) > 0 ? 'warning text-dark' : 'success'; ?>"><?php echo count($pendingUpdates); ?></span>
</div>
<?php if (!empty($pendingUpdates)): ?>
<div class="table-responsive">
<table class="table table-sm table-striped mb-0">
<thead><tr><th>Extension</th><th>Current</th><th>Available</th></tr></thead>
<tbody>
<?php foreach ($pendingUpdates as $upd): ?>
<tr>
<td class="text-muted"><?php echo $this->escape($upd->name); ?></td>
<td class="text-muted"><?php echo $this->escape($upd->current_version); ?></td>
<td class="text-success fw-bold"><?php echo $this->escape($upd->version); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div class="card-body text-center text-muted py-3">
<span class="icon-check-circle text-success"></span> All extensions up to date
</div>
<?php endif; ?>
</div>
<!-- Checked Out Items -->
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><span class="icon-lock" aria-hidden="true"></span> Checked Out Items</strong>
<span class="badge bg-<?php echo count($checkedOut) > 0 ? 'info' : 'success'; ?>"><?php echo count($checkedOut); ?></span>
</div>
<?php if (!empty($checkedOut)): ?>
<div class="table-responsive">
<table class="table table-sm table-striped mb-0">
<thead><tr><th>Article</th><th>User</th><th>Since</th></tr></thead>
<tbody>
<?php foreach ($checkedOut as $item): ?>
<tr>
<td class="text-muted"><?php echo $this->escape(mb_substr($item->title, 0, 30)); ?></td>
<td class="text-muted"><?php echo $this->escape($item->username ?? ''); ?></td>
<td class="text-muted"><?php echo HTMLHelper::_('date', $item->checked_out_time, 'M d H:i'); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="card-footer text-center py-1">
<a href="<?php echo Route::_('index.php?option=com_checkin'); ?>" class="text-muted">Global Check-in</a>
</div>
<?php else: ?>
<div class="card-body text-center text-muted py-3">
<span class="icon-check-circle text-success"></span> No checked out items
</div>
<?php endif; ?>
</div>
<!-- WAF Blocks -->
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><span class="icon-shield-alt" aria-hidden="true"></span> Recent WAF Blocks</strong>
<span class="badge bg-<?php echo count($wafBlocks) > 0 ? 'danger' : 'success'; ?>"><?php echo count($wafBlocks); ?></span>
</div>
<?php if (!empty($wafBlocks)): ?>
<div class="table-responsive">
<table class="table table-sm table-striped mb-0">
<thead><tr><th>IP</th><th>Rule</th><th>Time</th></tr></thead>
<tbody>
<?php foreach ($wafBlocks as $block): ?>
<tr>
<td class="text-muted"><code><?php echo $this->escape($block->ip); ?></code></td>
<td class="text-muted"><span class="badge bg-danger"><?php echo $this->escape($block->rule); ?></span></td>
<td class="text-muted"><?php echo HTMLHelper::_('date', $block->created, 'M d H:i'); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div class="card-body text-center text-muted py-3">
<span class="icon-check-circle text-success"></span> No recent blocks
</div>
<?php endif; ?>
</div>
<!-- Recent Logins -->
<div class="card mb-3">
<div class="card-header">
<strong><span class="icon-user" aria-hidden="true"></span> Recent Logins</strong>
</div>
<?php if (!empty($recentLogins)): ?>
<div class="table-responsive">
<table class="table table-sm table-striped mb-0">
<thead><tr><th>User</th><th>IP</th><th>Time</th></tr></thead>
<tbody>
<?php foreach ($recentLogins as $login): ?>
<tr>
<td class="text-muted"><?php echo $this->escape($login->username ?? ''); ?></td>
<td class="text-muted"><code><?php echo $this->escape($login->ip_address ?? ''); ?></code></td>
<td class="text-muted"><?php echo HTMLHelper::_('date', $login->log_date, 'M d H:i'); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div class="card-body text-center text-muted py-3">No login activity recorded</div>
<?php endif; ?>
</div>
</div><!-- /.col-xl-4 -->
</div><!-- /.row -->
</div>
<?php
// Prepare chart data as JSON for JavaScript
$wafChartData = $this->wafChartData ?? [];
$loginChartData = $this->loginChartData ?? [];
$wafLabels = array_map(fn($d) => $d->day, $wafChartData);
$wafValues = array_map(fn($d) => $d->total, $wafChartData);
$loginLabels = array_map(fn($d) => $d->day, $loginChartData);
$loginValues = array_map(fn($d) => $d->total, $loginChartData);
?>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js" integrity="sha384-jb8JQMbMoBUzgWatfe6COACi2ljcDdZQ2OxczGA3bGNeWe+6DChMTBJemed7ZnvJ" crossorigin="anonymous"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var chartDefaults = {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { display: false }, ticks: { maxRotation: 45, font: { size: 10 } } },
y: { beginAtZero: true, ticks: { stepSize: 1, font: { size: 10 } } }
}
};
// WAF chart
var wafCtx = document.getElementById('mokowaas-chart-waf');
if (wafCtx) {
new Chart(wafCtx, {
type: 'bar',
data: {
labels: <?php echo json_encode($wafLabels); ?>,
datasets: [{
data: <?php echo json_encode($wafValues); ?>,
backgroundColor: 'rgba(197, 40, 39, 0.6)',
borderColor: '#c52827',
borderWidth: 1,
borderRadius: 3
}]
},
options: chartDefaults
});
}
// Login chart
var loginCtx = document.getElementById('mokowaas-chart-logins');
if (loginCtx) {
new Chart(loginCtx, {
type: 'line',
data: {
labels: <?php echo json_encode($loginLabels); ?>,
datasets: [{
data: <?php echo json_encode($loginValues); ?>,
borderColor: '#2a69b8',
backgroundColor: 'rgba(42, 105, 184, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 3,
pointBackgroundColor: '#2a69b8'
}]
},
options: chartDefaults
});
}
});
</script>
@@ -0,0 +1,72 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$data = $this->tableData;
$tables = $data['tables'] ?? [];
$token = Session::getFormToken();
$optimizeUrl = Route::_('index.php?option=com_mokowaas&task=display.optimizeDb&format=json');
$repairUrl = Route::_('index.php?option=com_mokowaas&task=display.repairDb&format=json');
$purgeUrl = Route::_('index.php?option=com_mokowaas&task=display.purgeSessions&format=json');
?>
<div id="mokowaas-database">
<div class="row g-3 mb-4">
<div class="col-6 col-md-3"><div class="card text-center p-3"><span class="fw-bold fs-3"><?php echo $data['count']; ?></span><small class="text-muted">Tables</small></div></div>
<div class="col-6 col-md-3"><div class="card text-center p-3"><span class="fw-bold fs-3"><?php echo $data['total_size_mb']; ?> MB</span><small class="text-muted">Total Size</small></div></div>
<div class="col-6 col-md-3"><div class="card text-center p-3"><span class="fw-bold fs-3 <?php echo $data['total_overhead_kb'] > 100 ? 'text-warning' : 'text-success'; ?>"><?php echo $data['total_overhead_kb']; ?> KB</span><small class="text-muted">Overhead</small></div></div>
<div class="col-6 col-md-3">
<div class="card p-3 d-grid gap-2">
<button type="button" class="btn btn-sm btn-primary btn-db-action" data-url="<?php echo $optimizeUrl; ?>" data-token="<?php echo $token; ?>" data-confirm="Optimize all tables with overhead?">
<span class="icon-bolt"></span> Optimize All
</button>
<button type="button" class="btn btn-sm btn-outline-warning btn-db-action" data-url="<?php echo $repairUrl; ?>" data-token="<?php echo $token; ?>" data-confirm="Repair all tables?">
<span class="icon-wrench"></span> Repair All
</button>
<button type="button" class="btn btn-sm btn-outline-secondary btn-db-action" data-url="<?php echo $purgeUrl; ?>" data-token="<?php echo $token; ?>" data-confirm="Purge expired sessions?">
<span class="icon-trash"></span> Purge Sessions
</button>
</div>
</div>
</div>
<div class="card">
<div class="table-responsive">
<table class="table table-striped table-sm mb-0">
<thead><tr><th>Table</th><th>Engine</th><th class="text-end">Rows</th><th class="text-end">Size</th><th class="text-end">Overhead</th></tr></thead>
<tbody>
<?php foreach ($tables as $t): ?>
<tr class="<?php echo $t->overhead_kb > 10 ? 'table-warning' : ''; ?> <?php echo $t->is_moko ? 'fw-bold' : ''; ?>">
<td class="small"><?php echo htmlspecialchars($t->name); ?></td>
<td class="small"><?php echo htmlspecialchars($t->engine); ?></td>
<td class="text-end small"><?php echo number_format($t->rows); ?></td>
<td class="text-end small"><?php echo $t->size_mb; ?> MB</td>
<td class="text-end small <?php echo $t->overhead_kb > 10 ? 'text-warning fw-bold' : ''; ?>"><?php echo $t->overhead_kb > 0 ? $t->overhead_kb . ' KB' : '—'; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
<script>
document.querySelectorAll('.btn-db-action').forEach(function(btn) {
btn.addEventListener('click', function() {
if (!confirm(this.dataset.confirm)) return;
var el = this;
el.disabled = true;
var fd = new FormData();
fd.append(el.dataset.token, '1');
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) { Joomla.renderMessages({message:[d.message]}); setTimeout(function(){location.reload()},1500); }
else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; }
})
.catch(function(){ el.disabled = false; });
});
});
</script>
@@ -0,0 +1,174 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
/** @var \Moko\Component\MokoWaaS\Administrator\View\Extensions\HtmlView $this */
$packages = $this->packages;
$token = Session::getFormToken();
// Group by category
$grouped = [];
foreach ($packages as $pkg)
{
$grouped[$pkg->category][] = $pkg;
}
$statusBadge = [
'installed' => ['bg-success', 'Installed'],
'not_installed' => ['bg-secondary', 'Not Installed'],
];
?>
<div id="mokowaas-extensions">
<div class="alert alert-info">
<span class="icon-info-circle" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOWAAS_EXTENSIONS_INFO'); ?>
</div>
<?php foreach ($grouped as $category => $pkgs): ?>
<h3 class="mb-3"><?php echo htmlspecialchars($category); ?></h3>
<div class="row g-3 mb-4">
<?php foreach ($pkgs as $pkg): ?>
<?php
$badge = $statusBadge[$pkg->status] ?? $statusBadge['not_installed'];
?>
<div class="col-12 col-md-6 col-xl-4">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex align-items-start justify-content-between mb-2">
<div class="d-flex align-items-center gap-2">
<span class="<?php echo htmlspecialchars($pkg->icon); ?>" aria-hidden="true" style="font-size:1.5rem;color:#1a2744"></span>
<div>
<h5 class="card-title mb-0"><?php echo htmlspecialchars($pkg->label); ?></h5>
<small class="text-muted"><?php echo htmlspecialchars($pkg->type); ?></small>
</div>
</div>
<span class="badge <?php echo $badge[0]; ?>"><?php echo $badge[1]; ?></span>
</div>
<p class="card-text text-muted flex-grow-1"><?php echo htmlspecialchars($pkg->description); ?></p>
<div class="d-flex align-items-center justify-content-between mt-auto pt-2 border-top">
<div class="small text-muted">
<?php if ($pkg->local_version): ?>
v<?php echo htmlspecialchars($pkg->local_version); ?>
<?php elseif ($pkg->remote_version): ?>
Latest: <?php echo htmlspecialchars($pkg->remote_version); ?>
<?php endif; ?>
</div>
<div class="d-flex gap-1">
<?php if ($pkg->article_url): ?>
<a href="<?php echo htmlspecialchars($pkg->article_url); ?>" target="_blank" class="btn btn-sm btn-outline-secondary" title="Documentation">
<span class="icon-book" aria-hidden="true"></span>
</a>
<?php endif; ?>
<?php if ($pkg->download_url && $pkg->status === 'not_installed'): ?>
<button type="button" class="btn btn-sm btn-primary mokowaas-install-btn"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.installExtension&format=json'); ?>"
data-download="<?php echo htmlspecialchars($pkg->download_url); ?>"
data-token="<?php echo $token; ?>"
data-label="<?php echo htmlspecialchars($pkg->label); ?>">
<span class="icon-download" aria-hidden="true"></span>
Install
</button>
<?php elseif ($pkg->status === 'installed'): ?>
<?php
$dashLink = '';
if ($pkg->type === 'component')
{
$dashLink = 'index.php?option=' . $pkg->element;
}
elseif ($pkg->type === 'package' && strpos($pkg->element, 'pkg_') === 0)
{
$comElement = 'com_' . substr($pkg->element, 4);
if (is_dir(JPATH_ADMINISTRATOR . '/components/' . $comElement))
{
$dashLink = 'index.php?option=' . $comElement;
}
}
?>
<?php if ($dashLink): ?>
<a href="<?php echo Route::_($dashLink); ?>" class="btn btn-sm btn-outline-primary" title="Open">
<span class="icon-arrow-right" aria-hidden="true"></span> Open
</a>
<?php endif; ?>
<span class="btn btn-sm btn-outline-success disabled">
<span class="icon-check" aria-hidden="true"></span> Installed
</span>
<?php if (!$pkg->protected && $pkg->extension_id): ?>
<a href="<?php echo Route::_('index.php?option=com_installer&task=manage.remove&cid[]=' . $pkg->extension_id . '&' . $token . '=1'); ?>"
class="btn btn-sm btn-outline-danger"
onclick="return confirm('Uninstall <?php echo htmlspecialchars($pkg->label); ?>?')"
title="Uninstall">
<span class="icon-times" aria-hidden="true"></span>
</a>
<?php endif; ?>
<?php else: ?>
<span class="btn btn-sm btn-outline-secondary disabled">No release</span>
<?php endif; ?>
</div>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.mokowaas-install-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var el = this;
var url = el.dataset.url;
var downloadUrl = el.dataset.download;
var token = el.dataset.token;
var label = el.dataset.label;
if (!confirm('Install ' + label + '?')) return;
el.disabled = true;
var origHtml = el.textContent;
el.textContent = ' Installing...';
var fd = new FormData();
fd.append('download_url', downloadUrl);
fd.append(token, '1');
fetch(url, {
method: 'POST',
body: fd,
headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) {
Joomla.renderMessages({message: [label + ': ' + d.message]});
location.reload();
} else {
Joomla.renderMessages({error: [label + ': ' + (d.message || 'Failed')]});
el.disabled = false;
el.textContent = origHtml;
}
})
.catch(function() {
Joomla.renderMessages({error: ['Network error']});
el.disabled = false;
el.textContent = origHtml;
});
});
});
});
</script>
@@ -0,0 +1,306 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$opts = $this->options;
$preview = $this->preview;
$nginx = $this->nginxPreview;
$current = $this->currentHtaccess;
$token = Session::getFormToken();
$saveUrl = Route::_('index.php?option=com_mokowaas&task=display.saveHtaccess&format=json');
$genUrl = Route::_('index.php?option=com_mokowaas&task=display.generateHtaccess&format=json');
// Helper for toggle switch
$sw = function($name, $label, $desc = '') use ($opts) {
$checked = !empty($opts[$name]) ? 'checked' : '';
echo '<div class="d-flex justify-content-between align-items-center py-2 border-bottom">';
echo '<div><strong>' . htmlspecialchars($label) . '</strong>';
if ($desc) echo '<br><small class="text-muted">' . htmlspecialchars($desc) . '</small>';
echo '</div>';
echo '<div class="form-check form-switch">';
echo '<input type="checkbox" class="form-check-input htaccess-opt" name="' . $name . '" id="htopt-' . $name . '" ' . $checked . '>';
echo '</div></div>';
};
?>
<div id="mokowaas-htaccess">
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item"><a class="nav-link active" data-bs-toggle="tab" href="#tab-htaccess" role="tab">.htaccess</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-nginx" role="tab">NginX</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-current" role="tab">Current File</a></li>
</ul>
<div class="tab-content">
<!-- .htaccess Tab -->
<div class="tab-pane fade show active" id="tab-htaccess" role="tabpanel">
<div class="row">
<!-- Left: Options -->
<div class="col-12 col-xl-6">
<div class="card mb-3">
<div class="card-header"><strong><span class="icon-shield-alt"></span> Security</strong></div>
<div class="card-body">
<?php $sw('disable_directory_listing', 'Disable Directory Listing', 'Options -Indexes'); ?>
<?php $sw('block_sensitive_files', 'Block Sensitive Files', 'htaccess.txt, configuration.php-dist, etc.'); ?>
<?php $sw('block_php_in_uploads', 'Block PHP in Uploads', 'Prevent .php in images/, media/, tmp/'); ?>
<?php $sw('disable_server_signature', 'Hide Server Signature', 'ServerSignature Off, remove X-Powered-By'); ?>
<?php $sw('prevent_clickjacking', 'Clickjacking Protection', 'X-Frame-Options: SAMEORIGIN'); ?>
<?php $sw('prevent_mime_sniffing', 'MIME Sniffing Prevention', 'X-Content-Type-Options: nosniff'); ?>
<?php $sw('xss_protection', 'XSS Protection Header', 'X-XSS-Protection: 1; mode=block'); ?>
<?php $sw('disable_trace_track', 'Disable TRACE/TRACK', 'Block HTTP TRACE and TRACK methods'); ?>
<div class="py-2 border-bottom">
<label class="form-label fw-bold" for="htopt-referrer_policy">Referrer Policy</label>
<select class="form-select form-select-sm htaccess-opt" name="referrer_policy" id="htopt-referrer_policy">
<option value="off" <?php echo ($opts['referrer_policy'] ?? '') === 'off' ? 'selected' : ''; ?>>Off</option>
<option value="no-referrer" <?php echo ($opts['referrer_policy'] ?? '') === 'no-referrer' ? 'selected' : ''; ?>>no-referrer</option>
<option value="same-origin" <?php echo ($opts['referrer_policy'] ?? '') === 'same-origin' ? 'selected' : ''; ?>>same-origin</option>
<option value="strict-origin-when-cross-origin" <?php echo ($opts['referrer_policy'] ?? '') === 'strict-origin-when-cross-origin' ? 'selected' : ''; ?>>strict-origin-when-cross-origin</option>
</select>
</div>
<?php $sw('hsts_enabled', 'HSTS (Force HTTPS)', 'Strict-Transport-Security header'); ?>
<div class="ps-4 <?php echo empty($opts['hsts_enabled']) ? 'd-none' : ''; ?>" id="hsts-options">
<div class="row g-2 py-2">
<div class="col-6">
<label class="form-label small">Max Age (seconds)</label>
<input type="number" class="form-control form-control-sm htaccess-opt" name="hsts_max_age" value="<?php echo (int) ($opts['hsts_max_age'] ?? 31536000); ?>">
</div>
<div class="col-6 d-flex align-items-end">
<div class="form-check">
<input type="checkbox" class="form-check-input htaccess-opt" name="hsts_subdomains" id="htopt-hsts_sub" <?php echo !empty($opts['hsts_subdomains']) ? 'checked' : ''; ?>>
<label class="form-check-label small" for="htopt-hsts_sub">Include Subdomains</label>
</div>
</div>
</div>
</div>
<?php $sw('csp_enabled', 'Content Security Policy', 'CSP header'); ?>
<div class="ps-4 <?php echo empty($opts['csp_enabled']) ? 'd-none' : ''; ?>" id="csp-options">
<textarea class="form-control form-control-sm htaccess-opt mt-1" name="csp_value" rows="2" placeholder="default-src 'self'; script-src 'self' 'unsafe-inline'"><?php echo htmlspecialchars($opts['csp_value'] ?? ''); ?></textarea>
</div>
<?php $sw('permissions_policy', 'Permissions Policy', 'Camera, microphone, geolocation controls'); ?>
<div class="ps-4 <?php echo empty($opts['permissions_policy']) ? 'd-none' : ''; ?>" id="perms-options">
<textarea class="form-control form-control-sm htaccess-opt mt-1" name="permissions_value" rows="2" placeholder="camera=(), microphone=(), geolocation=()"><?php echo htmlspecialchars($opts['permissions_value'] ?? ''); ?></textarea>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><strong><span class="icon-bolt"></span> Performance</strong></div>
<div class="card-body">
<?php $sw('enable_gzip', 'GZip Compression', 'Compress CSS, JS, HTML, XML, JSON'); ?>
<?php $sw('enable_expires', 'Browser Caching', 'Set expiration headers for static files'); ?>
<div class="ps-4 <?php echo empty($opts['enable_expires']) ? 'd-none' : ''; ?>" id="expires-options">
<div class="row g-2 py-2">
<div class="col-4">
<label class="form-label small">HTML (sec)</label>
<input type="number" class="form-control form-control-sm htaccess-opt" name="expires_html" value="<?php echo (int) ($opts['expires_html'] ?? 3600); ?>">
</div>
<div class="col-4">
<label class="form-label small">CSS/JS (sec)</label>
<input type="number" class="form-control form-control-sm htaccess-opt" name="expires_css_js" value="<?php echo (int) ($opts['expires_css_js'] ?? 2592000); ?>">
</div>
<div class="col-4">
<label class="form-label small">Images (sec)</label>
<input type="number" class="form-control form-control-sm htaccess-opt" name="expires_images" value="<?php echo (int) ($opts['expires_images'] ?? 31536000); ?>">
</div>
</div>
</div>
<?php $sw('etag_control', 'Disable ETags', 'For load-balanced environments'); ?>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><strong><span class="icon-search"></span> SEO / Redirects</strong></div>
<div class="card-body">
<div class="py-2 border-bottom">
<label class="form-label fw-bold">WWW Redirect</label>
<select class="form-select form-select-sm htaccess-opt" name="www_redirect">
<option value="off" <?php echo ($opts['www_redirect'] ?? 'off') === 'off' ? 'selected' : ''; ?>>Off</option>
<option value="www" <?php echo ($opts['www_redirect'] ?? '') === 'www' ? 'selected' : ''; ?>>Force www</option>
<option value="non-www" <?php echo ($opts['www_redirect'] ?? '') === 'non-www' ? 'selected' : ''; ?>>Force non-www</option>
</select>
</div>
<?php $sw('redirect_index_php', 'Redirect /index.php to /', 'SEO-friendly root redirect'); ?>
<?php $sw('force_trailing_slash', 'Force Trailing Slash', 'Append / to URLs without file extension'); ?>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><strong><span class="icon-code"></span> Custom Rules</strong></div>
<div class="card-body">
<textarea class="form-control htaccess-opt" name="custom_rules" rows="4" placeholder="# Add custom Apache directives here"><?php echo htmlspecialchars($opts['custom_rules'] ?? ''); ?></textarea>
</div>
</div>
</div>
<!-- Right: Preview -->
<div class="col-12 col-xl-6">
<div class="card mb-3 sticky-top" style="top:1rem">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Preview</strong>
<span class="badge bg-secondary" id="htaccess-line-count"><?php echo substr_count($preview, "\n"); ?> lines</span>
</div>
<div class="card-body p-0">
<textarea id="htaccess-preview" class="form-control font-monospace border-0" rows="30" readonly style="font-size:0.8rem;resize:none"><?php echo htmlspecialchars($preview); ?></textarea>
</div>
<div class="card-footer d-flex gap-2">
<button type="button" class="btn btn-primary" id="htaccess-save"
data-url="<?php echo $saveUrl; ?>" data-token="<?php echo $token; ?>">
<span class="icon-save"></span> Save to .htaccess
</button>
<button type="button" class="btn btn-outline-secondary" id="htaccess-download">
<span class="icon-download"></span> Download
</button>
</div>
</div>
</div>
</div>
</div>
<!-- NginX Tab -->
<div class="tab-pane fade" id="tab-nginx" role="tabpanel">
<div class="card">
<div class="card-header"><strong>NginX Configuration Snippet</strong></div>
<div class="card-body p-0">
<textarea id="nginx-preview" class="form-control font-monospace border-0" rows="25" readonly style="font-size:0.8rem;resize:none"><?php echo htmlspecialchars($nginx); ?></textarea>
</div>
<div class="card-footer">
<button type="button" class="btn btn-outline-secondary" id="nginx-download">
<span class="icon-download"></span> Download NginX Config
</button>
</div>
</div>
</div>
<!-- Current File Tab -->
<div class="tab-pane fade" id="tab-current" role="tabpanel">
<div class="card">
<div class="card-header"><strong>Current .htaccess on Disk</strong></div>
<div class="card-body p-0">
<textarea class="form-control font-monospace border-0" rows="25" readonly style="font-size:0.8rem;resize:none"><?php echo htmlspecialchars($current); ?></textarea>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var saveBtn = document.getElementById('htaccess-save');
var preview = document.getElementById('htaccess-preview');
var lineCount = document.getElementById('htaccess-line-count');
// Toggle sub-option visibility
document.getElementById('htopt-hsts_enabled').addEventListener('change', function() {
document.getElementById('hsts-options').classList.toggle('d-none', !this.checked);
});
document.getElementById('htopt-csp_enabled').addEventListener('change', function() {
document.getElementById('csp-options').classList.toggle('d-none', !this.checked);
});
document.getElementById('htopt-permissions_policy').addEventListener('change', function() {
document.getElementById('perms-options').classList.toggle('d-none', !this.checked);
});
document.getElementById('htopt-enable_expires') && document.getElementById('htopt-enable_expires').addEventListener('change', function() {
document.getElementById('expires-options').classList.toggle('d-none', !this.checked);
});
// Regenerate preview on any option change
document.querySelectorAll('.htaccess-opt').forEach(function(el) {
el.addEventListener('change', regeneratePreview);
el.addEventListener('input', regeneratePreview);
});
function collectOptions() {
var opts = {};
document.querySelectorAll('.htaccess-opt').forEach(function(el) {
if (el.type === 'checkbox') {
opts[el.name] = el.checked ? 1 : 0;
} else {
opts[el.name] = el.value;
}
});
return opts;
}
var debounceTimer;
function regeneratePreview() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(function() {
var fd = new FormData();
var opts = collectOptions();
for (var k in opts) fd.append(k, opts[k]);
fd.append('<?php echo $token; ?>', '1');
fetch('<?php echo $genUrl; ?>', {
method: 'POST', body: fd,
headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.htaccess) {
preview.value = d.htaccess;
lineCount.textContent = d.htaccess.split('\n').length + ' lines';
}
if (d.nginx) {
document.getElementById('nginx-preview').value = d.nginx;
}
});
}, 300);
}
// Save to disk
saveBtn.addEventListener('click', function() {
if (!confirm('This will overwrite your current .htaccess file. A backup will be created at .htaccess.mokowaas.bak. Continue?')) return;
var btn = this;
btn.disabled = true;
var fd = new FormData();
fd.append('content', preview.value);
var opts = collectOptions();
for (var k in opts) fd.append('opt_' + k, opts[k]);
fd.append('<?php echo $token; ?>', '1');
fetch(btn.dataset.url, {
method: 'POST', body: fd,
headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) Joomla.renderMessages({message: [d.message]});
else Joomla.renderMessages({error: [d.message]});
})
.catch(function() { Joomla.renderMessages({error: ['Network error']}); })
.finally(function() { btn.disabled = false; });
});
// Download buttons
function downloadText(content, filename) {
var blob = new Blob([content], {type: 'text/plain'});
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}
document.getElementById('htaccess-download').addEventListener('click', function() {
downloadText(preview.value, '.htaccess');
});
document.getElementById('nginx-download').addEventListener('click', function() {
downloadText(document.getElementById('nginx-preview').value, 'mokowaas-nginx.conf');
});
});
</script>
@@ -0,0 +1,267 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$requests = $this->requests;
$policies = $this->policies;
$summary = $this->summary;
$token = Session::getFormToken();
$statusBadge = [
'pending' => 'bg-warning text-dark',
'processing' => 'bg-info',
'completed' => 'bg-success',
'denied' => 'bg-secondary',
];
$typeBadge = [
'export' => 'bg-primary',
'delete' => 'bg-danger',
'anonymize' => 'bg-warning text-dark',
];
?>
<div id="mokowaas-privacy">
<!-- Summary cards -->
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card text-center p-3">
<span class="fw-bold fs-3 <?php echo $summary->pending_requests > 0 ? 'text-warning' : 'text-success'; ?>"><?php echo $summary->pending_requests; ?></span>
<small class="text-muted">Pending Requests</small>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card text-center p-3">
<span class="fw-bold fs-3"><?php echo $summary->total_requests; ?></span>
<small class="text-muted">Total Requests</small>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card text-center p-3">
<span class="fw-bold fs-3"><?php echo $summary->consent_entries; ?></span>
<small class="text-muted">Consent Entries</small>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card text-center p-3">
<span class="fw-bold fs-3"><?php echo $summary->policies_active; ?></span>
<small class="text-muted">Active Policies</small>
</div>
</div>
</div>
<!-- New Request Form -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><span class="icon-plus"></span> Create Data Request</strong>
<button class="btn btn-sm btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#newRequestForm" aria-expanded="false">
<span class="icon-plus"></span> New Request
</button>
</div>
<div class="collapse" id="newRequestForm">
<div class="card-body">
<form id="formNewRequest" class="row g-3">
<div class="col-12 col-md-5">
<label for="req_user_id" class="form-label">User</label>
<select id="req_user_id" class="form-select" required>
<option value="">Select a user...</option>
<?php
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select([$db->quoteName('id'), $db->quoteName('name'), $db->quoteName('email')])
->from($db->quoteName('#__users'))
->where($db->quoteName('block') . ' = 0')
->order($db->quoteName('name'))
);
foreach ($db->loadObjectList() as $u):
?>
<option value="<?php echo (int) $u->id; ?>"><?php echo $this->escape($u->name); ?> (<?php echo $this->escape($u->email); ?>)</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 col-md-3">
<label for="req_type" class="form-label">Request Type</label>
<select id="req_type" class="form-select" required>
<option value="export">Export Data</option>
<option value="delete">Delete Data</option>
<option value="anonymize">Anonymize Data</option>
</select>
</div>
<div class="col-12 col-md-2">
<label for="req_auto" class="form-label">Auto-process</label>
<select id="req_auto" class="form-select">
<option value="0">No (pending)</option>
<option value="1">Yes (immediate)</option>
</select>
</div>
<div class="col-12 col-md-2 d-flex align-items-end">
<button type="submit" class="btn btn-primary w-100" id="btnCreateRequest"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.processDataRequest&format=json'); ?>"
data-token="<?php echo $token; ?>">
<span class="icon-check"></span> Submit
</button>
</div>
</form>
</div>
</div>
</div>
<div class="row">
<!-- Data Requests -->
<div class="col-12 col-xl-8">
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><span class="icon-user-shield"></span> Data Subject Requests</strong>
<form method="get" class="d-inline">
<input type="hidden" name="option" value="com_mokowaas">
<input type="hidden" name="view" value="privacy">
<select name="filter_status" class="form-select form-select-sm" style="width:auto" onchange="this.form.submit()">
<option value="">All</option>
<?php foreach (['pending','processing','completed','denied'] as $s): ?>
<option value="<?php echo $s; ?>" <?php echo Factory::getApplication()->getInput()->getString('filter_status') === $s ? 'selected' : ''; ?>><?php echo ucfirst($s); ?></option>
<?php endforeach; ?>
</select>
</form>
</div>
<?php if (empty($requests)): ?>
<div class="card-body text-center text-muted py-4">No data requests found.</div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-striped table-hover mb-0">
<thead><tr><th>#</th><th>User</th><th>Type</th><th>Status</th><th>Created</th><th>Processed</th><th>Actions</th></tr></thead>
<tbody>
<?php foreach ($requests as $r): ?>
<tr>
<td><?php echo $r->id; ?></td>
<td><?php echo $this->escape($r->user_name ?? ''); ?><br><small class="text-muted"><?php echo $this->escape($r->user_email ?? ''); ?></small></td>
<td><span class="badge <?php echo $typeBadge[$r->type] ?? 'bg-secondary'; ?>"><?php echo ucfirst($r->type); ?></span></td>
<td><span class="badge <?php echo $statusBadge[$r->status] ?? 'bg-secondary'; ?>"><?php echo ucfirst($r->status); ?></span></td>
<td class="text-nowrap small"><?php echo HTMLHelper::_('date', $r->created, 'M d, Y H:i'); ?></td>
<td class="text-nowrap small"><?php echo $r->processed ? HTMLHelper::_('date', $r->processed, 'M d, Y H:i') : '—'; ?></td>
<td>
<?php if ($r->status === 'pending'): ?>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-success btn-privacy-action" data-id="<?php echo $r->id; ?>" data-action="approve"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.processDataRequest&format=json'); ?>"
data-token="<?php echo $token; ?>">Approve</button>
<button type="button" class="btn btn-outline-danger btn-privacy-action" data-id="<?php echo $r->id; ?>" data-action="deny"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.processDataRequest&format=json'); ?>"
data-token="<?php echo $token; ?>">Deny</button>
</div>
<?php elseif ($r->status === 'completed' && $r->type === 'export'): ?>
<button type="button" class="btn btn-sm btn-outline-primary btn-export-download" data-user="<?php echo $r->user_id; ?>"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.exportUserData&format=json'); ?>"
data-token="<?php echo $token; ?>">
<span class="icon-download"></span> Download
</button>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
<!-- Retention Policies -->
<div class="col-12 col-xl-4">
<div class="card mb-4">
<div class="card-header"><strong><span class="icon-clock"></span> Retention Policies</strong></div>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr><th>Type</th><th>Days</th><th>Action</th><th>Active</th></tr></thead>
<tbody>
<?php foreach ($policies as $p): ?>
<tr>
<td class="small"><?php echo $this->escape($p->content_type); ?></td>
<td><?php echo $p->retention_days; ?></td>
<td><span class="badge bg-secondary"><?php echo $p->action; ?></span></td>
<td><?php echo (int) $p->enabled ? '<span class="text-success">Yes</span>' : '<span class="text-muted">No</span>'; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Process request buttons
document.querySelectorAll('.btn-privacy-action').forEach(function(btn) {
btn.addEventListener('click', function() {
var el = this;
var action = el.dataset.action;
if (!confirm(action === 'approve' ? 'Approve and process this data request?' : 'Deny this request?')) return;
el.disabled = true;
var fd = new FormData();
fd.append('request_id', el.dataset.id);
fd.append('action', action);
fd.append(el.dataset.token, '1');
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) { Joomla.renderMessages({message:[d.message]}); location.reload(); }
else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; }
});
});
});
// Create new request
var form = document.getElementById('formNewRequest');
if (form) {
form.addEventListener('submit', function(e) {
e.preventDefault();
var btn = document.getElementById('btnCreateRequest');
var userId = document.getElementById('req_user_id').value;
var type = document.getElementById('req_type').value;
var auto = document.getElementById('req_auto').value;
if (!userId) { Joomla.renderMessages({warning:['Please select a user.']}); return; }
btn.disabled = true;
var fd = new FormData();
fd.append('user_id', userId);
fd.append('type', type);
fd.append('action', auto === '1' ? 'approve' : 'create');
fd.append(btn.dataset.token, '1');
fetch(btn.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) { Joomla.renderMessages({message:[d.message || 'Request created.']}); location.reload(); }
else { Joomla.renderMessages({error:[d.message || 'Failed.']}); btn.disabled = false; }
})
.catch(function(){ btn.disabled = false; });
});
}
// Export download
document.querySelectorAll('.btn-export-download').forEach(function(btn) {
btn.addEventListener('click', function() {
var el = this;
var fd = new FormData();
fd.append('user_id', el.dataset.user);
fd.append(el.dataset.token, '1');
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success && d.data) {
var blob = new Blob([JSON.stringify(d.data, null, 2)], {type:'application/json'});
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'user-data-export-' + el.dataset.user + '.json';
a.click();
} else {
Joomla.renderMessages({error:[d.message || 'Export failed']});
}
});
});
});
});
</script>
@@ -0,0 +1,198 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$t = $this->ticket;
$canned = $this->cannedResponses;
$token = Session::getFormToken();
$statusBadge = [
'open' => 'bg-primary', 'in_progress' => 'bg-info',
'waiting' => 'bg-warning text-dark', 'resolved' => 'bg-success', 'closed' => 'bg-secondary',
];
$priorityBadge = [
'low' => 'bg-secondary', 'normal' => 'bg-primary', 'high' => 'bg-warning text-dark', 'urgent' => 'bg-danger',
];
?>
<div id="mokowaas-ticket" class="row">
<!-- Left: conversation thread -->
<div class="col-12 col-xl-8">
<!-- Original ticket -->
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<strong><?php echo $this->escape($t->created_by_name); ?></strong>
<small class="text-muted ms-2"><?php echo HTMLHelper::_('date', $t->created, 'M d, Y H:i'); ?></small>
</div>
<span class="badge bg-dark">Original</span>
</div>
<div class="card-body"><?php echo nl2br($this->escape($t->body)); ?></div>
</div>
<!-- Replies -->
<?php foreach ($t->replies as $reply): ?>
<div class="card mb-3 <?php echo $reply->is_internal ? 'border-warning' : ''; ?>">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<strong><?php echo $this->escape($reply->user_name ?? 'System'); ?></strong>
<small class="text-muted ms-2"><?php echo HTMLHelper::_('date', $reply->created, 'M d, Y H:i'); ?></small>
</div>
<?php if ($reply->is_internal): ?>
<span class="badge bg-warning text-dark">Internal Note</span>
<?php endif; ?>
</div>
<div class="card-body"><?php echo nl2br($this->escape($reply->body)); ?></div>
</div>
<?php endforeach; ?>
<!-- Reply form -->
<div class="card mb-3">
<div class="card-header"><strong>Reply</strong></div>
<div class="card-body">
<?php if (!empty($canned)): ?>
<div class="mb-2">
<select class="form-select form-select-sm" id="canned-select">
<option value="">Insert canned response...</option>
<?php foreach ($canned as $c): ?>
<option value="<?php echo $this->escape($c->body); ?>"><?php echo $this->escape($c->title); ?></option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
<textarea id="reply-body" class="form-control mb-2" rows="5" placeholder="Type your reply..."></textarea>
<div class="d-flex gap-2">
<button type="button" class="btn btn-primary" id="btn-reply"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.addTicketReply&format=json'); ?>"
data-ticket="<?php echo $t->id; ?>" data-token="<?php echo $token; ?>">
<span class="icon-reply"></span> Send Reply
</button>
<button type="button" class="btn btn-outline-warning" id="btn-internal"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.addTicketReply&format=json'); ?>"
data-ticket="<?php echo $t->id; ?>" data-token="<?php echo $token; ?>" data-internal="1">
<span class="icon-eye-slash"></span> Internal Note
</button>
</div>
</div>
</div>
</div>
<!-- Right: ticket metadata -->
<div class="col-12 col-xl-4">
<div class="card mb-3">
<div class="card-header"><strong>Details</strong></div>
<div class="card-body">
<table class="table table-sm mb-0">
<tr><td class="text-muted">Status</td><td><span class="badge <?php echo $statusBadge[$t->status] ?? ''; ?>"><?php echo ucwords(str_replace('_', ' ', $t->status)); ?></span></td></tr>
<tr><td class="text-muted">Priority</td><td><span class="badge <?php echo $priorityBadge[$t->priority] ?? ''; ?>"><?php echo ucfirst($t->priority); ?></span></td></tr>
<tr><td class="text-muted">Category</td><td><?php echo $this->escape($t->category_title ?? '—'); ?></td></tr>
<tr><td class="text-muted">Created By</td><td><?php echo $this->escape($t->created_by_name); ?><br><small><?php echo $this->escape($t->created_by_email ?? ''); ?></small></td></tr>
<tr><td class="text-muted">Assigned To</td><td><?php echo $this->escape($t->assigned_to_name ?? 'Unassigned'); ?></td></tr>
<tr><td class="text-muted">Created</td><td><?php echo HTMLHelper::_('date', $t->created, 'M d, Y H:i'); ?></td></tr>
<?php if ($t->resolved): ?><tr><td class="text-muted">Resolved</td><td><?php echo HTMLHelper::_('date', $t->resolved, 'M d, Y H:i'); ?></td></tr><?php endif; ?>
<?php if ($t->closed): ?><tr><td class="text-muted">Closed</td><td><?php echo HTMLHelper::_('date', $t->closed, 'M d, Y H:i'); ?></td></tr><?php endif; ?>
<tr><td class="text-muted">Replies</td><td><?php echo $t->reply_count; ?></td></tr>
</table>
</div>
</div>
<!-- SLA -->
<?php if ($t->sla_response_due || $t->sla_resolution_due): ?>
<div class="card mb-3">
<div class="card-header"><strong>SLA</strong></div>
<div class="card-body">
<?php if ($t->sla_response_due): ?>
<div class="mb-2">
<small class="text-muted">Response Due</small><br>
<?php
$responseOverdue = !$t->sla_responded && strtotime($t->sla_response_due) < time();
?>
<span class="<?php echo $t->sla_responded ? 'text-success' : ($responseOverdue ? 'text-danger fw-bold' : ''); ?>">
<?php echo $t->sla_responded ? 'Responded' : HTMLHelper::_('date', $t->sla_response_due, 'M d H:i'); ?>
<?php echo $responseOverdue ? ' OVERDUE' : ''; ?>
</span>
</div>
<?php endif; ?>
<?php if ($t->sla_resolution_due): ?>
<div>
<small class="text-muted">Resolution Due</small><br>
<?php
$resolutionOverdue = !\in_array($t->status, ['resolved','closed']) && strtotime($t->sla_resolution_due) < time();
?>
<span class="<?php echo \in_array($t->status, ['resolved','closed']) ? 'text-success' : ($resolutionOverdue ? 'text-danger fw-bold' : ''); ?>">
<?php echo \in_array($t->status, ['resolved','closed']) ? 'Met' : HTMLHelper::_('date', $t->sla_resolution_due, 'M d H:i'); ?>
<?php echo $resolutionOverdue ? ' OVERDUE' : ''; ?>
</span>
</div>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<!-- Status actions -->
<div class="card mb-3">
<div class="card-header"><strong>Actions</strong></div>
<div class="card-body d-grid gap-2">
<?php foreach (['open' => 'Reopen', 'in_progress' => 'In Progress', 'waiting' => 'Waiting', 'resolved' => 'Resolve', 'closed' => 'Close'] as $s => $label): ?>
<?php if ($s !== $t->status): ?>
<button type="button" class="btn btn-sm btn-outline-<?php echo $s === 'closed' ? 'danger' : ($s === 'resolved' ? 'success' : 'secondary'); ?> btn-status"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.updateTicketStatus&format=json'); ?>"
data-ticket="<?php echo $t->id; ?>" data-status="<?php echo $s; ?>" data-token="<?php echo $token; ?>">
<?php echo $label; ?>
</button>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Canned response insert
var cannedSel = document.getElementById('canned-select');
if (cannedSel) {
cannedSel.addEventListener('change', function() {
if (this.value) { document.getElementById('reply-body').value = this.value; this.selectedIndex = 0; }
});
}
// Reply buttons
document.querySelectorAll('#btn-reply, #btn-internal').forEach(function(btn) {
btn.addEventListener('click', function() {
var body = document.getElementById('reply-body').value.trim();
if (!body) return;
var el = this;
el.disabled = true;
var fd = new FormData();
fd.append('ticket_id', el.dataset.ticket);
fd.append('body', body);
fd.append('is_internal', el.dataset.internal || '0');
fd.append(el.dataset.token, '1');
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){ if(d.success) location.reload(); else Joomla.renderMessages({error:[d.message]}); })
.finally(function(){ el.disabled = false; });
});
});
// Status buttons
document.querySelectorAll('.btn-status').forEach(function(btn) {
btn.addEventListener('click', function() {
var el = this;
el.disabled = true;
var fd = new FormData();
fd.append('ticket_id', el.dataset.ticket);
fd.append('status', el.dataset.status);
fd.append(el.dataset.token, '1');
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){ if(d.success) location.reload(); else Joomla.renderMessages({error:[d.message]}); })
.finally(function(){ el.disabled = false; });
});
});
});
</script>
@@ -0,0 +1,291 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$tickets = $this->tickets;
$categories = $this->categories;
$counts = $this->statusCounts;
$overdue = $this->overdue;
$atsAvailable = $this->atsAvailable;
$token = Session::getFormToken();
$statusBadge = [
'open' => 'bg-primary',
'in_progress' => 'bg-info',
'waiting' => 'bg-warning text-dark',
'resolved' => 'bg-success',
'closed' => 'bg-secondary',
];
$priorityBadge = [
'low' => 'bg-secondary',
'normal' => 'bg-primary',
'high' => 'bg-warning text-dark',
'urgent' => 'bg-danger',
];
?>
<div id="mokowaas-tickets">
<!-- Status summary cards -->
<div class="row g-3 mb-4">
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo $counts->open; ?></span><small class="text-muted">Open</small></div></div>
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo $counts->in_progress; ?></span><small class="text-muted">In Progress</small></div></div>
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo $counts->waiting; ?></span><small class="text-muted">Waiting</small></div></div>
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo $counts->resolved; ?></span><small class="text-muted">Resolved</small></div></div>
<div class="col"><div class="card text-center p-2"><span class="fw-bold fs-4"><?php echo $counts->closed; ?></span><small class="text-muted">Closed</small></div></div>
<?php if (\count($overdue) > 0): ?>
<div class="col"><div class="card text-center p-2 border-danger"><span class="fw-bold fs-4 text-danger"><?php echo \count($overdue); ?></span><small class="text-danger">SLA Overdue</small></div></div>
<?php endif; ?>
</div>
<!-- New ticket + filters -->
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3">
<div class="d-flex gap-2">
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newTicketModal">
<span class="icon-plus"></span> New Ticket
</button>
<?php if ($atsAvailable): ?>
<button type="button" class="btn btn-outline-info" id="btn-import-ats"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.importAts&format=json'); ?>"
data-token="<?php echo $token; ?>"
data-tickets="<?php echo $atsAvailable->tickets; ?>"
data-posts="<?php echo $atsAvailable->posts; ?>">
<span class="icon-upload"></span> Import from Akeeba (<?php echo $atsAvailable->tickets; ?> tickets, <?php echo $atsAvailable->posts; ?> posts)
</button>
<?php endif; ?>
</div>
<form method="get" class="d-flex gap-2">
<input type="hidden" name="option" value="com_mokowaas">
<input type="hidden" name="view" value="tickets">
<select name="filter_status" class="form-select form-select-sm" style="width:auto" onchange="this.form.submit()">
<option value="">All Statuses</option>
<?php foreach (['open','in_progress','waiting','resolved','closed'] as $s): ?>
<option value="<?php echo $s; ?>" <?php echo Factory::getApplication()->getInput()->getString('filter_status') === $s ? 'selected' : ''; ?>><?php echo ucwords(str_replace('_', ' ', $s)); ?></option>
<?php endforeach; ?>
</select>
<select name="filter_priority" class="form-select form-select-sm" style="width:auto" onchange="this.form.submit()">
<option value="">All Priorities</option>
<?php foreach (['low','normal','high','urgent'] as $p): ?>
<option value="<?php echo $p; ?>" <?php echo Factory::getApplication()->getInput()->getString('filter_priority') === $p ? 'selected' : ''; ?>><?php echo ucfirst($p); ?></option>
<?php endforeach; ?>
</select>
</form>
</div>
<!-- Ticket table -->
<div class="card">
<div class="table-responsive">
<table class="table table-striped table-hover mb-0">
<thead>
<tr>
<th>#</th>
<th>Subject</th>
<th>Status</th>
<th>Priority</th>
<th>Category</th>
<th>Created By</th>
<th>Assigned To</th>
<th>Created</th>
<th>SLA</th>
</tr>
</thead>
<tbody>
<?php if (empty($tickets)): ?>
<tr><td colspan="9" class="text-center text-muted py-4">No tickets found.</td></tr>
<?php else: ?>
<?php foreach ($tickets as $t): ?>
<?php
$slaClass = '';
$now = time();
if ($t->sla_response_due && !$t->sla_responded && strtotime($t->sla_response_due) < $now) $slaClass = 'table-danger';
elseif ($t->sla_resolution_due && strtotime($t->sla_resolution_due) < $now && !\in_array($t->status, ['resolved','closed'])) $slaClass = 'table-danger';
elseif ($t->sla_response_due && !$t->sla_responded && strtotime($t->sla_response_due) < $now + 3600) $slaClass = 'table-warning';
?>
<tr class="<?php echo $slaClass; ?>">
<td><a href="<?php echo Route::_('index.php?option=com_mokowaas&view=ticket&id=' . $t->id); ?>"><?php echo $t->id; ?></a></td>
<td><a href="<?php echo Route::_('index.php?option=com_mokowaas&view=ticket&id=' . $t->id); ?>"><?php echo $this->escape(mb_substr($t->subject, 0, 60)); ?></a></td>
<td><span class="badge <?php echo $statusBadge[$t->status] ?? 'bg-secondary'; ?>"><?php echo ucwords(str_replace('_', ' ', $t->status)); ?></span></td>
<td><span class="badge <?php echo $priorityBadge[$t->priority] ?? 'bg-secondary'; ?>"><?php echo ucfirst($t->priority); ?></span></td>
<td><?php echo $this->escape($t->category_title ?? '—'); ?></td>
<td><?php echo $this->escape($t->created_by_name ?? ''); ?></td>
<td><?php echo $t->assigned_to_name ? $this->escape($t->assigned_to_name) : '<em>Unassigned</em>'; ?></td>
<td class="small"><?php echo HTMLHelper::_('date', $t->created, 'M d H:i'); ?></td>
<td class="small">
<?php if ($t->sla_response_due && !$t->sla_responded): ?>
<span title="Response due"><?php echo HTMLHelper::_('date', $t->sla_response_due, 'M d H:i'); ?></span>
<?php elseif ($t->sla_resolution_due): ?>
<span title="Resolution due"><?php echo HTMLHelper::_('date', $t->sla_resolution_due, 'M d H:i'); ?></span>
<?php else: ?>—<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<!-- New Ticket Modal -->
<div class="modal fade" id="newTicketModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header"><h5 class="modal-title">New Ticket</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body">
<!-- KB Search step -->
<div id="modal-kb-step">
<label class="form-label fw-bold">What's the issue?</label>
<div class="input-group mb-3">
<input type="text" id="modal-kb-search" class="form-control" placeholder="Describe your issue to search for existing answers...">
<button type="button" class="btn btn-outline-primary" id="modal-kb-btn"><span class="icon-search"></span></button>
</div>
<div id="modal-kb-results" class="list-group mb-3 d-none"></div>
<button type="button" class="btn btn-primary" id="modal-show-form">
<span class="icon-plus"></span> Create Ticket
</button>
</div>
<!-- Ticket form step (hidden initially) -->
<form id="modal-ticket-form" class="d-none" method="post" action="<?php echo Route::_('index.php?option=com_mokowaas&task=display.createTicket&format=json'); ?>">
<div class="mb-3">
<label class="form-label">Subject</label>
<input type="text" name="subject" id="modal-subject" class="form-control" required>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">Category</label>
<select name="category_id" class="form-select">
<option value="">— Select —</option>
<?php foreach ($categories as $cat): ?>
<option value="<?php echo $cat->id; ?>"><?php echo $this->escape($cat->title); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Priority</label>
<select name="priority" class="form-select">
<option value="normal">Normal</option>
<option value="low">Low</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea name="body" class="form-control" rows="6" required></textarea>
</div>
<input type="hidden" name="<?php echo $token; ?>" value="1">
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"><span class="icon-plus"></span> Create Ticket</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
// Modal KB search
var modalSearch = document.getElementById('modal-kb-search');
var modalSearchBtn = document.getElementById('modal-kb-btn');
var modalResults = document.getElementById('modal-kb-results');
var modalShowForm = document.getElementById('modal-show-form');
var modalKbStep = document.getElementById('modal-kb-step');
var modalForm = document.getElementById('modal-ticket-form');
var modalSubject = document.getElementById('modal-subject');
function modalDoSearch() {
var q = modalSearch.value.trim();
if (q.length < 3) return;
fetch('<?php echo Route::_('index.php?option=com_mokowaas&task=display.searchKb&format=json'); ?>&q=' + encodeURIComponent(q), {
headers: {'X-Requested-With': 'XMLHttpRequest'}
}).then(function(r){return r.json()}).then(function(d) {
modalResults.textContent = '';
if (d.results && d.results.length > 0) {
d.results.forEach(function(item) {
var a = document.createElement('a');
a.href = item.url;
a.target = '_blank';
a.className = 'list-group-item list-group-item-action';
var strong = document.createElement('strong');
strong.textContent = item.title;
a.appendChild(strong);
if (item.description) {
a.appendChild(document.createElement('br'));
var small = document.createElement('small');
small.className = 'text-muted';
small.textContent = item.description;
a.appendChild(small);
}
modalResults.appendChild(a);
});
modalResults.classList.remove('d-none');
} else {
modalResults.classList.add('d-none');
}
});
}
if (modalSearchBtn) modalSearchBtn.addEventListener('click', modalDoSearch);
if (modalSearch) modalSearch.addEventListener('keydown', function(e) { if (e.key === 'Enter') { e.preventDefault(); modalDoSearch(); } });
// Show ticket form
if (modalShowForm) {
modalShowForm.addEventListener('click', function() {
modalKbStep.classList.add('d-none');
modalForm.classList.remove('d-none');
if (modalSearch.value && !modalSubject.value) modalSubject.value = modalSearch.value;
modalSubject.focus();
});
}
// Submit ticket from modal
if (modalForm) {
modalForm.addEventListener('submit', function(e) {
e.preventDefault();
var form = this;
var fd = new FormData(form);
fetch(form.action, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) { location.href = 'index.php?option=com_mokowaas&view=ticket&id=' + d.id; }
else { Joomla.renderMessages({error:[d.message]}); }
});
});
}
// Reset modal on close
document.getElementById('newTicketModal').addEventListener('hidden.bs.modal', function() {
modalKbStep.classList.remove('d-none');
modalForm.classList.add('d-none');
modalResults.classList.add('d-none');
modalSearch.value = '';
modalForm.reset();
});
// ATS Import
var atsBtn = document.getElementById('btn-import-ats');
if (atsBtn) {
atsBtn.addEventListener('click', function() {
var el = this;
if (!confirm('Import ' + el.dataset.tickets + ' tickets and ' + el.dataset.posts + ' posts from Akeeba Ticket System? Duplicates will be skipped.')) return;
el.disabled = true;
el.textContent = ' Importing...';
var fd = new FormData();
fd.append(el.dataset.token, '1');
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) { Joomla.renderMessages({message:[d.message]}); location.reload(); }
else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; el.textContent = 'Import Failed - Retry'; }
})
.catch(function(){ Joomla.renderMessages({error:['Network error']}); el.disabled = false; });
});
}
</script>
@@ -0,0 +1,212 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$logs = $this->logs;
$ruleCounts = $this->ruleCounts;
$topIps = $this->topIps;
$ruleNames = $this->ruleNames;
$total = $this->total;
$filters = $this->filters;
$token = Session::getFormToken();
$input = Factory::getApplication()->getInput();
$page = max(1, $input->getInt('page', 1));
$totalPages = max(1, ceil($total / 50));
$ruleBadge = [
'sqli' => 'bg-danger', 'xss' => 'bg-danger', 'mua' => 'bg-warning text-dark',
'rfi' => 'bg-danger', 'dfi' => 'bg-danger', 'blocked_file' => 'bg-info',
'blocked_php' => 'bg-info', 'tmpl_switch' => 'bg-secondary',
'ip_blocklist' => 'bg-dark', 'admin_secret' => 'bg-dark',
];
?>
<div id="mokowaas-waflog">
<!-- Rule distribution cards -->
<div class="d-flex flex-wrap gap-2 mb-4">
<?php foreach ($ruleCounts as $rc): ?>
<div class="card p-2 text-center" style="min-width:100px">
<span class="badge <?php echo $ruleBadge[$rc->rule] ?? 'bg-secondary'; ?> mb-1"><?php echo htmlspecialchars($rc->rule); ?></span>
<span class="fw-bold"><?php echo number_format($rc->cnt); ?></span>
</div>
<?php endforeach; ?>
<div class="card p-2 text-center" style="min-width:100px">
<span class="badge bg-primary mb-1">Total</span>
<span class="fw-bold"><?php echo number_format($total); ?></span>
</div>
</div>
<div class="row">
<!-- Main: Log table -->
<div class="col-12 col-xl-9">
<!-- Filters -->
<form method="get" class="card mb-3">
<div class="card-body">
<input type="hidden" name="option" value="com_mokowaas">
<input type="hidden" name="view" value="waflog">
<div class="row g-2">
<div class="col-md-2">
<select name="filter_rule" class="form-select form-select-sm">
<option value="">All Rules</option>
<?php foreach ($ruleNames as $r): ?>
<option value="<?php echo htmlspecialchars($r); ?>" <?php echo $filters['rule'] === $r ? 'selected' : ''; ?>><?php echo htmlspecialchars($r); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-2">
<input type="text" name="filter_ip" class="form-control form-control-sm" placeholder="IP address" value="<?php echo htmlspecialchars($filters['ip']); ?>">
</div>
<div class="col-md-2">
<input type="text" name="filter_search" class="form-control form-control-sm" placeholder="Search URI/detail" value="<?php echo htmlspecialchars($filters['search']); ?>">
</div>
<div class="col-md-2">
<input type="date" name="filter_date_from" class="form-control form-control-sm" value="<?php echo htmlspecialchars($filters['date_from']); ?>">
</div>
<div class="col-md-2">
<input type="date" name="filter_date_to" class="form-control form-control-sm" value="<?php echo htmlspecialchars($filters['date_to']); ?>">
</div>
<div class="col-md-2 d-flex gap-1">
<button type="submit" class="btn btn-sm btn-primary"><span class="icon-search"></span> Filter</button>
<a href="<?php echo Route::_('index.php?option=com_mokowaas&view=waflog'); ?>" class="btn btn-sm btn-outline-secondary">Reset</a>
</div>
</div>
</div>
</form>
<!-- Log table -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><?php echo number_format($total); ?> blocked requests</strong>
<button type="button" class="btn btn-sm btn-outline-danger" id="btn-purge"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.purgeWafLog&format=json'); ?>"
data-token="<?php echo $token; ?>">
<span class="icon-trash"></span> Purge Old Logs
</button>
</div>
<div class="table-responsive">
<table class="table table-striped table-hover table-sm mb-0">
<thead>
<tr><th>Time</th><th>IP</th><th>Rule</th><th>URI</th><th>Detail</th><th>User Agent</th><th></th></tr>
</thead>
<tbody>
<?php if (empty($logs)): ?>
<tr><td colspan="7" class="text-center text-muted py-4">No blocked requests found.</td></tr>
<?php else: ?>
<?php foreach ($logs as $log): ?>
<tr>
<td class="text-nowrap small"><?php echo HTMLHelper::_('date', $log->created, 'M d H:i:s'); ?></td>
<td><code><?php echo htmlspecialchars($log->ip); ?></code></td>
<td><span class="badge <?php echo $ruleBadge[$log->rule] ?? 'bg-secondary'; ?>"><?php echo htmlspecialchars($log->rule); ?></span></td>
<td class="small" style="max-width:250px;overflow:hidden;text-overflow:ellipsis" title="<?php echo htmlspecialchars($log->uri); ?>"><?php echo htmlspecialchars(mb_substr($log->uri, 0, 60)); ?></td>
<td class="small" style="max-width:200px;overflow:hidden;text-overflow:ellipsis"><?php echo htmlspecialchars(mb_substr($log->detail, 0, 50)); ?></td>
<td class="small" style="max-width:200px;overflow:hidden;text-overflow:ellipsis"><?php echo htmlspecialchars(mb_substr($log->user_agent, 0, 40)); ?></td>
<td>
<button type="button" class="btn btn-sm btn-outline-danger btn-ban-ip" data-ip="<?php echo htmlspecialchars($log->ip); ?>"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.banIpFromLog&format=json'); ?>"
data-token="<?php echo $token; ?>" title="Ban this IP">
<span class="icon-ban"></span>
</button>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<?php if ($totalPages > 1): ?>
<div class="card-footer d-flex justify-content-between align-items-center">
<small class="text-muted">Page <?php echo $page; ?> of <?php echo $totalPages; ?></small>
<nav>
<ul class="pagination pagination-sm mb-0">
<?php if ($page > 1): ?>
<li class="page-item"><a class="page-link" href="<?php echo Route::_('index.php?option=com_mokowaas&view=waflog&page=' . ($page - 1)); ?>">Prev</a></li>
<?php endif; ?>
<?php if ($page < $totalPages): ?>
<li class="page-item"><a class="page-link" href="<?php echo Route::_('index.php?option=com_mokowaas&view=waflog&page=' . ($page + 1)); ?>">Next</a></li>
<?php endif; ?>
</ul>
</nav>
</div>
<?php endif; ?>
</div>
</div>
<!-- Sidebar: Top IPs -->
<div class="col-12 col-xl-3">
<div class="card">
<div class="card-header"><strong>Top Blocked IPs</strong></div>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr><th>IP</th><th>Blocks</th><th>Last</th><th></th></tr></thead>
<tbody>
<?php foreach ($topIps as $tip): ?>
<tr>
<td><code class="small"><?php echo htmlspecialchars($tip->ip); ?></code></td>
<td class="fw-bold"><?php echo $tip->cnt; ?></td>
<td class="small text-nowrap"><?php echo HTMLHelper::_('date', $tip->last_seen, 'M d'); ?></td>
<td>
<button type="button" class="btn btn-sm btn-outline-danger btn-ban-ip" data-ip="<?php echo htmlspecialchars($tip->ip); ?>"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.banIpFromLog&format=json'); ?>"
data-token="<?php echo $token; ?>" title="Ban">
<span class="icon-ban"></span>
</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var token = '<?php echo $token; ?>';
// Ban IP buttons
document.querySelectorAll('.btn-ban-ip').forEach(function(btn) {
btn.addEventListener('click', function() {
var el = this;
var ip = el.dataset.ip;
if (!confirm('Add ' + ip + ' to the firewall IP blocklist?')) return;
el.disabled = true;
var fd = new FormData();
fd.append('ip', ip);
fd.append(el.dataset.token, '1');
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) { Joomla.renderMessages({message:[d.message]}); el.textContent = 'Banned'; }
else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; }
});
});
});
// Purge button
var purgeBtn = document.getElementById('btn-purge');
if (purgeBtn) {
purgeBtn.addEventListener('click', function() {
var days = prompt('Delete WAF logs older than how many days?', '30');
if (!days || isNaN(days)) return;
this.disabled = true;
var fd = new FormData();
fd.append('days', days);
fd.append(this.dataset.token, '1');
fetch(this.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) { Joomla.renderMessages({message:[d.message]}); location.reload(); }
else { Joomla.renderMessages({error:[d.message]}); }
})
.finally(function(){ purgeBtn.disabled = false; });
});
}
});
</script>
@@ -109,4 +109,26 @@ document.addEventListener('DOMContentLoaded', function () {
});
});
}
// Akeeba import buttons
['btn-import-admintools', 'btn-import-ats-dash'].forEach(function(id) {
var btn = document.getElementById(id);
if (!btn) return;
btn.addEventListener('click', function() {
var el = this;
if (!confirm('Import Akeeba data into MokoWaaS? Akeeba extensions will be disabled after import.')) return;
el.disabled = true;
var origText = el.textContent;
el.textContent = ' Importing...';
var fd = new FormData();
fd.append(el.dataset.token, '1');
fetch(el.dataset.url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) { Joomla.renderMessages({message:[d.message]}); setTimeout(function(){location.reload()}, 2000); }
else { Joomla.renderMessages({error:[d.message]}); el.disabled = false; el.textContent = origText; }
})
.catch(function(){ Joomla.renderMessages({error:['Network error']}); el.disabled = false; el.textContent = origText; });
});
});
});
+33 -8
View File
@@ -8,7 +8,7 @@
DEFGROUP: Joomla.Component
INGROUP: MokoWaaS
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
VERSION: 02.32.03
VERSION: 02.34.00
PATH: /mokowaas.xml
BRIEF: Component manifest for MokoWaaS admin dashboard and REST API
-->
@@ -20,27 +20,52 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>
┌────────────────────────────────────────────────────────────────────┐
│ version_read v04.00.15 │
│ Read version — manifest.xml is canonical, falls back to README.md and Joomla XML│
└────────────────────────────────────────────────────────────────────┘
02.32.02-dev</version>
<version>02.34.08-dev</version>
<description>MokoWaaS admin dashboard and REST API. Provides a control panel for managing MokoWaaS feature plugins, site health monitoring, and remote management endpoints.</description>
<namespace path="src">Moko\Component\MokoWaaS</namespace>
<administration>
<menu img="class:cogs">MokoWaaS</menu>
<submenu>
<menu link="option=com_mokowaas" img="class:cogs">COM_MOKOWAAS_MENU_DASHBOARD</menu>
<menu link="option=com_mokowaas&amp;view=extensions" img="class:puzzle-piece">COM_MOKOWAAS_MENU_EXTENSIONS</menu>
<menu link="option=com_mokowaas&amp;view=tickets" img="class:headphones">COM_MOKOWAAS_MENU_TICKETS</menu>
<menu link="option=com_mokowaas&amp;view=htaccess" img="class:file-code">COM_MOKOWAAS_MENU_HTACCESS</menu>
<menu link="option=com_mokowaas&amp;view=privacy" img="class:lock">COM_MOKOWAAS_MENU_PRIVACY</menu>
<menu link="option=com_mokowaas&amp;view=waflog" img="class:shield-alt">COM_MOKOWAAS_MENU_WAFLOG</menu>
<menu link="option=com_mokowaas&amp;view=database" img="class:database">COM_MOKOWAAS_MENU_DATABASE</menu>
<menu link="option=com_mokowaas&amp;view=cleanup" img="class:trash">COM_MOKOWAAS_MENU_CLEANUP</menu>
<menu link="option=com_plugins&amp;filter[folder]=system&amp;filter[search]=mokowaas" img="class:power-off">COM_MOKOWAAS_MENU_PLUGINS</menu>
<menu link="option=com_installer&amp;view=update" img="class:refresh">COM_MOKOWAAS_MENU_UPDATES</menu>
<menu link="option=com_checkin" img="class:check-square">COM_MOKOWAAS_MENU_CHECKIN</menu>
<menu link="option=com_cache" img="class:bolt">COM_MOKOWAAS_MENU_CACHE</menu>
</submenu>
<files folder="admin">
<filename>access.xml</filename>
<filename>config.xml</filename>
<folder>language</folder>
<folder>services</folder>
<folder>sql</folder>
<folder>src</folder>
<folder>tmpl</folder>
</files>
<languages folder="admin/language">
<language tag="en-GB">en-GB/com_mokowaas.sys.ini</language>
</languages>
</administration>
<files folder="site">
<folder>language</folder>
<folder>services</folder>
<folder>src</folder>
<folder>tmpl</folder>
</files>
<install>
<sql><file driver="mysql" charset="utf8">admin/sql/install.mysql.sql</file></sql>
</install>
<api>
<files folder="api">
<folder>src</folder>
@@ -0,0 +1,11 @@
; MokoWaaS Customer Portal - Language Strings
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GPL-3.0-or-later
COM_MOKOWAAS_PORTAL_TITLE="Support Portal"
COM_MOKOWAAS_PORTAL_MY_TICKETS="My Support Tickets"
COM_MOKOWAAS_PORTAL_NEW_TICKET="New Ticket"
COM_MOKOWAAS_PORTAL_SUBMIT="Submit Ticket"
COM_MOKOWAAS_PORTAL_REPLY="Send Reply"
COM_MOKOWAAS_PORTAL_NO_TICKETS="You haven't submitted any support tickets yet."
COM_MOKOWAAS_PORTAL_LOGIN_REQUIRED="Please log in to access the support portal."
@@ -0,0 +1,38 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas.site
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Dispatcher\ComponentDispatcherFactoryInterface;
use Joomla\CMS\Extension\ComponentInterface;
use Joomla\CMS\Extension\Service\Provider\ComponentDispatcherFactory;
use Joomla\CMS\Extension\Service\Provider\MVCFactory;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
return new class implements ServiceProviderInterface
{
public function register(Container $container): void
{
$container->registerServiceProvider(new MVCFactory('\\Moko\\Component\\MokoWaaS'));
$container->registerServiceProvider(new ComponentDispatcherFactory('\\Moko\\Component\\MokoWaaS'));
$container->set(
ComponentInterface::class,
function (Container $container) {
$component = new \Joomla\CMS\Extension\MVCComponent(
$container->get(ComponentDispatcherFactoryInterface::class)
);
$component->setMVCFactory($container->get(MVCFactoryInterface::class));
return $component;
}
);
}
};
@@ -0,0 +1,267 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas.site
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Site\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
class DisplayController extends BaseController
{
protected $default_view = 'tickets';
public function display($cachable = false, $urlparams = [])
{
$user = Factory::getApplication()->getIdentity();
if ($user->guest)
{
Factory::getApplication()->enqueueMessage('Please log in to access the support portal.', 'warning');
Factory::getApplication()->redirect(Route::_(
'index.php?option=com_users&view=login&return=' . base64_encode('index.php?option=com_mokowaas&view=tickets'),
false
));
return;
}
return parent::display($cachable, $urlparams);
}
/**
* Submit a new ticket.
*/
public function submitTicket()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
$user = Factory::getApplication()->getIdentity();
if ($user->guest)
{
$this->jsonResponse(['success' => false, 'message' => 'Please log in.']);
return;
}
$input = Factory::getApplication()->getInput();
// Use admin TicketsModel
$model = $this->getModel('Tickets', 'Administrator');
$this->jsonResponse($model->createTicket([
'subject' => $input->getString('subject', ''),
'body' => $input->getRaw('body', ''),
'priority' => $input->getString('priority', 'normal'),
'category_id' => $input->getInt('category_id', 0),
]));
}
/**
* Submit a reply.
*/
public function submitReply()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
$user = Factory::getApplication()->getIdentity();
$input = Factory::getApplication()->getInput();
if ($user->guest)
{
$this->jsonResponse(['success' => false, 'message' => 'Please log in.']);
return;
}
$ticketId = $input->getInt('ticket_id', 0);
$model = $this->getModel('Tickets', 'Administrator');
$ticket = $model->getTicket($ticketId);
if (!$ticket)
{
$this->jsonResponse(['success' => false, 'message' => 'Ticket not found.']);
return;
}
// Customers can only reply to their own tickets; staff can reply to any
if ((int) $ticket->created_by !== $user->id && !$this->isStaff($user))
{
$this->jsonResponse(['success' => false, 'message' => 'Access denied.']);
return;
}
// Staff replies from frontend are not internal notes
$this->jsonResponse($model->addReply(
$ticketId,
$input->getRaw('body', ''),
false
));
}
/**
* Update ticket status (staff/manager only from frontend).
*/
public function updateStatus()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
$user = Factory::getApplication()->getIdentity();
if (!$this->isStaff($user))
{
$this->jsonResponse(['success' => false, 'message' => 'Access denied.']);
return;
}
$input = Factory::getApplication()->getInput();
$model = $this->getModel('Tickets', 'Administrator');
$this->jsonResponse($model->updateStatus(
$input->getInt('ticket_id', 0),
$input->getString('status', '')
));
}
/**
* Assign a ticket (manager only from frontend).
*/
public function assignTicket()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
$user = Factory::getApplication()->getIdentity();
if (!$user->authorise('mokowaas.tickets.assign', 'com_mokowaas'))
{
$this->jsonResponse(['success' => false, 'message' => 'Access denied.']);
return;
}
$input = Factory::getApplication()->getInput();
$ticketId = $input->getInt('ticket_id', 0);
$assignTo = $input->getInt('assigned_to', 0);
try
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__mokowaas_tickets'))
->set($db->quoteName('assigned_to') . ' = ' . ($assignTo ?: 'NULL'))
->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql()))
->where($db->quoteName('id') . ' = ' . $ticketId)
)->execute();
$this->jsonResponse(['success' => true, 'message' => 'Ticket assigned.']);
}
catch (\Throwable $e)
{
$this->jsonResponse(['success' => false, 'message' => $e->getMessage()]);
return;
}
}
/**
* Submit a data privacy request from frontend.
*/
public function submitDataRequest()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
$user = Factory::getApplication()->getIdentity();
if ($user->guest)
{
$this->jsonResponse(['success' => false, 'message' => 'Please log in.']);
return;
}
$type = Factory::getApplication()->getInput()->getString('type', '');
$model = new \Moko\Component\MokoWaaS\Administrator\Model\PrivacyModel();
$this->jsonResponse($model->createRequest($user->id, $type, 'Submitted via self-service portal'));
}
/**
* Check if user is support staff (can manage tickets beyond their own).
*/
private function isStaff($user): bool
{
if ($user->guest)
{
return false;
}
// Super admins always staff
if ($user->authorise('core.admin'))
{
return true;
}
// Anyone with mokowaas.tickets ACL on the component is staff
return $user->authorise('mokowaas.tickets', 'com_mokowaas');
}
/**
* Search KB articles via Smart Search (com_finder).
*/
public function searchKb()
{
$query = Factory::getApplication()->getInput()->getString('q', '');
if (strlen($query) < 3)
{
$this->jsonResponse(['results' => []]);
}
try
{
$db = Factory::getDbo();
$escaped = $db->quote('%' . $db->escape($query, true) . '%');
$results = $db->setQuery(
$db->getQuery(true)
->select([
$db->quoteName('l.link_id'),
$db->quoteName('l.title'),
$db->quoteName('l.url'),
$db->quoteName('l.description'),
])
->from($db->quoteName('#__finder_links', 'l'))
->where($db->quoteName('l.published') . ' = 1')
->where('(' . $db->quoteName('l.title') . ' LIKE ' . $escaped
. ' OR ' . $db->quoteName('l.description') . ' LIKE ' . $escaped . ')')
->order($db->quoteName('l.title') . ' ASC')
->setLimit(8)
)->loadObjectList() ?: [];
foreach ($results as $r)
{
$r->description = mb_substr(strip_tags($r->description ?? ''), 0, 150);
}
$this->jsonResponse(['results' => $results]);
}
catch (\Throwable $e)
{
$this->jsonResponse(['results' => []]);
}
}
private function jsonResponse(array $data): void
{
$app = Factory::getApplication();
$app->setHeader('Content-Type', 'application/json');
echo json_encode($data);
$app->close();
}
}
@@ -0,0 +1,68 @@
<?php
namespace Moko\Component\MokoWaaS\Site\View\Privacy;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Router\Route;
class HtmlView extends BaseHtmlView
{
protected $requests = [];
protected $consent = [];
public function display($tpl = null)
{
$user = Factory::getApplication()->getIdentity();
if ($user->guest)
{
Factory::getApplication()->redirect(Route::_(
'index.php?option=com_users&view=login&return=' . base64_encode('index.php?option=com_mokowaas&view=privacy'),
false
));
return;
}
$db = Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
// Get user's data requests
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_data_requests'))
->where($db->quoteName('user_id') . ' = ' . (int) $user->id)
->order($db->quoteName('created') . ' DESC');
try
{
$db->setQuery($query);
$this->requests = $db->loadObjectList() ?: [];
}
catch (\Throwable $e)
{
$this->requests = [];
}
// Get consent history
try
{
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_consent_log'))
->where($db->quoteName('user_id') . ' = ' . (int) $user->id)
->order($db->quoteName('created') . ' DESC')
->setLimit(20)
);
$this->consent = $db->loadObjectList() ?: [];
}
catch (\Throwable $e)
{
$this->consent = [];
}
parent::display($tpl);
}
}
@@ -0,0 +1,84 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas.site
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Site\View\Ticket;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Router\Route;
class HtmlView extends BaseHtmlView
{
protected $ticket;
protected $isStaff = false;
protected $canAssign = false;
public function display($tpl = null)
{
$db = Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
$user = Factory::getApplication()->getIdentity();
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$this->isStaff = $user->authorise('core.admin') || $user->authorise('mokowaas.tickets', 'com_mokowaas');
$this->canAssign = $user->authorise('core.admin') || $user->authorise('mokowaas.tickets.assign', 'com_mokowaas');
// Get ticket — staff see any, customers see only their own
$query = $db->getQuery(true)
->select([
$db->quoteName('t') . '.*',
$db->quoteName('c.title', 'category_title'),
$db->quoteName('u.name', 'created_by_name'),
$db->quoteName('u.email', 'created_by_email'),
$db->quoteName('a.name', 'assigned_to_name'),
])
->from($db->quoteName('#__mokowaas_tickets', 't'))
->leftJoin($db->quoteName('#__mokowaas_ticket_categories', 'c') . ' ON c.id = t.category_id')
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to')
->where($db->quoteName('t.id') . ' = ' . $id);
if (!$this->isStaff)
{
$query->where($db->quoteName('t.created_by') . ' = ' . (int) $user->id);
}
$db->setQuery($query);
$this->ticket = $db->loadObject();
if (!$this->ticket)
{
Factory::getApplication()->enqueueMessage('Ticket not found.', 'error');
Factory::getApplication()->redirect(Route::_('index.php?option=com_mokowaas&view=tickets', false));
return;
}
// Load replies — staff see internal notes, customers don't
$query = $db->getQuery(true)
->select([
$db->quoteName('r') . '.*',
$db->quoteName('u.name', 'user_name'),
])
->from($db->quoteName('#__mokowaas_ticket_replies', 'r'))
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = r.user_id')
->where($db->quoteName('r.ticket_id') . ' = ' . $id);
if (!$this->isStaff)
{
$query->where($db->quoteName('r.is_internal') . ' = 0');
}
$query->order($db->quoteName('r.created') . ' ASC');
$db->setQuery($query);
$this->ticket->replies = $db->loadObjectList() ?: [];
parent::display($tpl);
}
}
@@ -0,0 +1,75 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas.site
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Site\View\Tickets;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
class HtmlView extends BaseHtmlView
{
protected $tickets = [];
protected $categories = [];
protected $isStaff = false;
public function display($tpl = null)
{
$db = Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
$user = Factory::getApplication()->getIdentity();
$this->isStaff = $user->authorise('core.admin')
|| $user->authorise('mokowaas.tickets', 'com_mokowaas');
// Staff see all tickets, customers see their own
$query = $db->getQuery(true)
->select([
$db->quoteName('t.id'),
$db->quoteName('t.subject'),
$db->quoteName('t.status'),
$db->quoteName('t.priority'),
$db->quoteName('t.created'),
$db->quoteName('t.assigned_to'),
$db->quoteName('c.title', 'category_title'),
$db->quoteName('u.name', 'created_by_name'),
$db->quoteName('a.name', 'assigned_to_name'),
])
->from($db->quoteName('#__mokowaas_tickets', 't'))
->leftJoin($db->quoteName('#__mokowaas_ticket_categories', 'c') . ' ON c.id = t.category_id')
->leftJoin($db->quoteName('#__users', 'u') . ' ON u.id = t.created_by')
->leftJoin($db->quoteName('#__users', 'a') . ' ON a.id = t.assigned_to');
if (!$this->isStaff)
{
$query->where($db->quoteName('t.created_by') . ' = ' . (int) $user->id);
}
$filterStatus = Factory::getApplication()->getInput()->getString('filter_status', '');
if ($filterStatus)
{
$query->where($db->quoteName('t.status') . ' = ' . $db->quote($filterStatus));
}
$query->order($db->quoteName('t.created') . ' DESC')->setLimit(50);
$db->setQuery($query);
$this->tickets = $db->loadObjectList() ?: [];
// Categories for new ticket form
$query = $db->getQuery(true)
->select([$db->quoteName('id'), $db->quoteName('title')])
->from($db->quoteName('#__mokowaas_ticket_categories'))
->where($db->quoteName('published') . ' = 1')
->order($db->quoteName('ordering') . ' ASC');
$db->setQuery($query);
$this->categories = $db->loadObjectList() ?: [];
parent::display($tpl);
}
}
@@ -0,0 +1,114 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$user = Factory::getApplication()->getIdentity();
$requests = $this->requests;
$consent = $this->consent;
$token = Session::getFormToken();
$statusLabel = ['pending' => 'Pending', 'processing' => 'Processing', 'completed' => 'Completed', 'denied' => 'Denied'];
$statusClass = ['pending' => 'warning', 'processing' => 'info', 'completed' => 'success', 'denied' => 'secondary'];
?>
<div class="mokowaas-portal">
<h2>My Privacy &amp; Data</h2>
<p class="text-muted">Manage your personal data, download your information, or request account deletion.</p>
<!-- Action buttons -->
<div class="row g-3 mb-4">
<div class="col-12 col-md-4">
<button type="button" class="btn btn-primary w-100 py-3 btn-data-request" data-type="export">
<span class="icon-download d-block mb-1" style="font-size:1.5rem"></span>
Download My Data
</button>
</div>
<div class="col-12 col-md-4">
<button type="button" class="btn btn-outline-warning w-100 py-3 btn-data-request" data-type="anonymize">
<span class="icon-user-shield d-block mb-1" style="font-size:1.5rem"></span>
Anonymize My Account
</button>
</div>
<div class="col-12 col-md-4">
<button type="button" class="btn btn-outline-danger w-100 py-3 btn-data-request" data-type="delete">
<span class="icon-trash d-block mb-1" style="font-size:1.5rem"></span>
Delete My Account
</button>
</div>
</div>
<!-- My requests -->
<?php if (!empty($requests)): ?>
<div class="card mb-4">
<div class="card-header"><strong>My Data Requests</strong></div>
<div class="table-responsive">
<table class="table table-striped mb-0">
<thead><tr><th>Type</th><th>Status</th><th>Submitted</th><th>Processed</th></tr></thead>
<tbody>
<?php foreach ($requests as $r): ?>
<tr>
<td><?php echo ucfirst($r->type); ?></td>
<td><span class="badge bg-<?php echo $statusClass[$r->status] ?? 'secondary'; ?>"><?php echo $statusLabel[$r->status] ?? $r->status; ?></span></td>
<td><?php echo HTMLHelper::_('date', $r->created, 'M d, Y H:i'); ?></td>
<td><?php echo $r->processed ? HTMLHelper::_('date', $r->processed, 'M d, Y H:i') : '—'; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<!-- Consent history -->
<?php if (!empty($consent)): ?>
<div class="card mb-4">
<div class="card-header"><strong>Consent History</strong></div>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr><th>Category</th><th>Action</th><th>Date</th></tr></thead>
<tbody>
<?php foreach ($consent as $c): ?>
<tr>
<td><?php echo htmlspecialchars(ucwords(str_replace('_', ' ', $c->category))); ?></td>
<td><span class="badge bg-<?php echo $c->action === 'granted' ? 'success' : 'secondary'; ?>"><?php echo ucfirst($c->action); ?></span></td>
<td><?php echo HTMLHelper::_('date', $c->created, 'M d, Y H:i'); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
</div>
<script>
document.querySelectorAll('.btn-data-request').forEach(function(btn) {
btn.addEventListener('click', function() {
var type = this.dataset.type;
var messages = {
'export': 'Request a download of all your personal data?',
'anonymize': 'Request your account to be anonymized? Your name, email, and personal details will be replaced. This cannot be undone.',
'delete': 'Request permanent deletion of your account and all data? This cannot be undone.'
};
if (!confirm(messages[type] || 'Submit this request?')) return;
this.disabled = true;
var fd = new FormData();
fd.append('type', type);
fd.append('<?php echo $token; ?>', '1');
fetch('<?php echo Route::_("index.php?option=com_mokowaas&task=display.submitDataRequest&format=json"); ?>', {
method: 'POST', body: fd, headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) { alert(d.message); location.reload(); }
else { alert(d.message || 'Failed.'); }
})
.catch(function() { alert('Network error.'); })
.finally(function() { btn.disabled = false; });
});
});
</script>
@@ -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>
@@ -0,0 +1,3 @@
MOD_MOKOWAAS_CACHE="MokoWaaS Cache Cleaner"
MOD_MOKOWAAS_CACHE_DESC="One-click cache cleaner in the admin status bar. Clears all Joomla cache (site, admin, and expired)."
MOD_MOKOWAAS_CACHE_CLEAR_ALL="Clear All Cache"
@@ -0,0 +1,2 @@
MOD_MOKOWAAS_CACHE="MokoWaaS Cache Cleaner"
MOD_MOKOWAAS_CACHE_DESC="One-click cache cleaner in the admin status bar. Clears all Joomla cache (site, admin, and expired)."
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<extension type="module" client="administrator" method="upgrade">
<name>mod_mokowaas_cache</name>
<author>Moko Consulting</author>
<creationDate>2026-06-04</creationDate>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.08-dev</version>
<description>MOD_MOKOWAAS_CACHE_DESC</description>
<namespace path="src">Moko\Module\MokoWaaSCache</namespace>
<files>
<folder module="mod_mokowaas_cache">services</folder>
<folder>src</folder>
<folder>tmpl</folder>
</files>
<languages folder="language">
<language tag="en-GB">en-GB/mod_mokowaas_cache.ini</language>
<language tag="en-GB">en-GB/mod_mokowaas_cache.sys.ini</language>
</languages>
</extension>
@@ -0,0 +1,23 @@
<?php
/**
* @package MokoWaaS
* @subpackage mod_mokowaas_cache
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Extension\Service\Provider\Module;
use Joomla\CMS\Extension\Service\Provider\ModuleDispatcherFactory;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
return new class implements ServiceProviderInterface
{
public function register(Container $container): void
{
$container->registerServiceProvider(new ModuleDispatcherFactory('\\Moko\\Module\\MokoWaaSCache'));
$container->registerServiceProvider(new Module());
}
};
@@ -0,0 +1,14 @@
<?php
namespace Moko\Module\MokoWaaSCache\Administrator\Dispatcher;
defined('_JEXEC') or die;
use Joomla\CMS\Dispatcher\AbstractModuleDispatcher;
class Dispatcher extends AbstractModuleDispatcher
{
protected function getLayoutData()
{
return parent::getLayoutData();
}
}
@@ -0,0 +1,73 @@
<?php
/**
* MokoWaaS Cache Cleaner — status bar module
*
* One-click button in the admin status bar that clears all Joomla cache.
* Uses native Atum header-item markup.
*/
defined('_JEXEC') or die;
use Joomla\CMS\Session\Session;
use Joomla\CMS\Language\Text;
$token = Session::getFormToken();
$ajaxUrl = 'index.php?option=com_mokowaas&task=clearCache&format=json';
?>
<a href="#" class="header-item-content" title="<?php echo Text::_('MOD_MOKOWAAS_CACHE_CLEAR_ALL'); ?>" id="mokowaas-clear-cache">
<div class="header-item-icon">
<span class="icon-bolt" aria-hidden="true" id="mokowaas-cache-icon"></span>
</div>
<div class="header-item-text">
<?php echo Text::_('MOD_MOKOWAAS_CACHE'); ?>
</div>
</a>
<script>
document.addEventListener('DOMContentLoaded', function() {
var btn = document.getElementById('mokowaas-clear-cache');
var icon = document.getElementById('mokowaas-cache-icon');
if (!btn || !icon) return;
btn.addEventListener('click', function(e) {
e.preventDefault();
if (btn.dataset.busy) return;
btn.dataset.busy = '1';
icon.className = 'icon-spinner icon-spin';
var formData = new FormData();
formData.append('<?php echo $token; ?>', '1');
fetch('<?php echo $ajaxUrl; ?>', {
method: 'POST',
headers: {'X-Requested-With': 'XMLHttpRequest'},
body: formData
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
icon.className = 'icon-check';
icon.style.color = '#198754';
} else {
icon.className = 'icon-times';
icon.style.color = '#dc3545';
}
setTimeout(function() {
icon.className = 'icon-bolt';
icon.style.color = '';
delete btn.dataset.busy;
}, 2000);
})
.catch(function() {
icon.className = 'icon-times';
icon.style.color = '#dc3545';
setTimeout(function() {
icon.className = 'icon-bolt';
icon.style.color = '';
delete btn.dataset.busy;
}, 2000);
});
});
});
</script>
@@ -0,0 +1,29 @@
; MokoWaaS CPanel Module
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GPL-3.0-or-later
MOD_MOKOWAAS_CPANEL="MokoWaaS"
MOD_MOKOWAAS_CPANEL_DESC="Displays MokoWaaS feature plugin status and site health on the admin dashboard."
MOD_MOKOWAAS_CPANEL_FIELDSET_DISPLAY="Display Options"
MOD_MOKOWAAS_CPANEL_FIELDSET_DISPLAY_DESC="Choose which sections to show in the module."
MOD_MOKOWAAS_CPANEL_COLLAPSED_LABEL="Collapsed by Default"
MOD_MOKOWAAS_CPANEL_COLLAPSED_DESC="Start the module body collapsed. Click the header to expand."
MOD_MOKOWAAS_CPANEL_SHOW_HEALTH_LABEL="Health Status"
MOD_MOKOWAAS_CPANEL_SHOW_STATS_LABEL="Stats Cards"
MOD_MOKOWAAS_CPANEL_SHOW_STATS_DESC="Article count, user count, and pending updates."
MOD_MOKOWAAS_CPANEL_SHOW_DISK_LABEL="Disk Usage"
MOD_MOKOWAAS_CPANEL_SHOW_IP_LABEL="Current IP"
MOD_MOKOWAAS_CPANEL_SHOW_PLUGINS_LABEL="Feature Plugins"
MOD_MOKOWAAS_CPANEL_SHOW_ACTIONS_LABEL="Quick Actions"
MOD_MOKOWAAS_CPANEL_SHOW_ACTIONS_DESC="Clear cache, check updates buttons."
MOD_MOKOWAAS_CPANEL_SHOW_VERSIONS_LABEL="Joomla/PHP Versions"
MOD_MOKOWAAS_CPANEL_SHOW_VERSIONS_DESC="Show Joomla and PHP version numbers."
MOD_MOKOWAAS_CPANEL_OPEN_DASHBOARD="Control Panel"
MOD_MOKOWAAS_CPANEL_DEBUG="Debug ON"
MOD_MOKOWAAS_CPANEL_OFFLINE="Offline"
MOD_MOKOWAAS_CPANEL_HEALTH_OK="All Systems OK"
MOD_MOKOWAAS_CPANEL_HEALTH_ERROR="Database Error"
MOD_MOKOWAAS_CPANEL_PLUGINS_SUMMARY="%d of %d features enabled"
@@ -0,0 +1,3 @@
; MokoWaaS CPanel Module - System strings
MOD_MOKOWAAS_CPANEL="MokoWaaS"
MOD_MOKOWAAS_CPANEL_DESC="Displays MokoWaaS feature plugin status and site health on the admin dashboard."
@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="utf-8"?>
<extension type="module" client="administrator" method="upgrade">
<name>mod_mokowaas_cpanel</name>
<author>Moko Consulting</author>
<creationDate>2026-06-02</creationDate>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.08-dev</version>
<description>MOD_MOKOWAAS_CPANEL_DESC</description>
<namespace path="src">Moko\Module\MokoWaaSCpanel</namespace>
<files>
<folder module="mod_mokowaas_cpanel">services</folder>
<folder>src</folder>
<folder>tmpl</folder>
</files>
<languages folder="language">
<language tag="en-GB">en-GB/mod_mokowaas_cpanel.ini</language>
<language tag="en-GB">en-GB/mod_mokowaas_cpanel.sys.ini</language>
</languages>
<config>
<fields name="params">
<fieldset name="basic"
label="MOD_MOKOWAAS_CPANEL_FIELDSET_DISPLAY"
description="MOD_MOKOWAAS_CPANEL_FIELDSET_DISPLAY_DESC">
<field name="collapsed" type="radio" default="1"
label="MOD_MOKOWAAS_CPANEL_COLLAPSED_LABEL"
description="MOD_MOKOWAAS_CPANEL_COLLAPSED_DESC"
layout="joomla.form.field.radio.switcher">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="show_health" type="radio" default="1"
label="MOD_MOKOWAAS_CPANEL_SHOW_HEALTH_LABEL"
layout="joomla.form.field.radio.switcher">
<option value="0">JHIDE</option>
<option value="1">JSHOW</option>
</field>
<field name="show_stats" type="radio" default="1"
label="MOD_MOKOWAAS_CPANEL_SHOW_STATS_LABEL"
description="MOD_MOKOWAAS_CPANEL_SHOW_STATS_DESC"
layout="joomla.form.field.radio.switcher">
<option value="0">JHIDE</option>
<option value="1">JSHOW</option>
</field>
<field name="show_disk" type="radio" default="1"
label="MOD_MOKOWAAS_CPANEL_SHOW_DISK_LABEL"
layout="joomla.form.field.radio.switcher">
<option value="0">JHIDE</option>
<option value="1">JSHOW</option>
</field>
<field name="show_ip" type="radio" default="1"
label="MOD_MOKOWAAS_CPANEL_SHOW_IP_LABEL"
layout="joomla.form.field.radio.switcher">
<option value="0">JHIDE</option>
<option value="1">JSHOW</option>
</field>
<field name="show_plugins" type="radio" default="1"
label="MOD_MOKOWAAS_CPANEL_SHOW_PLUGINS_LABEL"
layout="joomla.form.field.radio.switcher">
<option value="0">JHIDE</option>
<option value="1">JSHOW</option>
</field>
<field name="show_actions" type="radio" default="1"
label="MOD_MOKOWAAS_CPANEL_SHOW_ACTIONS_LABEL"
description="MOD_MOKOWAAS_CPANEL_SHOW_ACTIONS_DESC"
layout="joomla.form.field.radio.switcher">
<option value="0">JHIDE</option>
<option value="1">JSHOW</option>
</field>
<field name="show_versions" type="radio" default="1"
label="MOD_MOKOWAAS_CPANEL_SHOW_VERSIONS_LABEL"
description="MOD_MOKOWAAS_CPANEL_SHOW_VERSIONS_DESC"
layout="joomla.form.field.radio.switcher">
<option value="0">JHIDE</option>
<option value="1">JSHOW</option>
</field>
</fieldset>
</fields>
</config>
</extension>
@@ -0,0 +1,25 @@
<?php
/**
* @package MokoWaaS
* @subpackage mod_mokowaas_cpanel
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Extension\Service\Provider\HelperFactory;
use Joomla\CMS\Extension\Service\Provider\Module;
use Joomla\CMS\Extension\Service\Provider\ModuleDispatcherFactory;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
return new class implements ServiceProviderInterface
{
public function register(Container $container): void
{
$container->registerServiceProvider(new ModuleDispatcherFactory('\\Moko\\Module\\MokoWaaSCpanel'));
$container->registerServiceProvider(new HelperFactory('\\Moko\\Module\\MokoWaaSCpanel\\Administrator\\Helper'));
$container->registerServiceProvider(new Module());
}
};
@@ -0,0 +1,52 @@
<?php
/**
* @package MokoWaaS
* @subpackage mod_mokowaas_cpanel
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Module\MokoWaaSCpanel\Administrator\Dispatcher;
defined('_JEXEC') or die;
use Joomla\CMS\Dispatcher\AbstractModuleDispatcher;
use Joomla\CMS\Factory;
use Joomla\CMS\Helper\HelperFactoryAwareInterface;
use Joomla\CMS\Helper\HelperFactoryAwareTrait;
use Joomla\Database\DatabaseInterface;
class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareInterface
{
use HelperFactoryAwareTrait;
protected function getLayoutData()
{
$data = parent::getLayoutData();
// Hide on MokoWaaS dashboard — the dashboard has its own info panels
$app = Factory::getApplication();
$option = $app->getInput()->get('option', '');
$view = $app->getInput()->get('view', '');
if ($option === 'com_mokowaas' && ($view === '' || $view === 'dashboard'))
{
$data['hidden'] = true;
return $data;
}
$db = Factory::getContainer()->get(DatabaseInterface::class);
$helper = $this->getHelperFactory()->getHelper('CpanelHelper');
$data['siteInfo'] = $helper->getSiteInfo($db);
$data['plugins'] = $helper->getFeaturePlugins($db);
$data['healthOk'] = $helper->isDatabaseOk($db);
$data['counts'] = $helper->getCounts($db);
$data['disk'] = $helper->getDiskInfo();
$data['currentIp'] = $helper->getCurrentIp();
$data['ssl'] = $helper->getSslStatus();
return $data;
}
}
@@ -0,0 +1,204 @@
<?php
/**
* @package MokoWaaS
* @subpackage mod_mokowaas_cpanel
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Module\MokoWaaSCpanel\Administrator\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Version;
use Joomla\Database\DatabaseInterface;
class CpanelHelper
{
/**
* Get basic site info for the cpanel card header.
*/
public function getSiteInfo(DatabaseInterface $db): object
{
$config = Factory::getConfig();
$query = $db->getQuery(true)
->select($db->quoteName('manifest_cache'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('pkg_mokowaas'))
->where($db->quoteName('type') . ' = ' . $db->quote('package'));
$db->setQuery($query);
$pkgCache = json_decode($db->loadResult() ?? '{}');
return (object) [
'mokowaas_version' => $pkgCache->version ?? '',
'joomla_version' => (new Version())->getShortVersion(),
'php_version' => PHP_VERSION,
'debug' => (bool) $config->get('debug'),
'offline' => (bool) $config->get('offline'),
];
}
/**
* Get MokoWaaS system feature plugins with their enabled state.
*/
public function getFeaturePlugins(DatabaseInterface $db): array
{
$query = $db->getQuery(true)
->select([
$db->quoteName('extension_id'),
$db->quoteName('name'),
$db->quoteName('element'),
$db->quoteName('enabled'),
])
->from($db->quoteName('#__extensions'))
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
->where('(' . $db->quoteName('element') . ' = ' . $db->quote('mokowaas')
. ' OR ' . $db->quoteName('element') . ' LIKE ' . $db->quote('mokowaas\\_%') . ')')
->order($db->quoteName('element') . ' ASC');
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
/**
* Quick database connectivity check.
*/
public function isDatabaseOk(DatabaseInterface $db): bool
{
try
{
$db->setQuery('SELECT 1');
$db->loadResult();
return true;
}
catch (\Throwable $e)
{
return false;
}
}
/**
* Get content and system counts.
*/
public function getCounts(DatabaseInterface $db): object
{
$counts = (object) [
'articles' => 0,
'users' => 0,
'extensions' => 0,
'updates' => 0,
'moko_updates' => 0,
];
try
{
$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from($db->quoteName('#__content')));
$counts->articles = (int) $db->loadResult();
$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from($db->quoteName('#__users')));
$counts->users = (int) $db->loadResult();
$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from($db->quoteName('#__extensions'))->where($db->quoteName('enabled') . ' = 1'));
$counts->extensions = (int) $db->loadResult();
$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from($db->quoteName('#__updates'))->where($db->quoteName('extension_id') . ' != 0'));
$counts->updates = (int) $db->loadResult();
// MokoWaaS-specific updates
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__updates', 'u'))
->join('INNER', $db->quoteName('#__extensions', 'e') . ' ON e.extension_id = u.extension_id')
->where('(' . $db->quoteName('e.element') . ' LIKE ' . $db->quote('mokowaas%')
. ' OR ' . $db->quoteName('e.element') . ' LIKE ' . $db->quote('pkg_mokowaas%')
. ' OR ' . $db->quoteName('e.element') . ' LIKE ' . $db->quote('com_mokowaas%')
. ' OR ' . $db->quoteName('e.element') . ' LIKE ' . $db->quote('mod_mokowaas%')
. ' OR ' . $db->quoteName('e.element') . ' = ' . $db->quote('mokoonyx') . ')')
);
$counts->moko_updates = (int) $db->loadResult();
}
catch (\Throwable $e)
{
// Silent
}
return $counts;
}
/**
* Get disk usage info.
*/
public function getDiskInfo(): object
{
$free = @disk_free_space(JPATH_ROOT);
$total = @disk_total_space(JPATH_ROOT);
return (object) [
'free_mb' => $free !== false ? round($free / 1048576) : null,
'total_mb' => $total !== false ? round($total / 1048576) : null,
];
}
/**
* Get the current visitor's IP address.
*/
public function getCurrentIp(): string
{
return $_SERVER['REMOTE_ADDR'] ?? '';
}
/**
* Check SSL certificate expiry (#148).
*
* @return object|null {expires, days_remaining, warning} or null if check fails
*/
public function getSslStatus(): ?object
{
try
{
$host = parse_url(\Joomla\CMS\Uri\Uri::root(), PHP_URL_HOST);
if (empty($host))
{
return null;
}
$context = stream_context_create(['ssl' => ['capture_peer_cert' => true, 'verify_peer' => false]]);
$client = @stream_socket_client('ssl://' . $host . ':443', $errno, $errstr, 5, STREAM_CLIENT_CONNECT, $context);
if (!$client)
{
return null;
}
$params = stream_context_get_params($client);
fclose($client);
$cert = openssl_x509_parse($params['options']['ssl']['peer_certificate'] ?? '');
if (empty($cert['validTo_time_t']))
{
return null;
}
$expires = $cert['validTo_time_t'];
$days = (int) floor(($expires - time()) / 86400);
return (object) [
'expires' => date('Y-m-d', $expires),
'days_remaining' => $days,
'warning' => $days <= 30,
'critical' => $days <= 7,
];
}
catch (\Throwable $e)
{
return null;
}
}
}
@@ -0,0 +1,230 @@
<?php
/**
* @package MokoWaaS
* @subpackage mod_mokowaas_cpanel
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
// Hidden when on MokoWaaS dashboard (redundant info)
if (!empty($hidden)) return;
$siteInfo = $siteInfo ?? (object) [];
$plugins = $plugins ?? [];
$healthOk = $healthOk ?? true;
$counts = $counts ?? (object) ['articles' => 0, 'users' => 0, 'extensions' => 0, 'updates' => 0];
$disk = $disk ?? (object) ['free_mb' => null, 'total_mb' => null];
$currentIp = $currentIp ?? '';
$collapsed = $params->get('collapsed', 1);
$showHealth = $params->get('show_health', 1);
$showStats = $params->get('show_stats', 1);
$showDisk = $params->get('show_disk', 1);
$showIp = $params->get('show_ip', 1);
$showPlugins = $params->get('show_plugins', 1);
$showActions = $params->get('show_actions', 1);
$showVersions = $params->get('show_versions', 1);
$token = Session::getFormToken();
$enabledCount = 0;
$totalCount = count($plugins);
foreach ($plugins as $p)
{
if ($p->enabled)
{
$enabledCount++;
}
}
$labels = [
'mokowaas' => 'Core',
'mokowaas_firewall' => 'Firewall',
'mokowaas_tenant' => 'Tenant',
'mokowaas_devtools' => 'DevTools',
'mokowaas_monitor' => 'Monitor',
];
$diskPct = ($disk->total_mb && $disk->total_mb > 0)
? round((($disk->total_mb - ($disk->free_mb ?? 0)) / $disk->total_mb) * 100)
: null;
$diskColor = ($diskPct !== null && $diskPct > 90) ? 'bg-danger' : (($diskPct !== null && $diskPct > 75) ? 'bg-warning' : 'bg-success');
?>
<div class="mod-mokowaas-cpanel card p-3 mb-4">
<!-- Header row -->
<div class="d-flex align-items-center gap-2">
<button type="button" class="btn btn-sm btn-link p-0 text-muted" data-bs-toggle="collapse" data-bs-target="#mokowaas-cpanel-body" aria-expanded="<?php echo $collapsed ? 'false' : 'true'; ?>" aria-controls="mokowaas-cpanel-body" id="mokowaas-cpanel-toggle" style="font-size:1rem;line-height:1;width:1.5rem;">
<span class="fa-solid fa-caret-<?php echo $collapsed ? 'right' : 'down'; ?>" aria-hidden="true" id="mokowaas-cpanel-caret"></span>
</button>
<span class="icon-shield-alt" aria-hidden="true" style="font-size:1.25rem;color:#1a2744"></span>
<strong>MokoWaaS</strong>
<span class="badge bg-primary"><?php echo htmlspecialchars($siteInfo->mokowaas_version ?? ''); ?></span>
<?php if (!empty($siteInfo->debug)): ?>
<span class="badge bg-warning text-dark">Debug</span>
<?php endif; ?>
<?php if (!empty($siteInfo->offline)): ?>
<span class="badge bg-danger">Offline</span>
<?php endif; ?>
<?php if (($counts->moko_updates ?? 0) > 0): ?>
<a href="<?php echo Route::_('index.php?option=com_installer&view=update'); ?>" class="badge bg-info text-decoration-none" title="MokoWaaS updates available">
<span class="icon-upload" aria-hidden="true"></span> <?php echo $counts->moko_updates; ?> MokoWaaS update<?php echo $counts->moko_updates > 1 ? 's' : ''; ?>
</a>
<?php endif; ?>
<?php if ($counts->updates > 0 && $counts->updates !== ($counts->moko_updates ?? 0)): ?>
<a href="<?php echo Route::_('index.php?option=com_installer&view=update'); ?>" class="badge bg-warning text-dark text-decoration-none" title="Other updates available">
<span class="icon-upload" aria-hidden="true"></span> <?php echo $counts->updates - ($counts->moko_updates ?? 0); ?> update<?php echo ($counts->updates - ($counts->moko_updates ?? 0)) > 1 ? 's' : ''; ?>
</a>
<?php endif; ?>
<span class="ms-auto">
<a href="<?php echo Route::_('index.php?option=com_mokowaas'); ?>" class="btn btn-sm btn-primary">
<span class="icon-cogs" aria-hidden="true"></span>
<?php echo Text::_('MOD_MOKOWAAS_CPANEL_OPEN_DASHBOARD'); ?>
</a>
</span>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var target = document.getElementById('mokowaas-cpanel-body');
var caret = document.getElementById('mokowaas-cpanel-caret');
if (target && caret) {
target.addEventListener('show.bs.collapse', function() { caret.className = 'fa-solid fa-caret-down'; });
target.addEventListener('hide.bs.collapse', function() { caret.className = 'fa-solid fa-caret-right'; });
}
});
</script>
<!-- Collapsible body -->
<div class="collapse<?php echo $collapsed ? '' : ' show'; ?> mt-3" id="mokowaas-cpanel-body">
<?php if ($showHealth && $showStats): ?>
<!-- Health + stats row -->
<div class="row g-2 mb-3">
<div class="col-6 col-md-3">
<div class="border rounded p-2 text-center h-100">
<?php if ($healthOk): ?>
<span class="icon-check-circle text-success d-block" style="font-size:1.5rem"></span>
<small class="text-success fw-bold">Healthy</small>
<?php else: ?>
<span class="icon-exclamation-circle text-danger d-block" style="font-size:1.5rem"></span>
<small class="text-danger fw-bold">DB Error</small>
<?php endif; ?>
</div>
</div>
<div class="col-6 col-md-3">
<div class="border rounded p-2 text-center h-100">
<span class="fw-bold d-block" style="font-size:1.25rem"><?php echo $counts->articles; ?></span>
<small class="text-muted">Articles</small>
</div>
</div>
<div class="col-6 col-md-3">
<div class="border rounded p-2 text-center h-100">
<span class="fw-bold d-block" style="font-size:1.25rem"><?php echo $counts->users; ?></span>
<small class="text-muted">Users</small>
</div>
</div>
<div class="col-6 col-md-3">
<div class="border rounded p-2 text-center h-100">
<?php if ($counts->updates > 0): ?>
<span class="fw-bold d-block text-warning" style="font-size:1.25rem"><?php echo $counts->updates; ?></span>
<small class="text-warning">Updates</small>
<?php else: ?>
<span class="icon-check d-block text-success" style="font-size:1.25rem"></span>
<small class="text-muted">Up to date</small>
<?php endif; ?>
</div>
</div>
</div>
<!-- Info + plugins + actions (consolidated) -->
<div class="d-flex flex-wrap align-items-center gap-2">
<?php if ($showDisk && $diskPct !== null): ?>
<span class="text-muted d-inline-flex align-items-center gap-1">
<span class="icon-hdd" aria-hidden="true"></span>
<?php echo $diskPct; ?>%
<span class="progress d-inline-flex" style="width:40px;height:5px"><span class="progress-bar <?php echo $diskColor; ?>" style="width:<?php echo $diskPct; ?>%"></span></span>
<?php echo number_format(($disk->free_mb ?? 0) / 1024, 1); ?>G free
</span>
<?php endif; ?>
<?php if ($showIp && $currentIp): ?>
<span class="text-muted"><span class="icon-globe" aria-hidden="true"></span> <code><?php echo htmlspecialchars($currentIp); ?></code></span>
<?php endif; ?>
<?php $ssl = $ssl ?? null; if ($ssl): ?>
<span class="badge bg-<?php echo $ssl->critical ? 'danger' : ($ssl->warning ? 'warning text-dark' : 'success'); ?>" title="SSL expires <?php echo $ssl->expires; ?>">
<span class="icon-lock" aria-hidden="true"></span>
SSL <?php echo $ssl->days_remaining; ?>d
</span>
<?php endif; ?>
<?php if ($showVersions): ?>
<span class="text-muted">J<?php echo htmlspecialchars($siteInfo->joomla_version ?? ''); ?> / PHP <?php echo htmlspecialchars($siteInfo->php_version ?? ''); ?></span>
<?php endif; ?>
<?php if ($showPlugins && !empty($plugins)): ?>
<span class="border-start ps-2 ms-1"></span>
<?php foreach ($plugins as $p): ?>
<?php
$label = $labels[$p->element] ?? $p->element;
$badge = $p->enabled ? 'bg-success' : 'bg-secondary';
$icon = $p->enabled ? 'icon-check' : 'icon-times';
$configUrl = Route::_('index.php?option=com_plugins&task=plugin.edit&extension_id=' . (int) $p->extension_id);
?>
<a href="<?php echo $configUrl; ?>" class="badge <?php echo $badge; ?> text-decoration-none" title="<?php echo htmlspecialchars($p->name); ?>">
<span class="<?php echo $icon; ?>" aria-hidden="true"></span> <?php echo htmlspecialchars($label); ?>
</a>
<?php endforeach; ?>
<?php endif; ?>
<?php if ($showActions): ?>
<span class="border-start ps-2 ms-1"></span>
<button type="button" class="btn btn-sm btn-outline-secondary" id="mokowaas-cpanel-cache"
data-url="<?php echo Route::_('index.php?option=com_mokowaas&task=display.clearCache&format=json'); ?>"
data-token="<?php echo $token; ?>">
<span class="icon-trash" aria-hidden="true"></span> Clear Cache
</button>
<a href="<?php echo Route::_('index.php?option=com_installer&view=update'); ?>" class="btn btn-sm btn-outline-secondary">
<span class="icon-refresh" aria-hidden="true"></span> Check Updates
</a>
<?php if ($counts->updates > 0): ?>
<a href="<?php echo Route::_('index.php?option=com_installer&view=update'); ?>" class="badge bg-warning text-dark text-decoration-none">
<span class="icon-upload" aria-hidden="true"></span> <?php echo $counts->updates; ?> update<?php echo $counts->updates > 1 ? 's' : ''; ?>
</a>
<?php endif; ?>
<?php endif; ?>
</div>
<?php endif; ?>
</div><!-- /.collapse -->
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var btn = document.getElementById('mokowaas-cpanel-cache');
if (!btn) return;
btn.addEventListener('click', function() {
var el = this;
var url = el.dataset.url;
var token = el.dataset.token;
el.disabled = true;
var icon = el.querySelector('span');
var origClass = icon ? icon.className : '';
if (icon) icon.className = 'icon-spinner icon-spin';
var fd = new FormData();
fd.append(token, '1');
fetch(url, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) Joomla.renderMessages({message:['Cache cleared.']});
else Joomla.renderMessages({error:[d.message||'Failed']});
})
.catch(function(){Joomla.renderMessages({error:['Network error']})})
.finally(function(){
el.disabled = false;
if (icon) icon.className = origClass;
});
});
});
</script>
@@ -0,0 +1 @@
MOD_MOKOWAAS_MENU="MokoWaaS Admin Menu"
@@ -0,0 +1,2 @@
MOD_MOKOWAAS_MENU="MokoWaaS Admin Menu"
MOD_MOKOWAAS_MENU_DESC="Dedicated MokoWaaS section in the admin sidebar menu."
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<extension type="module" client="administrator" method="upgrade">
<name>mod_mokowaas_menu</name>
<author>Moko Consulting</author>
<creationDate>2026-06-04</creationDate>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.34.08-dev</version>
<description>MokoWaaS admin sidebar menu — renders a dedicated MokoWaaS section in the admin menu before Joomla's default menu.</description>
<namespace path="src">Moko\Module\MokoWaaSMenu</namespace>
<files>
<folder module="mod_mokowaas_menu">services</folder>
<folder>src</folder>
<folder>tmpl</folder>
</files>
<languages folder="language">
<language tag="en-GB">en-GB/mod_mokowaas_menu.ini</language>
<language tag="en-GB">en-GB/mod_mokowaas_menu.sys.ini</language>
</languages>
</extension>
@@ -0,0 +1,18 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Extension\Service\Provider\HelperFactory;
use Joomla\CMS\Extension\Service\Provider\Module;
use Joomla\CMS\Extension\Service\Provider\ModuleDispatcherFactory;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
return new class implements ServiceProviderInterface
{
public function register(Container $container): void
{
$container->registerServiceProvider(new ModuleDispatcherFactory('\\Moko\\Module\\MokoWaaSMenu'));
$container->registerServiceProvider(new HelperFactory('\\Moko\\Module\\MokoWaaSMenu\\Administrator\\Helper'));
$container->registerServiceProvider(new Module());
}
};
@@ -0,0 +1,14 @@
<?php
namespace Moko\Module\MokoWaaSMenu\Administrator\Dispatcher;
defined('_JEXEC') or die;
use Joomla\CMS\Dispatcher\AbstractModuleDispatcher;
class Dispatcher extends AbstractModuleDispatcher
{
protected function getLayoutData()
{
return parent::getLayoutData();
}
}
@@ -0,0 +1,191 @@
<?php
/**
* MokoWaaS Admin Sidebar Menu
*
* Renders MokoWaaS static views first, then auto-discovers installed
* Moko components from #__menu and renders their submenu items as
* nested MetisMenu collapsible sections.
*/
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
$app = Factory::getApplication();
$currentOption = $app->getInput()->get('option', '');
$currentView = $app->getInput()->get('view', '');
// ── Static MokoWaaS views ────────────────────────────────────────────
$mokowaasItems = [
['icon' => 'icon-cogs', 'title' => 'Dashboard', 'link' => 'index.php?option=com_mokowaas'],
['icon' => 'fa-solid fa-handshake-angle', 'title' => 'Helpdesk', 'link' => 'index.php?option=com_mokowaas&view=tickets'],
['icon' => 'icon-puzzle-piece', 'title' => 'Extensions', 'link' => 'index.php?option=com_mokowaas&view=extensions'],
['icon' => 'fa-solid fa-file-code', 'title' => '.htaccess Maker', 'link' => 'index.php?option=com_mokowaas&view=htaccess'],
['icon' => 'icon-lock', 'title' => 'Privacy Guard', 'link' => 'index.php?option=com_mokowaas&view=privacy'],
['icon' => 'icon-shield-alt', 'title' => 'WAF Log', 'link' => 'index.php?option=com_mokowaas&view=waflog'],
['icon' => 'icon-database', 'title' => 'Database Tools', 'link' => 'index.php?option=com_mokowaas&view=database'],
['icon' => 'icon-trash', 'title' => 'Cache Cleanup', 'link' => 'index.php?option=com_mokowaas&view=cleanup'],
['icon' => 'icon-power-off', 'title' => 'Feature Plugins', 'link' => 'index.php?option=com_plugins&filter[folder]=system&filter[search]=mokowaas'],
];
// ── Auto-discover Moko component menus from #__menu ──────────────────
$mokoComponents = [];
try
{
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
// Find all Moko component menu items (exclude com_mokowaas — handled above)
$db->setQuery(
"SELECT m.id, m.title, m.link, m.level, m.parent_id, m.img, e.element"
. " FROM " . $db->quoteName('#__menu') . " m"
. " LEFT JOIN " . $db->quoteName('#__extensions') . " e ON m.component_id = e.extension_id"
. " WHERE m.client_id = 1 AND m.level >= 1 AND m.published = 1"
. " AND e.element LIKE 'com_moko%'"
. " AND e.element != 'com_mokowaas'"
. " AND e.enabled = 1"
. " ORDER BY e.element, m.level, m.lft"
);
$menuItems = $db->loadObjectList() ?: [];
// Load sys.ini language files for discovered components
$lang = Factory::getLanguage();
$loadedLangs = [];
foreach ($menuItems as $m)
{
if (!isset($loadedLangs[$m->element]))
{
$lang->load($m->element . '.sys', JPATH_ADMINISTRATOR);
$lang->load($m->element, JPATH_ADMINISTRATOR);
$loadedLangs[$m->element] = true;
}
}
// Group: level 1 = component parent, level 2 = children
foreach ($menuItems as $m)
{
if ((int) $m->level === 1)
{
$mokoComponents[$m->element] = [
'id' => $m->id,
'title' => Text::_($m->title),
'link' => $m->link,
'icon' => str_replace('class:', 'icon-', $m->img ?: 'class:puzzle-piece'),
'element' => $m->element,
'children' => [],
];
}
elseif ((int) $m->level === 2 && isset($mokoComponents[$m->element]))
{
$mokoComponents[$m->element]['children'][] = [
'title' => Text::_($m->title),
'link' => $m->link,
'icon' => str_replace('class:', 'icon-', $m->img ?: 'class:cog'),
];
}
}
}
catch (\Throwable $e)
{
// Silent — menu works without auto-discovered components
}
// ── Determine active state ───────────────────────────────────────────
$mokowaasActive = ($currentOption === 'com_mokowaas');
$anyMokoActive = $mokowaasActive;
foreach ($mokoComponents as $comp)
{
$parsed = [];
parse_str(parse_url($comp['link'], PHP_URL_QUERY) ?? '', $parsed);
if (($parsed['option'] ?? '') === $currentOption)
{
$anyMokoActive = true;
}
}
$topClass = 'item parent item-level-1' . ($anyMokoActive ? ' mm-active' : '');
$topCollapse = 'collapse-level-1 mm-collapse' . ($anyMokoActive ? ' mm-show' : '');
?>
<style>
.sidebar-wrapper .item-level-1 > a { padding-inline-start: 1.5rem; }
.sidebar-wrapper .mokowaas-menu-item > a { padding-inline-start: 1rem; }
.sidebar-wrapper .mokowaas-menu-child > a { padding-inline-start: 1.5rem; }
</style>
<ul class="nav flex-column main-nav">
<li class="<?php echo $topClass; ?>">
<a class="has-arrow" href="#" aria-label="MokoWaaS">
<span class="icon-shield-alt" aria-hidden="true"></span>
<span class="sidebar-item-title">MokoWaaS</span>
</a>
<ul class="<?php echo $topCollapse; ?>" style="padding-inline-start:0.5rem;">
<?php // ── MokoWaaS static items ── ?>
<?php foreach ($mokowaasItems as $item): ?>
<?php
$active = false;
$parsed = [];
parse_str(parse_url($item['link'], PHP_URL_QUERY) ?? '', $parsed);
if (($parsed['option'] ?? '') === $currentOption)
{
$active = empty($parsed['view'])
? ($currentView === '' || $currentView === 'dashboard')
: ($currentView === ($parsed['view'] ?? ''));
}
$liClass = 'item mokowaas-menu-item' . ($active ? ' mm-active' : '');
$aClass = 'no-dropdown' . ($active ? ' mm-active' : '');
?>
<li class="<?php echo $liClass; ?>">
<a class="<?php echo $aClass; ?>" href="<?php echo Route::_($item['link']); ?>"<?php echo $active ? ' aria-current="page"' : ''; ?>>
<span class="<?php echo $item['icon']; ?>" aria-hidden="true" style="display:inline-block!important;width:1.25em;text-align:center;margin-inline-end:0.4em;"></span>
<span class="sidebar-item-title"><?php echo $item['title']; ?></span>
</a>
</li>
<?php endforeach; ?>
<?php // ── Auto-discovered Moko components with submenus ── ?>
<?php foreach ($mokoComponents as $comp): ?>
<?php
$compParsed = [];
parse_str(parse_url($comp['link'], PHP_URL_QUERY) ?? '', $compParsed);
$compActive = ($compParsed['option'] ?? '') === $currentOption;
$hasChildren = !empty($comp['children']);
$compLiClass = 'item mokowaas-menu-item' . ($hasChildren ? ' parent' : '') . ($compActive ? ' mm-active' : '');
$compAClass = ($hasChildren ? 'has-arrow' : 'no-dropdown') . ($compActive ? ' mm-active' : '');
$childCollapse = 'collapse-level-2 mm-collapse' . ($compActive ? ' mm-show' : '');
?>
<li class="<?php echo $compLiClass; ?>">
<a class="<?php echo $compAClass; ?>" href="<?php echo $hasChildren ? '#' : Route::_($comp['link']); ?>"<?php echo ($compActive && !$hasChildren) ? ' aria-current="page"' : ''; ?>>
<span class="<?php echo $comp['icon']; ?>" aria-hidden="true" style="display:inline-block!important;width:1.25em;text-align:center;margin-inline-end:0.4em;"></span>
<span class="sidebar-item-title"><?php echo $comp['title']; ?></span>
</a>
<?php if ($hasChildren): ?>
<ul class="<?php echo $childCollapse; ?>" style="padding-inline-start:0.75rem;">
<?php foreach ($comp['children'] as $child): ?>
<?php
$childParsed = [];
parse_str(parse_url($child['link'], PHP_URL_QUERY) ?? '', $childParsed);
$childActive = ($childParsed['option'] ?? '') === $currentOption
&& ($childParsed['view'] ?? '') === $currentView;
$childLiClass = 'item mokowaas-menu-child' . ($childActive ? ' mm-active' : '');
$childAClass = 'no-dropdown' . ($childActive ? ' mm-active' : '');
?>
<li class="<?php echo $childLiClass; ?>">
<a class="<?php echo $childAClass; ?>" href="<?php echo Route::_($child['link']); ?>"<?php echo $childActive ? ' aria-current="page"' : ''; ?>>
<span class="<?php echo $child['icon']; ?>" aria-hidden="true" style="display:inline-block!important;width:1.25em;text-align:center;margin-inline-end:0.4em;"></span>
<span class="sidebar-item-title"><?php echo $child['title']; ?></span>
</a>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
</li>
</ul>
File diff suppressed because it is too large Load Diff

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